diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4adf8d4..b76d6eb 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
@@ -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:
@@ -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
@@ -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@v7
if: failure()
with:
name: screenshots
diff --git a/.gitignore b/.gitignore
index 45361e2..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
@@ -37,3 +41,9 @@
# Ignore key files for decrypting credentials and more.
/config/credentials/*.key
+# Python venv for ML training
+/lib/recon/.venv/
+
+# Trained ONNX models (regenerate with: rake recon:train)
+/lib/recon/model_*.onnx
+
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..5c0d44e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,130 @@
+# AGENTS.md
+
+Instructions for AI agents working on TornManager.
+
+## Critical Rules
+
+- **NEVER commit and push without explicit user consent.** Always show the diff and ask before committing. Only push when the user says "commit and push" or similar.
+- **NEVER run destructive git commands** (`push --force`, `reset --hard`, `rebase`) without explicit approval.
+- **NEVER run kamal deployments ever**
+- If you are unsure about something, stop and ask. Do not guess.
+- Write code like DHH would
+- Ideally use Test driven development coding style
+
+## Project Overview
+
+TornManager is a Rails 8 management and analytics tool for the game [Torn City](https://www.torn.com). It integrates with the Torn API and TornStats API to provide faction leaders with member tracking, ranked war analytics, stat backfills, and compliance monitoring.
+
+## Tech Stack
+
+| Component | Technology |
+|------------------|-------------------------------------------------|
+| Language | Ruby 3.3.5 |
+| Framework | Rails 8.1 |
+| Database | SQLite3 (4 databases: primary, cache, queue, cable) |
+| Job Backend | Solid Queue (database-backed, runs inside Puma) |
+| Cache Backend | Solid Cache |
+| WebSocket | Solid Cable (Action Cable) |
+| Asset Pipeline | Propshaft |
+| JavaScript | Importmap + Hotwire (Turbo + Stimulus) |
+| Web Server | Puma + Thruster |
+| Auth | Custom cookie-based sessions with bcrypt |
+| Monitoring | AppSignal |
+| Deployment | Kamal to single server (Docker, amd64) |
+| CI | GitHub Actions |
+
+No Redis, no PostgreSQL, no Node.js build step. This is a "Solid trifecta" Rails 8 app.
+
+## Key Architecture Decisions
+
+### Job Queues (Solid Queue)
+
+Configured in `config/queue.yml`. Four named queues:
+
+- **`admin`** (1 thread, 1.1s polling) -- Jobs using the app owner's personal API key. Rate-limited to ~28 req/min. Base class: `AdminApiJob`. Credentials via `AdminCredentials.api_key`.
+- **`faction`** (5 threads, 0.5s polling) -- Faction-level jobs using each faction's own API key. `limits_concurrency` ensures one API call per faction at a time while allowing parallel execution across factions. Base class: `FactionApiJob`.
+- **`war`** (3 threads, 0.5s polling) -- Real-time war status polling.
+- **`default`** (3 threads, 0.1s polling) -- Orchestrator jobs, scheduled cleanup, and non-API work.
+
+Each Torn API key has independent rate limits. Jobs using different API keys can run in parallel. Use `limits_concurrency` for per-key/per-faction rate limiting rather than serializing an entire queue.
+
+Solid Queue runs in-process with Puma (`SOLID_QUEUE_IN_PUMA=true`), not as a separate worker.
+
+### Torn API Integration
+
+API wrapper models live in `app/models/torn_api/` with sub-modules for different API categories (Faction, User, Torn, Key, Market). Each API call includes a ~1 second sleep to respect rate limits.
+
+### Multi-Database
+
+Four SQLite databases, each with their own migrations directory:
+- `primary` -- application data
+- `cache` -- Solid Cache
+- `queue` -- Solid Queue
+- `cable` -- Solid Cable
+
+## Development Commands
+
+```bash
+mise exec -- bin/rails server # Start dev server (also runs Solid Queue)
+mise exec -- bin/rails test # Run full test suite (always run all tests)
+mise exec -- bin/rails test:system # Run system tests (Capybara + Selenium)
+mise exec -- bin/rubocop # Lint (rubocop-rails-omakase style)
+mise exec -- bin/brakeman --no-pager # Security scan
+mise exec -- bin/bundler-audit # Gem vulnerability scan
+mise exec -- bin/importmap audit # JS dependency audit
+```
+
+## Testing
+
+- **Run tests**: `mise exec -- bin/rails test`
+- **Always run the full suite** after making changes. The suite is fast (~4 seconds) so there's no reason to run individual test files.
+- **Framework**: Minitest with Mocha for mocking
+- **Fixtures**: Used for test data (`test/fixtures/`)
+- **Parallelization**: Enabled (`parallelize(workers: :number_of_processors)`)
+- **Session helper**: `sign_in_as(user)` and `sign_out` available in integration tests via `SessionTestHelper`
+
+## CI Pipeline
+
+Five parallel jobs on every push to `main` and every PR:
+
+1. `scan_ruby` -- Brakeman + bundler-audit
+2. `scan_js` -- importmap audit
+3. `lint` -- RuboCop with GitHub formatter
+4. `test` -- `bin/rails db:test:prepare test`
+5. `system-test` -- `bin/rails db:test:prepare test:system`
+
+All CI jobs must pass. If you introduce changes, verify they won't break lint or tests.
+
+## Code Style
+
+- **Ruby**: rubocop-rails-omakase (Rails team's opinionated defaults). No custom overrides.
+- **No conventional commits**: Commit messages are plain English, lowercase or capitalized, imperative style. Examples: "Fix sign in button disappearing on hover", "Add data coverage card and 3-column settings layout".
+- **Guard clauses**: Preferred over nested conditionals. `return if/unless` is idiomatic, but do not use `return` as the last statement in a method (Rubocop will flag it).
+
+## Directory Structure (Key Paths)
+
+```
+app/
+ controllers/
+ admin/ # Admin namespace
+ api/ # JSON API namespace
+ factions/ # Faction sub-controllers (leadership, wars, polling)
+ concerns/ # FactionAccess concern for authorization
+ models/
+ torn_api/ # Torn API wrapper models
+ torn_stats_api/ # TornStats API wrapper
+ torn/ # Torn game data models (Item, Stock)
+ jobs/
+ daily/ # Scheduled daily jobs
+ services/ # Service objects (minimal -- mostly ComplianceSummary)
+ javascript/
+ controllers/ # Stimulus controllers
+ views/
+ layouts/ # Application layouts
+config/
+ queue.yml # Solid Queue configuration
+ deploy.yml # Kamal deployment config
+test/
+ fixtures/ # Test fixtures
+ test_helpers/ # Custom test helpers
+```
diff --git a/Gemfile b/Gemfile
index bd9f0f3..25db408 100644
--- a/Gemfile
+++ b/Gemfile
@@ -42,6 +42,10 @@ gem "thruster", require: false
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
gem "image_processing", "~> 1.2"
+gem "appsignal"
+gem "onnxruntime"
+gem "discordrb", require: false
+
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
diff --git a/Gemfile.lock b/Gemfile.lock
index f44c6a2..50bfff7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,31 +1,31 @@
GEM
remote: https://rubygems.org/
specs:
- action_text-trix (2.1.16)
+ action_text-trix (2.1.19)
railties
- actioncable (8.1.2)
- actionpack (= 8.1.2)
- activesupport (= 8.1.2)
+ actioncable (8.1.3)
+ actionpack (= 8.1.3)
+ activesupport (= 8.1.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
- actionmailbox (8.1.2)
- actionpack (= 8.1.2)
- activejob (= 8.1.2)
- activerecord (= 8.1.2)
- activestorage (= 8.1.2)
- activesupport (= 8.1.2)
+ actionmailbox (8.1.3)
+ actionpack (= 8.1.3)
+ activejob (= 8.1.3)
+ activerecord (= 8.1.3)
+ activestorage (= 8.1.3)
+ activesupport (= 8.1.3)
mail (>= 2.8.0)
- actionmailer (8.1.2)
- actionpack (= 8.1.2)
- actionview (= 8.1.2)
- activejob (= 8.1.2)
- activesupport (= 8.1.2)
+ actionmailer (8.1.3)
+ actionpack (= 8.1.3)
+ actionview (= 8.1.3)
+ activejob (= 8.1.3)
+ activesupport (= 8.1.3)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
- actionpack (8.1.2)
- actionview (= 8.1.2)
- activesupport (= 8.1.2)
+ actionpack (8.1.3)
+ actionview (= 8.1.3)
+ activesupport (= 8.1.3)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
@@ -33,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
- actiontext (8.1.2)
+ actiontext (8.1.3)
action_text-trix (~> 2.1.15)
- actionpack (= 8.1.2)
- activerecord (= 8.1.2)
- activestorage (= 8.1.2)
- activesupport (= 8.1.2)
+ actionpack (= 8.1.3)
+ activerecord (= 8.1.3)
+ activestorage (= 8.1.3)
+ activesupport (= 8.1.3)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
- actionview (8.1.2)
- activesupport (= 8.1.2)
+ actionview (8.1.3)
+ activesupport (= 8.1.3)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
- activejob (8.1.2)
- activesupport (= 8.1.2)
+ activejob (8.1.3)
+ activesupport (= 8.1.3)
globalid (>= 0.3.6)
- activemodel (8.1.2)
- activesupport (= 8.1.2)
- activerecord (8.1.2)
- activemodel (= 8.1.2)
- activesupport (= 8.1.2)
+ activemodel (8.1.3)
+ activesupport (= 8.1.3)
+ activerecord (8.1.3)
+ activemodel (= 8.1.3)
+ activesupport (= 8.1.3)
timeout (>= 0.4.0)
- activestorage (8.1.2)
- actionpack (= 8.1.2)
- activejob (= 8.1.2)
- activerecord (= 8.1.2)
- activesupport (= 8.1.2)
+ activestorage (8.1.3)
+ actionpack (= 8.1.3)
+ activejob (= 8.1.3)
+ activerecord (= 8.1.3)
+ activesupport (= 8.1.3)
marcel (~> 1.0)
- activesupport (8.1.2)
+ activesupport (8.1.3)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -75,19 +75,22 @@ 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.9.1)
+ logger
+ rack (>= 2.0.0)
ast (2.4.3)
base64 (0.3.0)
- bcrypt (3.1.21)
+ bcrypt (3.1.22)
bcrypt_pbkdf (1.1.2)
bcrypt_pbkdf (1.1.2-arm64-darwin)
bcrypt_pbkdf (1.1.2-x86_64-darwin)
- bigdecimal (4.0.1)
+ bigdecimal (4.1.2)
bindex (0.8.1)
- bootsnap (1.21.1)
+ bootsnap (1.24.6)
msgpack (~> 1.2)
- brakeman (7.1.2)
+ brakeman (8.0.5)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -102,34 +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.8.0)
+ base64 (~> 0.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.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.1)
+ erb (6.0.4)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
- 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)
+ event_emitter (0.2.6)
+ 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)
- i18n (1.14.8)
+ http-accept (1.7.0)
+ http-cookie (1.1.6)
+ domain_name (~> 0.5)
+ i18n (1.15.2)
concurrent-ruby (~> 1.0)
image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6)
@@ -139,15 +156,16 @@ GEM
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.2)
- irb (1.16.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.18.0)
- kamal (2.10.1)
+ json (2.21.1)
+ kamal (2.12.0)
activesupport (>= 7.0)
base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0)
@@ -158,24 +176,29 @@ 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.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.0701)
mini_magick (5.3.1)
logger
mini_mime (1.1.5)
- minitest (6.0.1)
+ minitest (6.0.6)
+ drb (~> 2.0)
prism (~> 1.5)
mission_control-jobs (1.1.0)
actioncable (>= 7.1)
@@ -187,10 +210,11 @@ GEM
railties (>= 7.1)
stimulus-rails
turbo-rails
- mocha (3.0.1)
+ mocha (3.1.0)
ruby2_keywords (>= 0.0.5)
- msgpack (1.8.0)
- net-imap (0.6.2)
+ msgpack (1.8.3)
+ mutex_m (0.3.0)
+ net-imap (0.6.4.1)
date
net-protocol
net-pop (0.1.2)
@@ -203,77 +227,87 @@ GEM
net-ssh (>= 5.0.0, < 8.0.0)
net-smtp (0.5.1)
net-protocol
- net-ssh (7.3.0)
+ net-ssh (7.3.3)
+ netrc (0.11.0)
nio4r (2.7.5)
- nokogiri (1.19.0-aarch64-linux-gnu)
+ nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.0-aarch64-linux-musl)
+ nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4)
- nokogiri (1.19.0-arm-linux-gnu)
+ nokogiri (1.19.4-arm-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.0-arm-linux-musl)
+ nokogiri (1.19.4-arm-linux-musl)
racc (~> 1.4)
- nokogiri (1.19.0-arm64-darwin)
+ nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4)
- nokogiri (1.19.0-x86_64-darwin)
+ nokogiri (1.19.4-x86_64-darwin)
racc (~> 1.4)
- nokogiri (1.19.0-x86_64-linux-gnu)
+ nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4)
- nokogiri (1.19.0-x86_64-linux-musl)
+ nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4)
+ onnxruntime (0.11.4)
+ ffi
+ onnxruntime (0.11.4-aarch64-linux)
+ ffi
+ onnxruntime (0.11.4-arm64-darwin)
+ ffi
+ onnxruntime (0.11.4-x86_64-darwin)
+ ffi
+ 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.1)
+ 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.8.0)
- propshaft (1.3.1)
+ prism (1.9.0)
+ 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 (7.1.0)
+ public_suffix (7.0.5)
+ puma (8.0.2)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
- rack (3.2.4)
- rack-session (2.1.1)
+ rack (3.2.6)
+ rack-session (2.1.2)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
- rails (8.1.2)
- actioncable (= 8.1.2)
- actionmailbox (= 8.1.2)
- actionmailer (= 8.1.2)
- actionpack (= 8.1.2)
- actiontext (= 8.1.2)
- actionview (= 8.1.2)
- activejob (= 8.1.2)
- activemodel (= 8.1.2)
- activerecord (= 8.1.2)
- activestorage (= 8.1.2)
- activesupport (= 8.1.2)
+ rails (8.1.3)
+ actioncable (= 8.1.3)
+ actionmailbox (= 8.1.3)
+ actionmailer (= 8.1.3)
+ actionpack (= 8.1.3)
+ actiontext (= 8.1.3)
+ actionview (= 8.1.3)
+ activejob (= 8.1.3)
+ activemodel (= 8.1.3)
+ activerecord (= 8.1.3)
+ activestorage (= 8.1.3)
+ activesupport (= 8.1.3)
bundler (>= 1.15.0)
- railties (= 8.1.2)
+ railties (= 8.1.3)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
- rails-html-sanitizer (1.6.2)
- loofah (~> 2.21)
+ rails-html-sanitizer (1.7.0)
+ loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
- railties (8.1.2)
- actionpack (= 8.1.2)
- activesupport (= 8.1.2)
+ railties (8.1.3)
+ actionpack (= 8.1.3)
+ activesupport (= 8.1.3)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
@@ -281,34 +315,44 @@ GEM
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
- rake (13.3.1)
- rdoc (7.1.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)
+ 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.82.1)
+ 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.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)
+ 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)
@@ -323,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.39.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)
@@ -340,21 +384,21 @@ GEM
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
- solid_queue (1.3.1)
+ solid_queue (1.4.0)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
fugit (~> 1.11)
railties (>= 7.1)
thor (>= 1.3.1)
- sqlite3 (2.9.0-aarch64-linux-gnu)
- sqlite3 (2.9.0-aarch64-linux-musl)
- sqlite3 (2.9.0-arm-linux-gnu)
- sqlite3 (2.9.0-arm-linux-musl)
- sqlite3 (2.9.0-arm64-darwin)
- sqlite3 (2.9.0-x86_64-darwin)
- sqlite3 (2.9.0-x86_64-linux-gnu)
- sqlite3 (2.9.0-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
@@ -364,16 +408,15 @@ GEM
ostruct
stimulus-rails (1.3.4)
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)
- timeout (0.6.0)
+ 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.20)
+ turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
@@ -383,19 +426,23 @@ GEM
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
- web-console (4.2.1)
- actionview (>= 6.0.0)
- activemodel (>= 6.0.0)
+ web-console (4.3.0)
+ actionview (>= 8.0.0)
bindex (>= 0.4.0)
- railties (>= 6.0.0)
+ railties (>= 8.0.0)
websocket (1.2.11)
- websocket-driver (0.8.0)
+ websocket-client-simple (0.9.0)
+ base64
+ event_emitter
+ mutex_m
+ websocket
+ 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.4)
+ zeitwerk (2.8.2)
PLATFORMS
aarch64-linux
@@ -410,18 +457,21 @@ PLATFORMS
x86_64-linux-musl
DEPENDENCIES
+ appsignal
bcrypt (~> 3.1.7)
bootsnap
brakeman
bundler-audit
capybara
debug
+ discordrb
image_processing (~> 1.2)
importmap-rails
jbuilder
kamal
mission_control-jobs
mocha
+ onnxruntime
propshaft
puma (>= 5.0)
rails (~> 8.1.1)
diff --git a/README.md b/README.md
index 7db80e4..e4d914d 100644
--- a/README.md
+++ b/README.md
@@ -1,24 +1,4 @@
-# README
+# TornManager
-This README would normally document whatever steps are necessary to get the
-application up and running.
+A web-based tool for Torn.com players to track personal statistics, monitor progress, and manage subscriptions.
-Things you may want to cover:
-
-* Ruby version
-
-* System dependencies
-
-* Configuration
-
-* Database creation
-
-* Database initialization
-
-* How to run the test suite
-
-* Services (job queues, cache servers, search engines, etc.)
-
-* Deployment instructions
-
-* ...
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index c3ccdee..4e8480c 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -1,240 +1,28 @@
/*
- * This is a manifest file that'll be compiled into application.css.
+ * TornManager - Vercel-inspired design system
*
- * 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.
+ * This file serves as the main CSS entry point.
+ * Propshaft automatically loads all CSS files in this directory.
*
- * Consider organizing styles into separate files for maintainability.
+ * 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
+ * - pages/spy_stats.css : Spy stats table + filters
+ * - pages/activity.css : Activity heatmap + member breakdown
*/
-
-html, body {
- height: 100%;
- margin: 0;
-}
-body {
- min-height: 100vh;
- display: flex;
- flex-direction: column;
- align-items: center;
- background: #111;
- color: #d1d5db;
- font-family: "Geist Mono", monospace;
-}
-
-header {
- padding-top: 2rem;
-}
-
-main {
- flex: 1;
- display: flex;
- flex-direction: column;
- align-items: center;
- width: 100%;
- max-width: 800px;
- padding-top: 2rem;
- box-sizing: border-box;
-}
-
-nav {
- display: flex;
- justify-content: center;
- margin-bottom: 3rem;
- font-size: 0.9rem;
- gap: 1.5rem;
-}
-
-footer {
- width: 100%;
- display: flex;
- text-align: center;
- justify-content: center;
- gap: 1.5rem;
-}
-
-footer img:hover {
- filter: brightness(1.2);
- transform: scale(1.1);
- transition: 0.2s;
-}
-
-h1, h2, h3 {
- color: #ffff;
-}
-
-h3 {
- margin-bottom: 0px;
-}
-
-section {
- margin-bottom: 4rem;
-}
-
-article {
- margin-bottom: 2rem;
-}
-
-a {
- text-decoration: none;
- color: #ffff;
-}
-
-a:hover h3{
- color: #e63946;
-}
-
-
-li:hover a {
- color: inherit;
- text-decoration: none;
-}
-
-li:hover {
- color: #e63946;
-}
-/* Table Styling */
-table {
- width: 100%;
- border-collapse: collapse;
- margin: 20px 0;
- font-size: 16px;
- color: #fff; /* Text color for dark background */
- background-color: #333; /* Background for table body */
-}
-
-th, td {
- padding: 12px 15px; /* Increase padding for readability */
- text-align: left;
- border: 1px solid #444; /* Dark border to fit theme */
- white-space: nowrap; /* Prevent text wrapping */
-}
-
-th {
- background-color: #444; /* Header background */
- font-weight: bold;
- text-transform: uppercase;
-}
-
-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;
- transition: color 0.2s;
- margin-top: auto;
-}
-
-.navbar-link:hover {
- color: #e63946;
-}
-
-.navbar-title {
- font-size: 1.5rem;
-}
-
-.login-container {
- flex: 1;
- display: flex;
- flex-direction: column;
- justify-content: center;
- align-items: center;
- min-height: 70vh;
- width: 100vw;
-}
-
-.login-form {
- background: #181818;
- padding: 2.5rem 3rem;
- border-radius: 8px;
- box-shadow: 0 0 20px 0 #0002;
- display: flex;
- flex-direction: column;
- align-items: center;
-}
-
-.login-form fieldset {
- border: none;
- margin: 1rem 0 2.5rem 0;
- padding: 0;
- width: 100%;
- display: flex;
- flex-direction: column;
- align-items: flex-start;
-}
-
-.login-form legend {
- margin-bottom: 2rem;
- font-size: 1.1rem;
- color: #fff;
- font-weight: bold;
- letter-spacing: 0.01em;
-}
-
-.login-form label {
- font-size: 1rem;
- margin-bottom: .5rem;
- color: #d1d5db;
-}
-
-.login-input {
- padding: 0.75rem 1rem;
- border: none;
- border-radius: 4px;
- margin-bottom: 1rem;
- background: #222;
- color: #d1d5db;
- width: 230px;
- font-size: 1rem;
-}
-
-.login-input:focus {
- outline: 2px solid #e63946;
- background: #1a1a1a;
-}
-
-.login-submit {
- background-color: #e63946;
- color: #fff;
- border: none;
- border-radius: 4px;
- padding: 0.7rem 2.2rem;
- font-size: 1.1rem;
- cursor: pointer;
- transition: background 0.18s;
- margin-top: 0.5rem;
-}
-
-.login-submit:hover, .login-submit:focus {
- background-color: #ff6b81;
-}
-
-.flash {
- margin-bottom: 1.25rem;
- padding: 0.7rem 1.5rem;
- border-radius: 4px;
- width: 100%;
- text-align: center;
- font-size: 1rem;
-}
-.flash-alert {
- background: #fff1f1;
- color: #e63946;
-}
-.flash-notice {
- background: #e0fbec;
- color: #2c8e47;
-}
diff --git a/app/assets/stylesheets/base/reset.css b/app/assets/stylesheets/base/reset.css
new file mode 100644
index 0000000..d377590
--- /dev/null
+++ b/app/assets/stylesheets/base/reset.css
@@ -0,0 +1,163 @@
+/*
+ * 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-6) 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;
+}
+
+@media (max-width: 768px) {
+ footer {
+ flex-direction: column;
+ gap: var(--space-3);
+ text-align: center;
+ font-size: 0.75rem;
+ padding: var(--space-4) var(--space-4);
+ }
+
+ .footer-left,
+ .footer-center,
+ .footer-right {
+ justify-content: center;
+ }
+
+ .footer-link {
+ font-size: 0.75rem;
+ }
+}
diff --git a/app/assets/stylesheets/base/utilities.css b/app/assets/stylesheets/base/utilities.css
new file mode 100644
index 0000000..207b264
--- /dev/null
+++ b/app/assets/stylesheets/base/utilities.css
@@ -0,0 +1,127 @@
+/*
+ * 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;
+}
+
+/* Faction Links */
+.faction-link {
+ color: inherit;
+ text-decoration: none;
+ transition: color 0.15s ease;
+}
+
+.faction-link:hover {
+ color: #ffffff;
+ text-decoration: underline;
+}
+
+/* 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..f522f49
--- /dev/null
+++ b/app/assets/stylesheets/components/badges.css
@@ -0,0 +1,194 @@
+/*
+ * 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);
+}
+
+.badge-muted {
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--gray-500);
+ border: 1px solid var(--gray-700);
+}
+
+/* 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-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;
+}
+
+.subscription-badge-inactive {
+ background: rgba(239, 68, 68, 0.1);
+ color: #ef4444;
+ border-color: rgba(239, 68, 68, 0.3);
+}
+
+@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);
+ }
+}
+
+/* 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/buttons.css b/app/assets/stylesheets/components/buttons.css
new file mode 100644
index 0000000..e5580e6
--- /dev/null
+++ b/app/assets/stylesheets/components/buttons.css
@@ -0,0 +1,216 @@
+/*
+ * Button component styles
+ */
+
+a[class*="btn-"]:hover {
+ text-decoration: none;
+}
+
+.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;
+ text-decoration: none;
+ display: inline-block;
+}
+
+a.btn-primary {
+ color: var(--gray-900);
+}
+
+a.btn-primary:hover {
+ color: var(--gray-900);
+}
+
+.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-outline {
+ background: transparent;
+ color: var(--blue-500);
+ padding: 0.625rem 1.25rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid var(--blue-500);
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ font-family: inherit;
+ font-size: 0.875rem;
+ text-decoration: none;
+ display: inline-block;
+}
+
+a.btn-outline {
+ color: var(--blue-500);
+}
+
+a.btn-outline:hover {
+ color: var(--blue-500);
+ text-decoration: none;
+}
+
+.btn-outline:hover {
+ background: rgba(59, 130, 246, 0.1);
+}
+
+.btn-danger {
+ background: #dc2626;
+ color: white;
+ padding: 0.625rem 1.25rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid #dc2626;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.2s ease;
+ 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;
+}
+
+.btn-small-active {
+ background: rgba(139, 92, 246, 0.2);
+ border-color: rgba(139, 92, 246, 0.4);
+ color: rgb(167, 139, 250);
+}
+
+.btn-small-active:hover {
+ background: rgba(139, 92, 246, 0.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);
+}
diff --git a/app/assets/stylesheets/components/cards.css b/app/assets/stylesheets/components/cards.css
new file mode 100644
index 0000000..c4d13a3
--- /dev/null
+++ b/app/assets/stylesheets/components/cards.css
@@ -0,0 +1,859 @@
+/*
+ * Card Components
+ */
+
+/* Feature Cards — reusable card grid for dashboards (admin, faction, etc.)
+ * Usage:
+ */
+.feature-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
+ gap: var(--space-6);
+ margin-top: var(--space-8);
+}
+
+.feature-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;
+}
+
+.feature-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);
+}
+
+.feature-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;
+}
+
+.feature-card:hover .feature-card-icon {
+ background: rgba(255, 255, 255, 0.1);
+ color: #ffffff;
+}
+
+.feature-card h2 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: #ffffff;
+ margin: 0 0 var(--space-3) 0;
+}
+
+.feature-card p {
+ font-size: 0.875rem;
+ color: var(--gray-400);
+ margin: 0;
+ line-height: 1.6;
+}
+
+.feature-card:hover p {
+ color: var(--gray-300);
+}
+
+.feature-card-disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+ position: relative;
+}
+
+.feature-card-disabled:hover {
+ border-color: var(--gray-800);
+ background: var(--gray-900);
+ transform: none;
+ box-shadow: none;
+}
+
+.feature-card-disabled:hover .feature-card-icon {
+ background: rgba(255, 255, 255, 0.05);
+ color: var(--gray-400);
+}
+
+.feature-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);
+}
+
+/* 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;
+}
+
+.info-card a[class*="btn-"]:hover {
+ text-decoration: none;
+}
+
+/* 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: 1.25rem;
+ margin-bottom: 1.5rem;
+}
+
+.subscription-status-header {
+ margin-bottom: 0.75rem;
+}
+
+.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-source-detail {
+ font-size: 0.75rem;
+ color: var(--gray-400);
+ margin-top: 0.25rem;
+}
+
+.subscription-refresh-form {
+ margin-top: 0.25rem;
+}
+
+.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 */
+.api-key-card {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-md);
+ padding: 2rem;
+ min-height: 360px;
+}
+
+.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;
+}
+
+@media (max-width: 480px) {
+ .subscription-card,
+ .api-key-card {
+ padding: 1.25rem;
+ min-height: auto;
+ }
+
+ .api-key-row {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.25rem;
+ }
+
+ .api-key-label {
+ min-width: auto;
+ }
+
+ .api-key-form-inline {
+ flex-direction: column;
+ }
+
+ .subscription-days {
+ font-size: 1.5rem;
+ }
+}
+
+/* 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);
+}
+
+/* Spy Configuration Card */
+.spy-config-card {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-md);
+ padding: 2rem;
+}
+
+.spy-config-keys {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-6);
+}
+
+@media (max-width: 768px) {
+ .spy-config-keys {
+ grid-template-columns: 1fr;
+ }
+}
+
+.spy-config-keys-inputs {
+ margin-top: var(--space-3);
+}
+
+.spy-config-details {
+ margin-top: 1.5rem;
+ border-top: 1px solid var(--gray-800);
+ padding-top: 1rem;
+}
+
+.spy-config-details summary {
+ cursor: pointer;
+ color: var(--gray-400);
+ font-size: 0.875rem;
+ font-weight: 500;
+ user-select: none;
+ list-style: none;
+}
+
+.spy-config-details summary::-webkit-details-marker {
+ display: none;
+}
+
+.spy-config-details summary::before {
+ content: "\25B8 ";
+ color: var(--gray-500);
+}
+
+.spy-config-details[open] summary::before {
+ content: "\25BE ";
+}
+
+.spy-config-details-content {
+ margin-top: 1rem;
+}
+
+.spy-config-method {
+ margin-bottom: 1rem;
+}
+
+.spy-config-method:last-child {
+ margin-bottom: 0;
+}
+
+.spy-config-method h4 {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--gray-200);
+ margin-bottom: 0.5rem;
+}
+
+.spy-config-method p {
+ color: var(--gray-400);
+ font-size: 0.8125rem;
+ line-height: 1.6;
+ margin-bottom: 0.25rem;
+}
+
+.spy-config-method p:last-child {
+ margin-bottom: 0;
+}
+
+/* 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 {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ margin-bottom: 0.75rem;
+}
+
+.key-delete-btn {
+ padding: 0.125rem 0.5rem;
+ background: transparent;
+ border: 1px solid var(--gray-700);
+ border-radius: var(--radius-sm);
+ color: var(--gray-500);
+ font-size: 0.6875rem;
+ font-family: "Geist Mono", monospace;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.key-delete-btn:hover {
+ background: rgba(239, 68, 68, 0.1);
+ border-color: rgba(239, 68, 68, 0.3);
+ color: #fca5a5;
+}
+
+.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;
+ 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..c780d1f
--- /dev/null
+++ b/app/assets/stylesheets/components/navigation.css
@@ -0,0 +1,420 @@
+/*
+ * 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-8);
+ 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-faction-dropdown {
+ position: relative;
+}
+
+.navbar-dropdown-left {
+ left: 0;
+ right: auto;
+}
+
+.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);
+}
+
+/* Mobile Navigation */
+.navbar-mobile {
+ display: none;
+ align-items: center;
+ gap: var(--space-3);
+ position: relative;
+}
+
+.navbar-mobile-toggle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--gray-800);
+ border: 1px solid var(--gray-700);
+ border-radius: var(--radius-md);
+ padding: var(--space-2);
+ cursor: pointer;
+ color: var(--gray-300);
+ transition: all 0.15s ease;
+}
+
+.navbar-mobile-toggle:hover {
+ background: var(--gray-700);
+ color: #ffffff;
+ border-color: var(--gray-600);
+}
+
+.navbar-mobile-menu {
+ 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: 200px;
+ opacity: 0;
+ visibility: hidden;
+ transform: translateY(-8px);
+ transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s;
+ z-index: 1000;
+ overflow: hidden;
+}
+
+.navbar-mobile-menu.navbar-dropdown-open {
+ opacity: 1;
+ visibility: visible;
+ transform: translateY(0);
+}
+
+.navbar-mobile-menu-section {
+ padding: var(--space-2) 0;
+}
+
+.navbar-mobile-menu-divider {
+ height: 1px;
+ background: var(--gray-800);
+ margin: 0;
+}
+
+.navbar-mobile-menu-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-mobile-menu-item:hover {
+ background: rgba(255, 255, 255, 0.05);
+ color: #ffffff;
+}
+
+.navbar-mobile-menu-item.active {
+ color: var(--primary-400);
+}
+
+.navbar-mobile-menu-item.disabled {
+ color: var(--gray-600);
+ cursor: not-allowed;
+}
+
+.navbar-mobile-menu-item.disabled:hover {
+ background: transparent;
+ color: var(--gray-600);
+}
+
+.navbar-mobile-menu-item.signout:hover {
+ background: rgba(239, 68, 68, 0.1);
+ color: #fca5a5;
+}
+
+.navbar-mobile-menu-label {
+ display: block;
+ padding: var(--space-2) var(--space-4);
+ color: var(--gray-500);
+ font-size: 0.75rem;
+ font-weight: 600;
+ font-family: "Geist Mono", monospace;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.navbar-mobile-menu-item-nested {
+ padding-left: var(--space-6);
+}
+
+/* Mobile breakpoint */
+@media (max-width: 768px) {
+ nav {
+ flex-wrap: wrap;
+ padding: var(--space-3) var(--space-4);
+ }
+
+ .navbar-mobile {
+ display: flex;
+ }
+
+ .navbar-center,
+ .navbar-right {
+ display: none;
+ }
+
+ .navbar-left {
+ flex: 1;
+ }
+
+ .live-api-feed {
+ position: fixed;
+ top: 60px;
+ left: var(--space-3);
+ right: var(--space-3);
+ z-index: 1000;
+ pointer-events: none;
+ }
+
+ .live-api-feed-list {
+ width: 100%;
+ }
+}
diff --git a/app/assets/stylesheets/components/tables.css b/app/assets/stylesheets/components/tables.css
new file mode 100644
index 0000000..0bcce12
--- /dev/null
+++ b/app/assets/stylesheets/components/tables.css
@@ -0,0 +1,510 @@
+/*
+ * 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);
+}
+
+/* Sortable Headers */
+th.sortable {
+ cursor: pointer;
+ user-select: none;
+ white-space: nowrap;
+ transition: color 0.15s ease;
+}
+
+th.sortable:hover {
+ color: var(--gray-200);
+}
+
+/* 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 {
+ color: var(--gray-600);
+ transition: transform 0.2s ease, color 0.15s ease;
+}
+
+.sort-arrow.active {
+ color: var(--gray-200);
+}
+
+.sort-arrow.asc {
+ transform: rotate(180deg);
+}
+
+/* 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;
+}
+
+/* Mobile horizontal scroll for tables */
+@media (max-width: 768px) {
+ .table-container {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ .table-container table {
+ min-width: 700px;
+ }
+}
+
+/* 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 h2 {
+ opacity: 0.4;
+ filter: blur(1px);
+}
+
+.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;
+ text-align: center;
+ 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;
+}
+
+/* Utility classes */
+.nowrap {
+ white-space: nowrap;
+}
+
+.muted {
+ color: var(--gray-500);
+}
+
+/* Selection tags for API calls table */
+.selections-cell {
+ max-width: 300px;
+}
+
+.selection-tag {
+ display: inline-block;
+ background: var(--gray-800);
+ color: var(--gray-300);
+ padding: 2px 8px;
+ border-radius: var(--radius-sm);
+ font-size: 0.75rem;
+ font-family: "Geist Mono", monospace;
+ margin: 2px 4px 2px 0;
+ white-space: nowrap;
+}
+
+/* Status indicators */
+.status-success {
+ color: #10b981;
+ font-weight: 500;
+}
+
+.status-error {
+ color: #ef4444;
+ font-weight: 500;
+}
+
+/* API key display */
+.api-key-display {
+ font-family: "Geist Mono", monospace;
+ color: var(--gray-500);
+}
+
+/* API ToS Disclosure */
+.api-tos-disclosure {
+ margin: var(--space-4) 0;
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+}
+
+/* Desktop: table layout */
+.api-tos-table {
+ min-width: 100%;
+ font-size: 0.8125rem;
+ border-collapse: separate;
+ border-spacing: 0;
+}
+
+.api-tos-table th {
+ background: var(--gray-900);
+ color: var(--gray-200);
+ font-size: 0.75rem;
+ font-weight: 600;
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--gray-700);
+ border-right: 1px solid var(--gray-800);
+}
+
+.api-tos-table th:last-child {
+ border-right: none;
+}
+
+.api-tos-table td {
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--gray-800);
+ border-right: 1px solid var(--gray-800);
+ vertical-align: top;
+}
+
+.api-tos-table td:last-child {
+ border-right: none;
+}
+
+.api-tos-description-row td {
+ color: var(--gray-500);
+ font-size: 0.75rem;
+ line-height: 1.5;
+}
+
+.api-tos-answer-row td {
+ color: #f87171;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ border-bottom: none;
+ line-height: 1.5;
+}
+
+/* Mobile: stacked layout */
+.api-tos-stacked {
+ display: none;
+ padding: var(--space-4);
+ gap: var(--space-3);
+}
+
+.api-tos-item {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+.api-tos-label {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: var(--gray-400);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.api-tos-value {
+ font-size: 0.75rem;
+ color: #f87171;
+ line-height: 1.4;
+}
+
+/* Force stacked layout when inside constrained containers */
+.api-tos-stacked-only .api-tos-table {
+ display: none;
+}
+
+.api-tos-stacked-only .api-tos-stacked {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+}
+
+@media (max-width: 768px) {
+ .api-tos-table {
+ display: none;
+ }
+
+ .api-tos-stacked {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ }
+}
+
+@media (max-width: 400px) {
+ .api-tos-stacked {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* 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/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/activity.css b/app/assets/stylesheets/pages/activity.css
new file mode 100644
index 0000000..44b4571
--- /dev/null
+++ b/app/assets/stylesheets/pages/activity.css
@@ -0,0 +1,361 @@
+/* Activity Section */
+.activity-section {
+ margin-top: 1rem;
+}
+
+.activity-section h2 {
+ font-size: 0.9375rem;
+ font-weight: 600;
+ margin-bottom: 0.125rem;
+}
+
+.activity-subtitle {
+ color: var(--color-text-secondary);
+ font-size: 0.75rem;
+ margin-bottom: 0.5rem;
+}
+
+/* Info Bar */
+.activity-info-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem 1.25rem;
+ font-size: 0.75rem;
+ color: var(--color-text-secondary);
+ margin-bottom: 0.75rem;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.activity-info-bar strong {
+ color: var(--color-text);
+}
+
+/* Heatmap Table */
+.activity-heatmap-wrapper {
+ overflow: auto;
+ max-height: 370px;
+}
+
+.activity-heatmap {
+ border-collapse: separate;
+ border-spacing: 2px;
+ width: 100%;
+}
+
+.activity-heatmap thead th {
+ font-weight: 500;
+ color: var(--color-text-secondary);
+ text-align: center;
+ padding: 0.125rem 0;
+ vertical-align: bottom;
+}
+
+.activity-heatmap-date-num {
+ font-size: 0.5rem;
+ color: var(--color-text-secondary);
+}
+
+.activity-heatmap-today-label {
+ color: rgb(99, 102, 241) !important;
+}
+
+.activity-heatmap-nodata {
+ opacity: 0.3;
+}
+
+.activity-heatmap-nodata-row .activity-heatmap-day {
+ opacity: 0.4;
+}
+
+/* Hour labels on Y-axis */
+.activity-heatmap-hour {
+ font-size: 0.5625rem;
+ font-weight: 500;
+ color: var(--color-text-secondary);
+ text-align: right;
+ padding-right: 0.375rem;
+ white-space: nowrap;
+ width: 2.5rem;
+}
+
+.activity-heatmap-day {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: var(--color-text-secondary);
+ text-align: right;
+ padding-right: 0.5rem;
+ white-space: nowrap;
+ width: 3rem;
+}
+
+/* Cells */
+.activity-heatmap-cell {
+ background: rgb(var(--cell-r, 239), var(--cell-g, 68), var(--cell-b, 68));
+ text-align: center;
+ vertical-align: middle;
+ position: relative;
+ cursor: default;
+ width: 2rem;
+ height: 2rem;
+ border-radius: 3px;
+}
+
+.activity-heatmap-cell:hover {
+ box-shadow: inset 0 0 0 2px var(--color-text-secondary);
+}
+
+.activity-heatmap-count {
+ font-size: 0.5625rem;
+ color: transparent;
+}
+
+.activity-heatmap-cell:hover .activity-heatmap-count {
+ color: rgba(255, 255, 255, 0.9);
+ text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
+}
+
+/* Legend */
+.activity-heatmap-legend {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ margin-top: 0.75rem;
+ justify-content: flex-end;
+}
+
+.activity-legend-label {
+ font-size: 0.6875rem;
+ color: var(--color-text-secondary);
+}
+
+.activity-legend-cell {
+ width: 1rem;
+ height: 1rem;
+ border-radius: 3px;
+}
+
+/* Member Activity Bar */
+.activity-bar-container {
+ width: 100%;
+ height: 0.5rem;
+ background: var(--color-border);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.activity-bar {
+ height: 100%;
+ background: rgb(99, 102, 241);
+ border-radius: 4px;
+ transition: width 0.3s ease;
+}
+
+/* Preview Banner */
+.activity-preview-banner {
+ background: rgba(234, 179, 8, 0.1);
+ border: 1px solid rgba(234, 179, 8, 0.3);
+ border-radius: 6px;
+ padding: 0.625rem 1rem;
+ margin-bottom: 1rem;
+ color: rgb(234, 179, 8);
+ font-size: 0.8125rem;
+ line-height: 1.5;
+}
+
+.activity-preview-banner strong {
+ color: rgb(250, 204, 21);
+}
+
+.activity-preview-progress {
+ margin-top: 0.5rem;
+ height: 4px;
+ background: rgba(234, 179, 8, 0.15);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.activity-preview-progress-bar {
+ height: 100%;
+ background: rgb(234, 179, 8);
+ border-radius: 2px;
+ transition: width 0.5s ease;
+}
+
+.activity-preview-progress-label {
+ font-size: 0.75rem;
+ color: rgba(234, 179, 8, 0.7);
+ margin-top: 0.25rem;
+ display: inline-block;
+}
+
+/* Risk Badges */
+.activity-risk-badge {
+ font-size: 0.75rem;
+ font-weight: 500;
+ padding: 0.125rem 0.5rem;
+ border-radius: 4px;
+ white-space: nowrap;
+}
+
+.activity-risk-critical {
+ background: rgba(239, 68, 68, 0.15);
+ color: rgb(239, 68, 68);
+}
+
+.activity-risk-high {
+ background: rgba(249, 115, 22, 0.15);
+ color: rgb(249, 115, 22);
+}
+
+.activity-risk-medium {
+ background: rgba(234, 179, 8, 0.15);
+ color: rgb(234, 179, 8);
+}
+
+.activity-risk-low {
+ background: rgba(34, 197, 94, 0.15);
+ color: rgb(34, 197, 94);
+}
+
+/* Chain Coverage - Danger Windows */
+.chain-danger-windows {
+ margin-bottom: 1rem;
+}
+
+.chain-danger-windows h3 {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: rgb(239, 68, 68);
+ margin-bottom: 0.5rem;
+}
+
+.chain-danger-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.chain-danger-card {
+ background: rgba(239, 68, 68, 0.08);
+ border: 1px solid rgba(239, 68, 68, 0.2);
+ border-radius: 6px;
+ padding: 0.5rem 0.75rem;
+ min-width: 180px;
+}
+
+.chain-danger-time {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: rgb(239, 68, 68);
+}
+
+.chain-danger-detail {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+ margin-top: 0.25rem;
+}
+
+.chain-danger-duration {
+ font-size: 0.75rem;
+ color: var(--color-text-secondary);
+}
+
+.chain-danger-avg {
+ font-size: 0.75rem;
+ color: var(--color-text-secondary);
+}
+
+/* Chain Coverage - Hourly Chart */
+.chain-hourly-chart {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.chain-hour-row {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ height: 1.125rem;
+}
+
+.chain-hour-label {
+ font-size: 0.5625rem;
+ color: var(--color-text-secondary);
+ width: 2.5rem;
+ text-align: right;
+ flex-shrink: 0;
+}
+
+.chain-hour-bar-bg {
+ flex: 1;
+ height: 0.75rem;
+ background: var(--color-border);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.chain-hour-bar {
+ height: 100%;
+ border-radius: 2px;
+ transition: width 0.3s ease;
+}
+
+.chain-hour-value {
+ font-size: 0.5625rem;
+ color: var(--color-text-secondary);
+ width: 1.5rem;
+ text-align: left;
+ flex-shrink: 0;
+}
+
+/* Member Timeline */
+.activity-timeline-header {
+ width: 100%;
+}
+
+.activity-timeline-hours {
+ display: flex;
+ gap: 1px;
+}
+
+.activity-timeline-hours span {
+ flex: 1;
+ text-align: center;
+ font-size: 0.5rem;
+ color: var(--color-text-secondary);
+}
+
+.activity-member-timeline {
+ display: flex;
+ gap: 1px;
+}
+
+.activity-member-cell {
+ flex: 1;
+ height: 1.25rem;
+ border-radius: 2px;
+ background: rgb(var(--cell-r, 239), var(--cell-g, 68), var(--cell-b, 68));
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .activity-section-header {
+ flex-direction: column;
+ }
+
+ .activity-heatmap-hour {
+ font-size: 0.5625rem;
+ width: 2.5rem;
+ }
+
+ .activity-heatmap-date-label {
+ font-size: 0.625rem;
+ }
+
+ .activity-heatmap-date-num {
+ font-size: 0.5625rem;
+ }
+}
diff --git a/app/assets/stylesheets/pages/admin.css b/app/assets/stylesheets/pages/admin.css
new file mode 100644
index 0000000..24eb8b0
--- /dev/null
+++ b/app/assets/stylesheets/pages/admin.css
@@ -0,0 +1,1253 @@
+/*
+ * 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 — compact variant */
+.admin-dashboard .feature-cards {
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: var(--space-4);
+ margin-top: var(--space-6);
+}
+
+.admin-dashboard .feature-card {
+ padding: var(--space-4) var(--space-5);
+}
+
+.admin-dashboard .feature-card-icon {
+ width: 36px;
+ height: 36px;
+ margin-bottom: var(--space-3);
+}
+
+.admin-dashboard .feature-card h2 {
+ font-size: 0.9375rem;
+ margin-bottom: var(--space-2);
+}
+
+.admin-dashboard .feature-card p {
+ font-size: 0.75rem;
+}
+
+/* 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;
+}
+
+/* Admin Stats */
+.admin-stats {
+ max-width: 1400px;
+ margin: 0 auto;
+}
+
+.admin-stats-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3, 12px);
+ margin-bottom: var(--space-4, 16px);
+}
+
+.admin-stats-header h1 {
+ margin: 0;
+ font-size: 1.25rem;
+ color: var(--gray-100, #f5f5f5);
+}
+
+.admin-stats-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--space-3, 12px);
+}
+
+.admin-stats-hint {
+ font-size: 0.625rem;
+ color: var(--gray-500);
+ font-weight: 400;
+}
+
+.admin-center {
+ text-align: center !important;
+ width: 2.5rem;
+}
+
+.admin-stats-panel {
+ background: var(--gray-900, #171717);
+ border: 1px solid var(--gray-800, #262626);
+ border-radius: var(--radius-lg, 8px);
+ padding: var(--space-3, 12px);
+}
+
+.admin-stats-panel h2 {
+ margin: 0 0 var(--space-2, 8px) 0;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: var(--gray-500, #737373);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.admin-stats-rows {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.admin-stat-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 3px 0;
+}
+
+.admin-stat-label {
+ font-size: 0.8125rem;
+ color: var(--gray-400, #a3a3a3);
+}
+
+.admin-stat-value {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--gray-100, #f5f5f5);
+ font-family: "Geist Mono", monospace;
+}
+
+.admin-stats-retention {
+ font-size: 0.5625rem;
+ font-weight: 400;
+ color: var(--gray-600, #525252);
+ text-transform: none;
+ letter-spacing: 0;
+}
+
+.admin-stat-value.success, .admin-stats-table td.success { color: #22c55e; }
+.admin-stat-value.warning, .admin-stats-table td.warning { color: #f59e0b; }
+.admin-stat-value.error, .admin-stats-table td.error { color: #ef4444; }
+
+.admin-stats-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: var(--space-2, 8px);
+ border-top: 1px solid var(--gray-800, #262626);
+ padding-top: var(--space-2, 8px);
+}
+
+.admin-stats-table th,
+.admin-stats-table td {
+ padding: 3px 0;
+ text-align: left;
+ font-size: 0.75rem;
+}
+
+.admin-stats-table th {
+ color: var(--gray-600, #525252);
+ font-weight: 500;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-size: 0.625rem;
+ padding-bottom: 4px;
+}
+
+.admin-stats-table td {
+ color: var(--gray-300, #d4d4d4);
+ border-top: 1px solid var(--gray-800, #1c1c1c);
+}
+
+.admin-stats-table td:last-child,
+.admin-stats-table th:last-child {
+ text-align: right;
+ font-family: "Geist Mono", monospace;
+}
+
+@media (max-width: 900px) {
+ .admin-stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (max-width: 600px) {
+ .admin-stats-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Admin Recon */
+.recon-stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: var(--space-4);
+ margin-bottom: var(--space-6);
+}
+
+.recon-stat-card {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-lg);
+ padding: var(--space-5);
+ text-align: center;
+}
+
+.recon-stat-label {
+ font-size: 0.8125rem;
+ color: var(--gray-400);
+ margin-bottom: var(--space-2);
+}
+
+.recon-stat-value {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--gray-100);
+ font-family: "Geist Mono", monospace;
+}
+
+.recon-stat-value-sm {
+ font-size: 1rem;
+}
+
+.recon-import-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-import-card h2 {
+ margin: 0 0 var(--space-2);
+ font-size: 1.125rem;
+}
+
+.recon-import-hint {
+ color: var(--gray-400);
+ font-size: 0.8125rem;
+ margin-bottom: var(--space-4);
+}
+
+.recon-import-textarea {
+ width: 100%;
+ 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.75rem;
+ padding: var(--space-4);
+ resize: vertical;
+ line-height: 1.5;
+}
+
+.recon-import-textarea:focus {
+ outline: none;
+ border-color: var(--gray-600);
+}
+
+.recon-import-textarea::placeholder {
+ color: var(--gray-600);
+}
+
+.recon-import-actions {
+ margin-top: var(--space-4);
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+}
+
+.recon-import-note {
+ color: var(--gray-500);
+ font-size: 0.75rem;
+ margin: 0;
+}
+
+.recon-samples-container {
+ display: flex;
+ flex-direction: column;
+ max-height: 600px;
+}
+
+.recon-samples-container h2 {
+ flex-shrink: 0;
+}
+
+.recon-samples-wrapper {
+ flex: 1;
+ overflow: auto;
+ min-height: 0;
+ scrollbar-color: var(--gray-700) transparent;
+}
+
+.recon-samples-wrapper::-webkit-scrollbar {
+ width: 6px;
+}
+
+.recon-samples-wrapper::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.recon-samples-wrapper::-webkit-scrollbar-thumb {
+ background: var(--gray-800);
+ border-radius: 3px;
+}
+
+.recon-samples-wrapper::-webkit-scrollbar-thumb:hover {
+ background: var(--gray-700);
+}
+
+.recon-samples-wrapper thead {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: var(--gray-900);
+}
+
+.recon-sample-row {
+ cursor: pointer;
+}
+
+.recon-sample-row:hover {
+ background-color: rgba(255, 255, 255, 0.05);
+}
+
+.recon-sample-detail td {
+ background: var(--gray-1000);
+ padding: var(--space-4) var(--space-6);
+}
+
+.recon-sample-features {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ gap: var(--space-2) var(--space-4);
+}
+
+.recon-feature {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.recon-feature-label {
+ font-size: 0.6875rem;
+ color: var(--gray-500);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.recon-feature-value {
+ font-size: 0.8125rem;
+ color: var(--gray-200);
+ font-family: "Geist Mono", monospace;
+}
+
+/* Snapshot Management */
+.stats-section {
+ background: var(--card-bg, #171717);
+ border: 1px solid var(--border-color, #262626);
+ border-radius: 8px;
+ padding: 24px;
+ margin-bottom: 24px;
+}
+
+.stats-section h2 {
+ margin: 0 0 16px 0;
+ font-size: 1.25rem;
+ color: var(--text-primary, #e5e5e5);
+}
+
+.stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: 16px;
+}
+
+.stat-card {
+ background: var(--bg-secondary, #0a0a0a);
+ border: 1px solid var(--border-color, #262626);
+ border-radius: 8px;
+ padding: 20px;
+ text-align: center;
+}
+
+.stat-value {
+ font-size: 1.75rem;
+ font-weight: 700;
+ color: var(--text-primary, #e5e5e5);
+ font-family: 'Geist Mono', monospace;
+}
+
+.stat-value.success { color: #22c55e; }
+.stat-value.warning { color: #f59e0b; }
+.stat-value.error { color: #ef4444; }
+
+.stat-label {
+ font-size: 0.75rem;
+ color: var(--text-muted, #737373);
+ text-transform: uppercase;
+ margin-top: 8px;
+ letter-spacing: 0.05em;
+}
+
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.875rem;
+}
+
+.data-table th,
+.data-table td {
+ padding: 12px 16px;
+ text-align: left;
+ border-bottom: 1px solid var(--border-color, #262626);
+}
+
+.data-table th {
+ color: var(--text-muted, #737373);
+ font-weight: 600;
+ text-transform: uppercase;
+ font-size: 0.75rem;
+ background: var(--bg-secondary, #0a0a0a);
+}
+
+.data-table td {
+ color: var(--text-primary, #e5e5e5);
+}
+
+.text-muted {
+ color: var(--text-muted, #737373);
+}
+
+.badge {
+ display: inline-block;
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ font-family: 'Geist Mono', monospace;
+}
+
+.badge-warning {
+ background: rgba(245, 158, 11, 0.2);
+ color: #f59e0b;
+}
+
+.badge-info {
+ background: rgba(96, 165, 250, 0.2);
+ color: #60a5fa;
+}
+
+.btn-small {
+ padding: 6px 12px;
+ font-size: 0.75rem;
+ border-radius: 4px;
+ cursor: pointer;
+ font-family: 'Geist Mono', monospace;
+ font-weight: 600;
+ border: none;
+ margin-right: 4px;
+}
+
+.details-row td {
+ background: var(--bg-secondary, #0a0a0a);
+ padding: 16px;
+}
+
+.missing-dates-container {
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.missing-dates-list {
+ margin-top: 8px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.date-tag {
+ display: inline-block;
+ padding: 4px 8px;
+ background: var(--card-bg, #171717);
+ border: 1px solid var(--border-color, #262626);
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-family: 'Geist Mono', monospace;
+ color: var(--text-muted, #a3a3a3);
+}
+
+.empty-state {
+ text-align: center;
+ color: var(--text-muted, #737373);
+ padding: 32px;
+ font-style: italic;
+}
+
+.flash-notice {
+ background: rgba(34, 197, 94, 0.1);
+ border: 1px solid #22c55e;
+ color: #22c55e;
+ padding: 12px 16px;
+ border-radius: 8px;
+ margin-bottom: 24px;
+ font-size: 0.875rem;
+}
+
+/* API Logs */
+.stat-sublabel {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ margin-top: 0.25rem;
+}
+
+.error-text {
+ color: #e74c3c;
+}
+
+.error-row {
+ background-color: rgba(231, 76, 60, 0.1);
+}
+
+.status-badge {
+ display: inline-block;
+ padding: 0.25rem 0.75rem;
+ border-radius: 4px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.status-badge.success {
+ background-color: #27ae60;
+ color: white;
+}
+
+.status-badge.error {
+ background-color: #e74c3c;
+ color: white;
+}
+
+.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);
+}
+
+/* Recon Stats */
+.recon-section-title {
+ font-size: 0.9375rem;
+ font-weight: 600;
+ margin: 1.5rem 0 0.75rem;
+ color: var(--color-text);
+}
+
+.recon-feature-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+ gap: 0.75rem;
+}
+
+.recon-feature-card {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: 8px;
+ padding: 0.75rem;
+}
+
+.recon-feature-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+.recon-feature-header h3 {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ margin: 0;
+}
+
+.recon-histogram {
+ display: flex;
+ align-items: flex-end;
+ gap: 1px;
+ height: 60px;
+ margin-bottom: 0.5rem;
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.recon-histogram-bar {
+ flex: 1;
+ background: rgb(99, 102, 241);
+ border-radius: 1px 1px 0 0;
+ min-height: 1px;
+ transition: background 0.15s;
+}
+
+.recon-histogram-bar:hover {
+ background: rgb(129, 132, 255);
+}
+
+.recon-feature-stats {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+}
+
+.recon-stat-row {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.6875rem;
+ color: var(--color-text-secondary);
+}
+
+.recon-stat-row span:last-child {
+ font-weight: 500;
+ color: var(--color-text);
+}
+
+/* Outlier Filter */
+.recon-outlier-filter {
+ margin: 0.75rem 0;
+}
+
+.recon-filter-form {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.8125rem;
+}
+
+.recon-filter-form label {
+ color: var(--color-text-secondary);
+}
+
+.recon-filter-input {
+ width: 4rem;
+ padding: 0.25rem 0.5rem;
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: 4px;
+ color: var(--color-text);
+ font-size: 0.8125rem;
+}
+
+/* Data Quality Warnings */
+.recon-warnings {
+ margin-bottom: 1rem;
+}
+
+.recon-warning-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.recon-warning-item {
+ font-size: 0.75rem;
+ padding: 0.375rem 0.75rem;
+ border-radius: 4px;
+ border-left: 3px solid;
+}
+
+.recon-warning-high_zeros,
+.recon-warning-low_variance {
+ background: rgba(239, 68, 68, 0.08);
+ border-color: rgb(239, 68, 68);
+ color: rgb(239, 68, 68);
+}
+
+.recon-warning-skewed {
+ background: rgba(234, 179, 8, 0.08);
+ border-color: rgb(234, 179, 8);
+ color: rgb(234, 179, 8);
+}
+
+.recon-warning-moderate_zeros {
+ background: rgba(249, 115, 22, 0.08);
+ border-color: rgb(249, 115, 22);
+ color: rgb(249, 115, 22);
+}
+
+.recon-warning-item strong {
+ color: inherit;
+}
+
+/* Total Stats Distribution Chart */
+.recon-total-stats-chart {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: 8px;
+ padding: 1rem;
+ margin-bottom: 1rem;
+}
+
+.recon-total-histogram {
+ display: flex;
+ align-items: flex-end;
+ gap: 2px;
+ height: 150px;
+ border-bottom: 1px solid var(--gray-800);
+ margin-bottom: 0.75rem;
+}
+
+.recon-total-histogram {
+ position: relative;
+}
+
+.recon-total-bin {
+ flex: 1;
+ height: 100%;
+ display: flex;
+ align-items: flex-end;
+}
+
+.recon-total-bar {
+ width: 100%;
+ background: rgb(99, 102, 241);
+ border-radius: 2px 2px 0 0;
+ min-height: 1px;
+ transition: background 0.15s;
+}
+
+.recon-total-bin:hover .recon-total-bar {
+ background: rgb(129, 132, 255);
+}
+
+.recon-normal-overlay {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+}
+
+.recon-total-stats-info {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.375rem 1.25rem;
+ font-size: 0.75rem;
+ color: var(--color-text-secondary);
+}
+
+.recon-total-stats-info strong {
+ color: var(--color-text);
+}
+
+/* Admin Subscriptions */
+.admin-sub-grant {
+ margin-bottom: var(--space-4);
+}
+
+.admin-sub-grant-form {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+}
+
+.admin-sub-input {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: 4px;
+ color: var(--color-text);
+ padding: 0.375rem 0.5rem;
+ font-size: 0.75rem;
+ width: 120px;
+}
+
+.admin-sub-input-sm {
+ width: 70px;
+}
+
+.admin-sub-sections {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-4);
+ margin-bottom: var(--space-4);
+}
+
+@media (max-width: 900px) {
+ .admin-sub-sections {
+ grid-template-columns: 1fr;
+ }
+}
+
+.admin-sub-section {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: 8px;
+ padding: var(--space-4);
+}
+
+.admin-sub-heading {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ margin: 0 0 var(--space-3) 0;
+ cursor: default;
+}
+
+.admin-sub-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.75rem;
+}
+
+.admin-sub-table th {
+ text-align: left;
+ color: var(--gray-400);
+ font-weight: 500;
+ font-size: 0.625rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 0.25rem 0.5rem;
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.admin-sub-table td {
+ padding: 0.375rem 0.5rem;
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.admin-sub-table-compact {
+ font-size: 0.6875rem;
+}
+
+.admin-sub-table-compact td {
+ padding: 0.25rem 0.5rem;
+ color: var(--color-text-secondary);
+}
+
+.admin-sub-empty {
+ font-size: 0.75rem;
+ color: var(--color-text-secondary);
+}
+
+.admin-sub-payments {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: 8px;
+ padding: var(--space-4);
+ margin-bottom: var(--space-4);
+}
+
+.admin-sub-payments summary {
+ cursor: pointer;
+ user-select: none;
+}
+
+.admin-sub-payments[open] summary {
+ margin-bottom: var(--space-3);
+}
+
+.admin-sub-info {
+ font-size: 0.75rem;
+ color: var(--color-text-secondary);
+ line-height: 1.5;
+}
+
+.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;
+}
+
+.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;
+}
+
+/* ── 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/assets/stylesheets/pages/armory.css b/app/assets/stylesheets/pages/armory.css
new file mode 100644
index 0000000..676eef0
--- /dev/null
+++ b/app/assets/stylesheets/pages/armory.css
@@ -0,0 +1,316 @@
+/* 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);
+}
+
+/* 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;
+ font-size: 0.75rem;
+}
+
+.armory-chip {
+ display: inline-block;
+ padding: 2px 6px;
+ margin: 1px;
+ border-radius: 4px;
+ font-size: 0.625rem;
+}
+
+.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;
+}
+
+/* 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;
+}
+
+.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-scroll {
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.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.25rem 0.75rem;
+ border-bottom: 1px solid var(--gray-850, var(--gray-800));
+ font-size: 0.75rem;
+}
+
+/* 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/assets/stylesheets/pages/content.css b/app/assets/stylesheets/pages/content.css
new file mode 100644
index 0000000..1ec0699
--- /dev/null
+++ b/app/assets/stylesheets/pages/content.css
@@ -0,0 +1,74 @@
+.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;
+ scroll-margin-top: var(--space-8);
+}
+
+.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 h4 {
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: var(--gray-300);
+ margin-bottom: var(--space-2);
+ margin-top: var(--space-4);
+}
+
+.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);
+}
+
+.content-section code {
+ font-size: 0.8125rem;
+ background: var(--gray-900);
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+ color: var(--gray-300);
+}
diff --git a/app/assets/stylesheets/pages/faction.css b/app/assets/stylesheets/pages/faction.css
new file mode 100644
index 0000000..709434c
--- /dev/null
+++ b/app/assets/stylesheets/pages/faction.css
@@ -0,0 +1,843 @@
+/*
+ * Faction page specific styles
+ */
+
+/* Faction Dashboard */
+.dashboard-section {
+ margin-bottom: var(--space-8);
+}
+
+.dashboard-section-header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ margin-bottom: var(--space-4);
+}
+
+.dashboard-section-header h2 {
+ color: var(--gray-200);
+ font-size: 1rem;
+ font-weight: 600;
+ margin: 0;
+}
+
+.dashboard-section-link {
+ color: var(--gray-400);
+ font-size: 0.8125rem;
+ text-decoration: none;
+ transition: color 0.15s ease;
+}
+
+.dashboard-section-link:hover {
+ color: #ffffff;
+}
+
+.dashboard-stats-row {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: var(--space-3);
+ margin-bottom: var(--space-3);
+}
+
+.dashboard-stat-card {
+ padding: var(--space-4);
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-md);
+ text-align: center;
+}
+
+.dashboard-stat-compliant {
+ border-color: rgba(34, 197, 94, 0.2);
+ background: rgba(34, 197, 94, 0.05);
+}
+
+.dashboard-stat-warning {
+ border-color: rgba(234, 179, 8, 0.2);
+ background: rgba(234, 179, 8, 0.05);
+}
+
+.dashboard-stat-danger {
+ border-color: rgba(239, 68, 68, 0.2);
+ background: rgba(239, 68, 68, 0.05);
+}
+
+.dashboard-stat-value {
+ display: block;
+ font-size: 1.75rem;
+ font-weight: 700;
+ color: var(--gray-100);
+ line-height: 1;
+ margin-bottom: var(--space-1);
+}
+
+.dashboard-stat-compliant .dashboard-stat-value {
+ color: #22c55e;
+}
+
+.dashboard-stat-warning .dashboard-stat-value {
+ color: #eab308;
+}
+
+.dashboard-stat-danger .dashboard-stat-value {
+ color: #ef4444;
+}
+
+.dashboard-stat-label {
+ font-size: 0.75rem;
+ color: var(--gray-400);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-weight: 600;
+}
+
+.dashboard-targets {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ color: var(--gray-500);
+ font-size: 0.8125rem;
+}
+
+.dashboard-target-item {
+ color: var(--gray-400);
+}
+
+.dashboard-target-divider {
+ color: var(--gray-600);
+}
+
+/* Worst Performers Mini Table */
+.dashboard-mini-table {
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+}
+
+.dashboard-mini-table table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.dashboard-mini-table th {
+ padding: var(--space-2) var(--space-4);
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--gray-400);
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ text-align: left;
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.dashboard-mini-table td {
+ padding: var(--space-3) var(--space-4);
+ color: var(--gray-300);
+ font-size: 0.875rem;
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.dashboard-mini-table tr:last-child td {
+ border-bottom: none;
+}
+
+.dashboard-mini-table-row-danger td {
+ color: #fca5a5;
+}
+
+.dashboard-mini-table-row-warning td {
+ color: #fde68a;
+}
+
+.dashboard-mini-table-row-compliant td {
+ color: #86efac;
+}
+
+.dashboard-member-link {
+ color: inherit;
+ text-decoration: none;
+ transition: color 0.15s ease;
+}
+
+.dashboard-member-link:hover {
+ color: #ffffff;
+ text-decoration: underline;
+}
+
+/* Compact feature cards for dashboard "More Tools" */
+.feature-cards-compact {
+ grid-template-columns: repeat(2, 1fr);
+}
+
+.feature-card-small {
+ padding: var(--space-4);
+}
+
+.feature-card-small .feature-card-icon {
+ display: none;
+}
+
+.feature-card-small h3 {
+ color: var(--gray-200);
+ font-size: 0.9375rem;
+ font-weight: 600;
+ margin: 0 0 var(--space-1) 0;
+}
+
+.feature-card-small p {
+ color: var(--gray-500);
+ font-size: 0.8125rem;
+ margin: 0;
+}
+
+@media (max-width: 768px) {
+ .dashboard-stats-row {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .dashboard-targets {
+ flex-wrap: wrap;
+ }
+
+ .feature-cards-compact {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Compliance Summary Section */
+.compliance-summary {
+ display: grid;
+ grid-template-columns: 1fr 2fr;
+ gap: var(--space-6);
+ padding: var(--space-6);
+ border-bottom: 1px solid var(--gray-800);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.target-info h3 {
+ color: var(--gray-300);
+ font-size: 0.875rem;
+ font-weight: 600;
+ margin: 0 0 var(--space-3) 0;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.target-info ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.target-info li {
+ color: var(--gray-400);
+ font-size: 0.8125rem;
+ padding: var(--space-1) 0;
+}
+
+.target-info li strong {
+ color: var(--gray-200);
+}
+
+/* Compliance Stats Boxes */
+.compliance-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--space-4);
+}
+
+.stat-box {
+ background: var(--gray-1000);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-md);
+ padding: var(--space-4);
+ text-align: center;
+ transition: border-color 0.15s ease, transform 0.15s ease;
+}
+
+.stat-box:hover {
+ transform: translateY(-2px);
+}
+
+.stat-box.compliant {
+ border-color: rgba(34, 197, 94, 0.3);
+}
+
+.stat-box.compliant:hover {
+ border-color: rgba(34, 197, 94, 0.5);
+}
+
+.stat-box.warning {
+ border-color: rgba(234, 179, 8, 0.3);
+}
+
+.stat-box.warning:hover {
+ border-color: rgba(234, 179, 8, 0.5);
+}
+
+.stat-box.danger {
+ border-color: rgba(239, 68, 68, 0.3);
+}
+
+.stat-box.danger:hover {
+ border-color: rgba(239, 68, 68, 0.5);
+}
+
+.stat-box .count {
+ display: block;
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--gray-100);
+ margin-bottom: var(--space-2);
+ font-family: "Geist Mono", monospace;
+}
+
+.stat-box .label {
+ display: block;
+ font-size: 0.75rem;
+ color: var(--gray-500);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+/* Compliance Badges in Table */
+.compliance-badge-cell {
+ width: 40px;
+ text-align: center;
+}
+
+.compliance-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ font-size: 0.875rem;
+ font-weight: 700;
+ transition: transform 0.15s ease;
+}
+
+.compliance-badge.compliant {
+ background: rgba(34, 197, 94, 0.2);
+ color: rgb(34, 197, 94);
+ border: 1px solid rgba(34, 197, 94, 0.4);
+}
+
+.compliance-badge.warning {
+ background: rgba(234, 179, 8, 0.2);
+ color: rgb(234, 179, 8);
+ border: 1px solid rgba(234, 179, 8, 0.4);
+}
+
+.compliance-badge.danger {
+ background: rgba(239, 68, 68, 0.2);
+ color: rgb(239, 68, 68);
+ border: 1px solid rgba(239, 68, 68, 0.4);
+}
+
+/* Table Row Highlighting by Compliance */
+tr.row-compliant {
+ background: rgba(34, 197, 94, 0.03);
+}
+
+tr.row-compliant:hover {
+ background: rgba(34, 197, 94, 0.06);
+}
+
+tr.row-warning {
+ background: rgba(234, 179, 8, 0.03);
+}
+
+tr.row-warning:hover {
+ background: rgba(234, 179, 8, 0.06);
+}
+
+tr.row-danger {
+ background: rgba(239, 68, 68, 0.03);
+}
+
+tr.row-danger:hover {
+ background: rgba(239, 68, 68, 0.06);
+}
+
+/* Stat Cell with Compliance Coloring */
+.stat-cell {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+.stat-daily.compliance-green {
+ color: rgb(34, 197, 94);
+ font-weight: 600;
+}
+
+.stat-daily.compliance-yellow {
+ color: rgb(234, 179, 8);
+ font-weight: 600;
+}
+
+.stat-daily.compliance-red {
+ color: rgb(239, 68, 68);
+ font-weight: 600;
+}
+
+/* Player Link */
+.player-link {
+ color: var(--gray-100);
+ text-decoration: none;
+ font-weight: 500;
+ transition: color 0.15s ease;
+}
+
+.player-link:hover {
+ color: #ffffff;
+ text-decoration: underline;
+}
+
+/* SSL Badge (Sports Science Lab) */
+.ssl-badge {
+ display: inline-flex;
+ align-items: center;
+ margin-left: var(--space-2);
+ padding: 1px 5px;
+ background: rgba(139, 92, 246, 0.15);
+ color: rgb(167, 139, 250);
+ border: 1px solid rgba(139, 92, 246, 0.3);
+ border-radius: var(--radius-sm);
+ font-size: 0.625rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ vertical-align: middle;
+}
+
+.ssl-exempt {
+ color: rgb(167, 139, 250);
+ font-style: italic;
+}
+
+/* Responsive adjustments */
+@media (max-width: 768px) {
+ .compliance-summary {
+ grid-template-columns: 1fr;
+ padding: var(--space-3);
+ gap: var(--space-3);
+ }
+
+ .target-info {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-2);
+ flex-wrap: wrap;
+ }
+
+ .target-info h3 {
+ font-size: 0.6875rem;
+ margin: 0;
+ }
+
+ .target-info ul {
+ display: flex;
+ gap: var(--space-3);
+ }
+
+ .target-info li {
+ font-size: 0.6875rem;
+ padding: 0;
+ }
+
+ .compliance-stats {
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--space-2);
+ }
+
+ .stat-box {
+ padding: var(--space-2);
+ }
+
+ .stat-box .count {
+ font-size: 1.25rem;
+ margin-bottom: 0;
+ }
+
+ .stat-box .label {
+ font-size: 0.5625rem;
+ }
+
+ .stat-box:hover {
+ transform: none;
+ }
+
+ .backfill-banner {
+ padding: var(--space-3);
+ margin-bottom: var(--space-3);
+ }
+
+ .backfill-banner-text {
+ font-size: 0.75rem;
+ }
+
+ .backfill-banner-text strong {
+ font-size: 0.8125rem;
+ }
+
+ .backfill-members-banner,
+ .coverage-warning-banner {
+ font-size: 0.75rem;
+ padding: var(--space-2) var(--space-3);
+ margin-bottom: var(--space-3);
+ }
+}
+
+/* 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;
+}
+
+/* Per-member Backfill Banner */
+.backfill-members-banner {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ margin-bottom: var(--space-4);
+ background: rgba(59, 130, 246, 0.08);
+ border: 1px solid rgba(59, 130, 246, 0.2);
+ border-radius: var(--radius-md);
+ color: var(--gray-400);
+ font-size: 0.8125rem;
+ line-height: 1.5;
+}
+
+.backfill-members-banner svg {
+ flex-shrink: 0;
+ color: rgba(59, 130, 246, 0.6);
+}
+
+/* Data Coverage Warning Banner */
+.coverage-warning-banner {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-3) var(--space-4);
+ margin-bottom: var(--space-4);
+ background: rgba(234, 179, 8, 0.08);
+ border: 1px solid rgba(234, 179, 8, 0.2);
+ border-radius: var(--radius-md);
+ color: var(--gray-400);
+ font-size: 0.8125rem;
+ line-height: 1.5;
+}
+
+.coverage-warning-banner svg {
+ flex-shrink: 0;
+ color: rgba(234, 179, 8, 0.7);
+}
+
+/* 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);
+}
+
+/* Copy Stats Button */
+.copy-cell {
+ width: 40px;
+ text-align: center;
+}
+
+.copy-stats-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ background: transparent;
+ border: 1px solid var(--gray-700);
+ border-radius: var(--radius-md);
+ color: var(--gray-500);
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.copy-stats-button:hover {
+ background: var(--gray-800);
+ border-color: var(--gray-600);
+ color: var(--gray-300);
+}
+
+.copy-stats-button:active {
+ transform: scale(0.95);
+}
+
+.copy-stats-button svg {
+ flex-shrink: 0;
+}
+
+/* Settings: Dashboard Access / Whitelist */
+.settings-section-description {
+ color: var(--gray-400);
+ font-size: 0.875rem;
+ margin: 0 0 var(--space-4) 0;
+ line-height: 1.5;
+}
+
+.whitelist-add-form {
+ margin-bottom: var(--space-4);
+}
+
+.whitelist-select {
+ flex: 1;
+ padding: var(--space-2) var(--space-3);
+ background: var(--gray-900);
+ border: 1px solid var(--gray-700);
+ border-radius: var(--radius-sm);
+ color: var(--gray-200);
+ font-size: 0.875rem;
+ font-family: "Geist Mono", monospace;
+}
+
+.whitelist-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ margin-top: var(--space-4);
+}
+
+.whitelist-list-scroll {
+ max-height: 300px;
+ overflow-y: auto;
+ position: relative;
+}
+
+.whitelist-list-wrapper {
+ position: relative;
+}
+
+.whitelist-list-wrapper::after {
+ content: "";
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 60px;
+ background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.6));
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ border-radius: 0 0 var(--radius-sm) var(--radius-sm);
+}
+
+.whitelist-list-wrapper.has-overflow::after {
+ opacity: 1;
+ animation: scroll-hint-pulse 2s ease-in-out 0.5s 2;
+}
+
+.whitelist-list-wrapper.scrolled-bottom::after {
+ opacity: 0;
+}
+
+@keyframes scroll-hint-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+
+.whitelist-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: var(--space-3) var(--space-4);
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-sm);
+}
+
+.whitelist-user-name {
+ color: var(--gray-200);
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.whitelist-remove-btn {
+ padding: var(--space-1) var(--space-3);
+ background: transparent;
+ border: 1px solid var(--gray-700);
+ border-radius: var(--radius-sm);
+ color: var(--gray-400);
+ font-size: 0.75rem;
+ font-family: "Geist Mono", monospace;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.whitelist-remove-btn:hover {
+ background: rgba(239, 68, 68, 0.1);
+ border-color: rgba(239, 68, 68, 0.3);
+ color: #fca5a5;
+}
+
+.whitelist-empty {
+ color: var(--gray-500);
+ font-size: 0.875rem;
+ font-style: italic;
+ margin-top: var(--space-3);
+}
+
+/* War Polling */
+.war-polling-card {
+ padding: var(--space-5);
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-md);
+}
+
+.war-polling-scores {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-2);
+ margin-bottom: var(--space-4);
+}
+
+.war-polling-score {
+ font-size: 1.5rem;
+ font-weight: 700;
+ font-family: "Geist Mono", monospace;
+}
+
+.war-polling-score--ours {
+ color: #22c55e;
+}
+
+.war-polling-score--theirs {
+ color: #ef4444;
+}
+
+.war-polling-score-divider {
+ color: var(--gray-500);
+ font-size: 1.25rem;
+ font-weight: 400;
+}
+
+.war-polling-target {
+ color: var(--gray-500);
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.war-polling-actions {
+ margin-bottom: var(--space-3);
+}
+
+.war-polling-status {
+ color: var(--gray-500);
+ font-size: 0.8125rem;
+ margin: 0;
+}
+
+/* Share subscription slider */
+.share-slider-container {
+ margin: var(--space-4) 0 var(--space-2);
+}
+
+.share-slider {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 100%;
+ height: 6px;
+ background: var(--gray-800);
+ border-radius: 3px;
+ outline: none;
+ cursor: pointer;
+}
+
+.share-slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 20px;
+ height: 20px;
+ background: var(--gray-100);
+ border-radius: 50%;
+ cursor: pointer;
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
+}
+
+.share-slider::-webkit-slider-thumb:hover {
+ transform: scale(1.2);
+ box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.1);
+}
+
+.share-slider::-moz-range-thumb {
+ width: 20px;
+ height: 20px;
+ background: var(--gray-100);
+ border: none;
+ border-radius: 50%;
+ cursor: pointer;
+}
+
+.share-slider-labels {
+ display: flex;
+ justify-content: space-between;
+ margin-top: var(--space-1);
+ font-size: 0.75rem;
+ color: var(--gray-500);
+ font-family: "Geist Mono", monospace;
+}
+
+.share-slider-preview {
+ color: var(--gray-300);
+ font-size: 0.875rem;
+ margin: var(--space-3) 0;
+ min-height: 1.25em;
+}
+
+.share-slider-action {
+ margin-top: var(--space-2);
+}
diff --git a/app/assets/stylesheets/pages/faction_dashboard.css b/app/assets/stylesheets/pages/faction_dashboard.css
new file mode 100644
index 0000000..433ecf1
--- /dev/null
+++ b/app/assets/stylesheets/pages/faction_dashboard.css
@@ -0,0 +1,1151 @@
+/*
+ * Faction Dashboard - Full viewport sections with Vercel-inspired design
+ */
+
+/* Override main container for full-width sections */
+.faction-dashboard {
+ margin: calc(var(--space-12) * -1) calc(var(--space-6) * -1);
+ width: calc(100% + var(--space-6) * 2);
+ max-width: none;
+}
+
+/* Hero Section */
+.dashboard-hero {
+ min-height: calc(100vh - 40px); /* Account for header height */
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-6) var(--space-6);
+}
+
+.dashboard-hero-content {
+ width: 100%;
+ max-width: 900px;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-5);
+}
+
+.dashboard-hero-header {
+ text-align: center;
+}
+
+.dashboard-hero-title {
+ font-size: 2.5rem;
+ font-weight: 700;
+ letter-spacing: -0.02em;
+ margin: 0;
+ background: linear-gradient(to bottom, var(--gray-100), var(--gray-400));
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+}
+
+.dashboard-hero-subtitle {
+ font-size: 1rem;
+ color: var(--gray-500);
+ margin: var(--space-2) 0 0;
+}
+
+/* Stats Grid */
+.dashboard-stats-grid {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: var(--space-4);
+}
+
+@media (max-width: 768px) {
+ .dashboard-stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.dashboard-stat-card {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-lg);
+ padding: var(--space-4);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-1);
+ transition: border-color 0.2s ease;
+}
+
+.dashboard-stat-card:hover {
+ border-color: var(--gray-700);
+}
+
+.dashboard-stat-card-link {
+ text-decoration: none;
+ color: inherit;
+ cursor: pointer;
+}
+
+.dashboard-stat-card-link:hover {
+ border-color: var(--gray-600);
+}
+
+.dashboard-stat-card-toggle {
+ font-family: inherit;
+ font-size: inherit;
+ width: 100%;
+ height: 100%;
+}
+
+.dashboard-stat-card-cta {
+ border-style: dashed;
+}
+
+.dashboard-stat-card-cta:hover {
+ border-style: solid;
+ border-color: var(--gray-600);
+}
+
+.dashboard-stat-card-disabled {
+ opacity: 0.4;
+ cursor: default;
+ border-style: dashed;
+}
+
+.dashboard-stat-icon {
+ color: var(--gray-500);
+}
+
+.dashboard-stat-value {
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--gray-100);
+ display: flex;
+ align-items: baseline;
+ gap: 2px;
+}
+
+.dashboard-stat-value .stat-wins {
+ color: #22c55e;
+}
+
+.dashboard-stat-value .stat-losses {
+ color: #ef4444;
+}
+
+.dashboard-stat-value .stat-divider {
+ font-size: 0.75rem;
+ color: var(--gray-500);
+ margin-left: 1px;
+}
+
+.dashboard-stat-value .stat-separator {
+ color: var(--gray-600);
+ margin: 0 var(--space-1);
+}
+
+.dashboard-stat-value .stat-compliant {
+ color: #22c55e;
+}
+
+.dashboard-stat-value .stat-warning {
+ color: #eab308;
+}
+
+.dashboard-stat-value .stat-danger {
+ color: #ef4444;
+}
+
+.dashboard-stat-label {
+ font-size: 0.75rem;
+ color: var(--gray-500);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+}
+
+/* Weekly Top Performers Card */
+.dashboard-performers-card {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+.dashboard-performers-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.dashboard-performers-title {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.dashboard-performers-header h3 {
+ margin: 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--gray-200);
+}
+
+.dashboard-performers-period {
+ font-size: 0.75rem;
+ color: var(--gray-500);
+}
+
+.dashboard-performers-copy {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ background: transparent;
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-md);
+ color: var(--gray-500);
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.dashboard-performers-copy:hover {
+ background: var(--gray-800);
+ border-color: var(--gray-700);
+ color: var(--gray-300);
+}
+
+.dashboard-performers-list {
+ padding: var(--space-1) 0;
+ max-height: 320px;
+ overflow-y: auto;
+}
+
+.dashboard-performer-row {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: 6px var(--space-4);
+ transition: background 0.15s ease;
+}
+
+.dashboard-performer-row:hover {
+ background: var(--gray-800);
+}
+
+.dashboard-performer-rank {
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--gray-500);
+ background: var(--gray-800);
+ border-radius: 50%;
+}
+
+.dashboard-performer-rank.top-three {
+ background: linear-gradient(135deg, var(--gray-700), var(--gray-800));
+ color: var(--gray-200);
+}
+
+.dashboard-performer-name {
+ flex: 1;
+ font-size: 0.875rem;
+ color: var(--gray-200);
+ text-decoration: none;
+ transition: color 0.15s ease;
+}
+
+.dashboard-performer-name:hover {
+ color: var(--gray-100);
+}
+
+.dashboard-performer-stat {
+ font-size: 0.75rem;
+ color: var(--gray-500);
+ font-variant-numeric: tabular-nums;
+}
+
+.dashboard-performer-badge {
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+}
+
+.dashboard-performer-badge.compliant {
+ background: rgba(34, 197, 94, 0.15);
+ color: #22c55e;
+}
+
+.dashboard-performer-badge.warning {
+ background: rgba(234, 179, 8, 0.15);
+ color: #eab308;
+}
+
+.dashboard-performer-badge.danger {
+ background: rgba(239, 68, 68, 0.15);
+ color: #ef4444;
+}
+
+.dashboard-performers-empty {
+ padding: var(--space-8);
+ text-align: center;
+ color: var(--gray-500);
+ font-size: 0.875rem;
+}
+
+.performers-backfill-banner {
+ padding: var(--space-3) var(--space-4);
+ background: linear-gradient(135deg, rgba(59, 130, 246, 0.08), rgba(59, 130, 246, 0.03));
+ border-bottom: 1px solid rgba(59, 130, 246, 0.15);
+}
+
+.performers-backfill-content {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ color: rgba(147, 197, 253, 0.7);
+}
+
+.performers-backfill-content svg {
+ flex-shrink: 0;
+ margin-top: 1px;
+}
+
+.performers-backfill-text {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ font-size: 0.8125rem;
+ color: var(--gray-400);
+}
+
+.performers-backfill-text strong {
+ color: var(--gray-200);
+ font-size: 0.8125rem;
+}
+
+/* Scroll Cards */
+.dashboard-scroll-cards {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--space-4);
+}
+
+@media (max-width: 640px) {
+ .dashboard-scroll-cards {
+ grid-template-columns: 1fr;
+ }
+}
+
+.dashboard-scroll-card {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-lg);
+ cursor: pointer;
+ text-align: left;
+ transition: all 0.2s ease;
+ width: 100%;
+}
+
+.dashboard-scroll-card:hover {
+ transform: translateY(-2px);
+ border-color: var(--gray-700);
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
+}
+
+.dashboard-scroll-card:active {
+ transform: translateY(0);
+}
+
+.dashboard-scroll-card-icon {
+ flex-shrink: 0;
+ width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--gray-800);
+ border-radius: var(--radius-md);
+ color: var(--gray-400);
+}
+
+.dashboard-scroll-card-icon svg {
+ width: 24px;
+ height: 24px;
+}
+
+.dashboard-scroll-card-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.dashboard-scroll-card-content h4 {
+ margin: 0;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--gray-100);
+}
+
+.scroll-card-status {
+ margin: var(--space-1) 0 0;
+ font-size: 0.875rem;
+ color: var(--gray-500);
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.scroll-card-status-live {
+ color: var(--gray-300);
+}
+
+.scroll-card-status-muted {
+ color: var(--gray-600);
+}
+
+.status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--gray-600);
+}
+
+.status-dot.live {
+ background: #22c55e;
+ box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
+ animation: pulse 2s infinite;
+}
+
+@keyframes pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+.compliance-mini {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 20px;
+ height: 20px;
+ padding: 0 6px;
+ font-size: 0.75rem;
+ font-weight: 600;
+ border-radius: var(--radius-sm);
+}
+
+.compliance-mini.compliant {
+ background: rgba(34, 197, 94, 0.15);
+ color: #22c55e;
+}
+
+.compliance-mini.warning {
+ background: rgba(234, 179, 8, 0.15);
+ color: #eab308;
+}
+
+.compliance-mini.danger {
+ background: rgba(239, 68, 68, 0.15);
+ color: #ef4444;
+}
+
+.compliance-mini-label {
+ font-size: 0.75rem;
+ color: var(--gray-600);
+ margin-left: var(--space-1);
+}
+
+/* Scroll Mouse Indicator */
+.dashboard-scroll-indicator {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.scroll-mouse {
+ width: 20px;
+ height: 32px;
+ border: 2px solid var(--gray-600);
+ border-radius: 10px;
+ position: relative;
+ transition: border-color 0.2s ease;
+}
+
+.dashboard-scroll-card:hover .scroll-mouse {
+ border-color: var(--gray-400);
+}
+
+.scroll-mouse-dot {
+ width: 4px;
+ height: 4px;
+ background: var(--gray-500);
+ border-radius: 50%;
+ position: absolute;
+ left: 50%;
+ top: 6px;
+ transform: translateX(-50%);
+ animation: scroll-down 3s ease-out infinite;
+}
+
+.dashboard-scroll-card:hover .scroll-mouse-dot {
+ background: var(--gray-300);
+}
+
+@keyframes scroll-down {
+ 0% {
+ top: 6px;
+ opacity: 1;
+ }
+ 30% {
+ top: 6px;
+ opacity: 1;
+ }
+ 70% {
+ top: 20px;
+ opacity: 0;
+ }
+ 100% {
+ top: 6px;
+ opacity: 0;
+ }
+}
+
+/* Full Page Sections */
+.dashboard-fullpage {
+ min-height: calc(100vh - 40px); /* Account for header height */
+ display: flex;
+ flex-direction: column;
+ padding: var(--space-12) var(--space-6);
+ border-top: 1px solid var(--gray-900);
+}
+
+.dashboard-fullpage-content {
+ flex: 1;
+ width: 100%;
+ max-width: 1200px;
+ margin: 0 auto;
+}
+
+.dashboard-fullpage-scroll {
+ overflow-y: auto;
+}
+
+.dashboard-fullpage-center {
+ min-height: calc(100vh - 60px - var(--space-12) * 2); /* Account for header and section padding */
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+/* Placeholder Cards */
+.dashboard-placeholder-card {
+ text-align: center;
+ padding: var(--space-12);
+ max-width: 480px;
+}
+
+.dashboard-placeholder-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 80px;
+ height: 80px;
+ margin-bottom: var(--space-6);
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-lg);
+ color: var(--gray-400);
+}
+
+.dashboard-placeholder-icon.muted {
+ color: var(--gray-600);
+}
+
+.dashboard-placeholder-card h2 {
+ margin: 0 0 var(--space-2);
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--gray-200);
+}
+
+.dashboard-placeholder-opponent {
+ font-size: 1.125rem;
+ color: var(--gray-400);
+ margin: 0 0 var(--space-4);
+}
+
+.dashboard-placeholder-scores {
+ display: flex;
+ align-items: baseline;
+ justify-content: center;
+ gap: var(--space-2);
+ font-size: 2rem;
+ font-weight: 600;
+ margin-bottom: var(--space-6);
+}
+
+.dashboard-placeholder-scores .score-ours {
+ color: var(--gray-100);
+}
+
+.dashboard-placeholder-scores .score-divider {
+ color: var(--gray-600);
+}
+
+.dashboard-placeholder-scores .score-theirs {
+ color: var(--gray-400);
+}
+
+.dashboard-placeholder-scores .score-target {
+ font-size: 1rem;
+ font-weight: 400;
+ color: var(--gray-600);
+}
+
+.dashboard-placeholder-message {
+ font-size: 0.875rem;
+ color: var(--gray-500);
+ margin: 0;
+ line-height: 1.6;
+}
+
+/* War Summary Card */
+.dashboard-war-summary-card {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-lg);
+ padding: var(--space-8);
+ text-align: center;
+ max-width: 720px;
+ width: 100%;
+}
+
+.dashboard-war-summary-card.war-won {
+ border-color: rgba(34, 197, 94, 0.3);
+}
+
+.dashboard-war-summary-card.war-lost {
+ border-color: rgba(239, 68, 68, 0.3);
+}
+
+.war-summary-badge {
+ display: inline-block;
+ padding: var(--space-1) var(--space-3);
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ border-radius: var(--radius-sm);
+ margin-bottom: var(--space-4);
+}
+
+.war-summary-badge.won {
+ background: rgba(34, 197, 94, 0.15);
+ color: #22c55e;
+}
+
+.war-summary-badge.lost {
+ background: rgba(239, 68, 68, 0.15);
+ color: #ef4444;
+}
+
+.dashboard-war-summary-card h2 {
+ margin: 0 0 var(--space-2);
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: var(--gray-300);
+}
+
+.war-summary-opponent {
+ font-size: 1.125rem;
+ color: var(--gray-400);
+ margin: 0 0 var(--space-6);
+}
+
+.war-summary-opponent .faction-link {
+ color: var(--gray-200);
+ text-decoration: none;
+ transition: color 0.15s ease;
+}
+
+.war-summary-opponent .faction-link:hover {
+ color: var(--gray-100);
+}
+
+.war-summary-scores {
+ display: flex;
+ align-items: stretch;
+ justify-content: center;
+ gap: var(--space-4);
+ margin-bottom: var(--space-6);
+}
+
+.war-summary-score {
+ flex: 1;
+ max-width: 180px;
+ padding: var(--space-4);
+ background: var(--gray-800);
+ border-radius: var(--radius-md);
+}
+
+.war-summary-score.winner {
+ background: rgba(34, 197, 94, 0.1);
+ border: 1px solid rgba(34, 197, 94, 0.2);
+}
+
+.war-summary-score .score-label {
+ display: block;
+ font-size: 0.75rem;
+ color: var(--gray-500);
+ margin-bottom: var(--space-1);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.war-summary-score .score-value {
+ display: block;
+ font-size: 1.75rem;
+ font-weight: 700;
+ color: var(--gray-100);
+}
+
+.war-summary-divider {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 0 var(--space-2);
+}
+
+.war-summary-divider .target-label {
+ font-size: 0.625rem;
+ color: var(--gray-600);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.war-summary-divider .target-value {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--gray-500);
+}
+
+.war-summary-meta {
+ display: flex;
+ justify-content: center;
+ gap: var(--space-4);
+ font-size: 0.875rem;
+ color: var(--gray-500);
+}
+
+.war-summary-stats-row {
+ display: flex;
+ justify-content: center;
+ gap: var(--space-6);
+ margin-bottom: var(--space-6);
+ padding: var(--space-4) 0;
+ border-top: 1px solid var(--gray-800);
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.war-summary-stat {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-1);
+}
+
+.war-summary-stat-label {
+ font-size: 0.6875rem;
+ color: var(--gray-500);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.war-summary-stat-value {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--gray-300);
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+}
+
+.war-summary-stat-value .stat-better {
+ color: #22c55e;
+}
+
+.war-summary-stat-value .stat-worse {
+ color: var(--gray-500);
+}
+
+.war-summary-stat-value .stat-vs {
+ font-size: 0.75rem;
+ color: var(--gray-600);
+}
+
+.war-summary-stat-value .rank-arrow {
+ font-size: 0.75rem;
+}
+
+.war-summary-stat-value .rank-arrow.rank-up {
+ color: #22c55e;
+}
+
+.war-summary-stat-value .rank-arrow.rank-down {
+ color: #ef4444;
+}
+
+.war-summary-rewards {
+ flex-direction: column;
+ gap: 2px;
+}
+
+.war-summary-performers {
+ margin-bottom: var(--space-6);
+}
+
+.war-summary-performers h3 {
+ font-size: 0.75rem;
+ font-weight: 600;
+ color: var(--gray-500);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin: 0 0 var(--space-3);
+}
+
+.war-summary-performers-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.war-summary-performer {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ background: var(--gray-800);
+ border-radius: var(--radius-md);
+}
+
+.war-summary-performer .performer-rank {
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--gray-500);
+ width: 20px;
+ text-align: center;
+}
+
+.war-summary-performer .performer-name {
+ flex: 1;
+ text-align: left;
+}
+
+.war-summary-performer .performer-name .player-link {
+ color: var(--gray-200);
+ text-decoration: none;
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.war-summary-performer .performer-name .player-link:hover {
+ color: var(--gray-100);
+}
+
+.war-summary-performer .performer-stats {
+ display: flex;
+ gap: var(--space-3);
+ font-size: 0.8125rem;
+}
+
+.war-summary-performer .performer-score {
+ color: #22c55e;
+ font-weight: 500;
+}
+
+.war-summary-performer .performer-attacks {
+ color: var(--gray-500);
+}
+
+.war-summary-footer {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-4);
+}
+
+.war-summary-actions {
+ display: flex;
+ gap: var(--space-3);
+}
+
+/* Training Section Header */
+.training-section-header {
+ text-align: center;
+ margin-bottom: var(--space-4);
+ flex-shrink: 0;
+}
+
+.training-section-header h2 {
+ margin: 0;
+ font-size: 1.5rem;
+ font-weight: 600;
+ color: var(--gray-200);
+}
+
+/* Training Section - fit in viewport */
+#training {
+ 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);
+}
+
+#training .dashboard-fullpage-content {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+}
+
+#training .table-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0; /* Allow flex child to shrink */
+ overflow: hidden;
+}
+
+#training .table-info {
+ flex-shrink: 0;
+}
+
+#training .compliance-summary {
+ flex-shrink: 0;
+ padding: var(--space-2) var(--space-4);
+ gap: var(--space-3);
+}
+
+#training .target-info-inline {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.75rem;
+ color: var(--gray-400);
+}
+
+#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-1) var(--space-3);
+}
+
+#training .stat-box .count {
+ font-size: 1.25rem;
+ margin-bottom: 0;
+}
+
+#training .stat-box .label {
+ font-size: 0.625rem;
+}
+
+.training-table-wrapper {
+ flex: 1;
+ overflow: auto;
+ min-height: 0; /* Allow flex child to shrink */
+}
+
+/* Mobile Responsiveness */
+@media (max-width: 768px) {
+ .dashboard-hero,
+ .dashboard-fullpage {
+ min-height: auto;
+ padding: var(--space-6) var(--space-4);
+ }
+
+ .dashboard-hero-title {
+ font-size: 1.75rem;
+ }
+
+ .dashboard-performers-list {
+ max-height: 240px;
+ }
+
+ .dashboard-fullpage-center {
+ min-height: auto;
+ padding: var(--space-8) 0;
+ }
+
+ .dashboard-placeholder-card,
+ .dashboard-war-summary-card {
+ padding: var(--space-6);
+ }
+
+ .war-summary-scores {
+ flex-direction: column;
+ gap: var(--space-3);
+ }
+
+ .war-summary-score {
+ max-width: none;
+ }
+
+ .war-summary-divider {
+ flex-direction: row;
+ gap: var(--space-2);
+ }
+
+ .war-summary-stats-row {
+ flex-wrap: wrap;
+ gap: var(--space-4);
+ }
+
+ .training-section-header h2 {
+ font-size: 1.125rem;
+ }
+
+ #training {
+ height: auto;
+ min-height: auto;
+ max-height: none;
+ overflow: visible;
+ padding-top: var(--space-3);
+ padding-bottom: var(--space-3);
+ }
+
+ #training .dashboard-fullpage-content {
+ overflow: visible;
+ }
+
+ #training .table-container:not(.table-disabled) {
+ overflow: visible;
+ }
+
+ #training .compliance-summary {
+ padding: var(--space-2) var(--space-3);
+ gap: var(--space-2);
+ }
+
+ #training .stat-box {
+ padding: var(--space-2);
+ }
+
+ #training .stat-box .count {
+ font-size: 1.125rem;
+ margin-bottom: 0;
+ }
+
+ #training .stat-box .label {
+ font-size: 0.5625rem;
+ }
+
+ #training .table-info {
+ padding: var(--space-3);
+ }
+
+ .filter-inline {
+ gap: var(--space-2);
+ }
+
+ .date-inputs-inline {
+ width: 100%;
+ }
+
+ .date-input-inline {
+ flex: 1;
+ min-width: 0;
+ font-size: 0.75rem;
+ padding: var(--space-2);
+ }
+
+ .info-label {
+ font-size: 0.75rem;
+ }
+
+ .info-days {
+ font-size: 0.75rem;
+ }
+
+ .training-table-wrapper {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ }
+
+ .training-table-wrapper .copy-cell {
+ width: auto;
+ padding: var(--space-1);
+ }
+
+ .training-table-wrapper .copy-stats-button {
+ width: 24px;
+ height: 24px;
+ }
+
+ .training-table-wrapper .copy-stats-button svg {
+ width: 12px;
+ height: 12px;
+ }
+
+ .training-table-wrapper th,
+ .training-table-wrapper td {
+ padding: var(--space-2) var(--space-2);
+ font-size: 0.6875rem;
+ }
+
+ .training-table-wrapper th small {
+ font-size: 0.5625rem;
+ }
+
+ .training-table-wrapper .stat-cell .stat-value {
+ font-size: 0.75rem;
+ }
+
+ .training-table-wrapper .stat-cell .stat-daily {
+ font-size: 0.625rem;
+ }
+
+ .training-table-wrapper .compliance-badge {
+ width: 20px;
+ height: 20px;
+ font-size: 0.75rem;
+ }
+
+ .training-table-wrapper .compliance-badge-cell {
+ width: 28px;
+ }
+
+ .training-table-wrapper .player-link {
+ font-size: 0.75rem;
+ }
+
+ .training-table-wrapper .ssl-badge {
+ font-size: 0.5625rem;
+ padding: 0 3px;
+ }
+}
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/landing.css b/app/assets/stylesheets/pages/landing.css
new file mode 100644
index 0000000..dce277b
--- /dev/null
+++ b/app/assets/stylesheets/pages/landing.css
@@ -0,0 +1,1038 @@
+.landing-content {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 0 var(--space-6);
+}
+
+.landing-section {
+ padding: var(--space-16) 0;
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.landing-section:last-child {
+ border-bottom: none;
+}
+
+.landing-section-header {
+ text-align: center;
+ margin-bottom: var(--space-12);
+}
+
+.landing-section-label {
+ display: inline-block;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--gray-500);
+ margin-bottom: var(--space-3);
+}
+
+.landing-section-title {
+ font-size: 2rem;
+ font-weight: 700;
+ color: #ffffff;
+ margin: 0 0 var(--space-3);
+ letter-spacing: -0.025em;
+}
+
+.landing-section-subtitle {
+ font-size: 1rem;
+ color: var(--gray-400);
+ margin: 0;
+ max-width: 560px;
+ margin-left: auto;
+ margin-right: auto;
+ line-height: 1.6;
+}
+
+.landing-intro {
+ text-align: center;
+ padding: var(--space-16) 0;
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.landing-intro-tagline {
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: #ffffff;
+ letter-spacing: -0.025em;
+ margin: 0 0 var(--space-6);
+ line-height: 1.2;
+}
+
+.landing-intro-description {
+ font-size: 1.0625rem;
+ color: var(--gray-400);
+ max-width: 600px;
+ margin: 0 auto;
+ line-height: 1.7;
+}
+
+.landing-features-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--space-6);
+}
+
+.landing-feature-card {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-lg);
+ padding: var(--space-8);
+ transition: border-color 0.2s ease;
+ overflow: hidden;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.landing-feature-card .demo-war-container,
+.landing-feature-card .demo-ct-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+.landing-feature-card .demo-war-table-wrap,
+.landing-feature-card .demo-ct-table-wrap {
+ flex: 1;
+}
+
+.landing-feature-card:hover {
+ border-color: var(--gray-700);
+}
+
+.landing-feature-card-full {
+ grid-column: 1 / -1;
+}
+
+.landing-feature-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border-radius: var(--radius-md);
+ background: var(--gray-800);
+ margin-bottom: var(--space-4);
+ color: var(--gray-300);
+}
+
+.landing-feature-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ color: #ffffff;
+ margin: 0 0 var(--space-2);
+}
+
+.landing-feature-description {
+ font-size: 0.875rem;
+ color: var(--gray-400);
+ line-height: 1.6;
+ margin: 0 0 var(--space-4);
+}
+
+.landing-feature-list-card .landing-feature-title {
+ margin-bottom: var(--space-4);
+}
+
+.landing-feature-grid-list {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--space-4);
+}
+
+.landing-feature-list-item {
+ display: flex;
+ gap: var(--space-3);
+ align-items: flex-start;
+}
+
+.landing-feature-list-item svg {
+ flex-shrink: 0;
+ color: var(--gray-400);
+ margin-top: 2px;
+}
+
+.landing-feature-list-item strong {
+ display: block;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: #ffffff;
+ margin-bottom: var(--space-1);
+}
+
+.landing-feature-list-item p {
+ font-size: 0.75rem;
+ color: var(--gray-400);
+ line-height: 1.5;
+ margin: 0;
+}
+
+.landing-trust-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--space-6);
+}
+
+.landing-trust-item {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-lg);
+ padding: var(--space-8);
+ transition: border-color 0.2s ease;
+}
+
+.landing-trust-item:hover {
+ border-color: var(--gray-700);
+}
+
+.landing-trust-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border-radius: var(--radius-md);
+ background: var(--gray-800);
+ margin-bottom: var(--space-4);
+ color: var(--gray-300);
+}
+
+.landing-trust-title {
+ font-size: 1rem;
+ font-weight: 600;
+ color: #ffffff;
+ margin: 0 0 var(--space-2);
+}
+
+.landing-trust-description {
+ font-size: 0.8125rem;
+ color: var(--gray-400);
+ line-height: 1.6;
+ margin: 0;
+}
+
+.landing-pricing {
+ text-align: center;
+}
+
+.landing-pricing-card {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-lg);
+ padding: var(--space-12);
+ max-width: 480px;
+ margin: 0 auto;
+}
+
+.landing-pricing-amount {
+ font-size: 2.5rem;
+ font-weight: 700;
+ color: #ffffff;
+ margin: 0 0 var(--space-1);
+}
+
+.landing-pricing-period {
+ font-size: 0.875rem;
+ color: var(--gray-500);
+ margin: 0 0 var(--space-6);
+}
+
+.landing-pricing-detail {
+ font-size: 0.9375rem;
+ color: var(--gray-400);
+ margin: 0 0 var(--space-2);
+ line-height: 1.6;
+}
+
+.landing-pricing-value {
+ font-size: 0.8125rem;
+ color: var(--gray-500);
+ margin: var(--space-4) 0 0;
+}
+
+.landing-pricing-divider {
+ width: 48px;
+ height: 1px;
+ background: var(--gray-800);
+ margin: var(--space-6) auto;
+}
+
+.landing-pricing-features {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ text-align: left;
+}
+
+.landing-pricing-features li {
+ font-size: 0.875rem;
+ color: var(--gray-300);
+ padding: var(--space-2) 0;
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+
+
+.landing-pricing-check {
+ color: #22c55e;
+ flex-shrink: 0;
+ width: 16px;
+ height: 16px;
+}
+
+.landing-pricing-trial {
+ display: inline-block;
+ margin-top: var(--space-6);
+ padding: var(--space-2) var(--space-4);
+ background: rgba(34, 197, 94, 0.1);
+ border: 1px solid rgba(34, 197, 94, 0.2);
+ border-radius: var(--radius-md);
+ color: #22c55e;
+ font-size: 0.8125rem;
+ font-weight: 600;
+}
+
+.landing-coming-soon-grid {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: var(--space-4);
+}
+
+.landing-coming-soon-item {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-md);
+ padding: var(--space-5);
+ text-align: center;
+ flex: 0 1 calc(33.333% - var(--space-4));
+ min-width: 200px;
+}
+
+.landing-coming-soon-item-title {
+ font-size: 0.9375rem;
+ font-weight: 600;
+ color: var(--gray-200);
+ margin: 0 0 var(--space-1);
+}
+
+.landing-coming-soon-item-description {
+ font-size: 0.8125rem;
+ color: var(--gray-500);
+ margin: 0;
+ line-height: 1.5;
+}
+
+.landing-coming-soon-link {
+ text-align: center;
+ margin-top: var(--space-8);
+}
+
+.landing-coming-soon-link a {
+ color: var(--gray-400);
+ text-decoration: none;
+ font-size: 0.875rem;
+ transition: color 0.15s ease;
+}
+
+.landing-coming-soon-link a:hover {
+ color: #ffffff;
+}
+
+.landing-steps {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--space-6);
+}
+
+.landing-step {
+ text-align: center;
+ padding: var(--space-6);
+}
+
+.landing-step-number {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ border: 1px solid var(--gray-700);
+ font-size: 1rem;
+ font-weight: 700;
+ color: #ffffff;
+ margin-bottom: var(--space-4);
+}
+
+.landing-step-title {
+ font-size: 1rem;
+ font-weight: 600;
+ color: #ffffff;
+ margin: 0 0 var(--space-2);
+}
+
+.landing-step-description {
+ font-size: 0.8125rem;
+ color: var(--gray-500);
+ margin: 0;
+ line-height: 1.6;
+}
+
+.landing-cta {
+ text-align: center;
+ padding: var(--space-16) 0 var(--space-12);
+}
+
+.landing-cta-title {
+ font-size: 1.75rem;
+ font-weight: 700;
+ color: #ffffff;
+ margin: 0 0 var(--space-3);
+}
+
+.landing-cta-subtitle {
+ font-size: 1rem;
+ color: var(--gray-500);
+ margin: 0 0 var(--space-8);
+}
+
+.landing-cta-button {
+ display: inline-block;
+ background: #ffffff;
+ color: #000000;
+ padding: var(--space-3) var(--space-8);
+ border-radius: var(--radius-md);
+ font-size: 1rem;
+ font-weight: 600;
+ text-decoration: none;
+ transition: background 0.2s ease, transform 0.1s ease, box-shadow 0.2s ease;
+}
+
+.landing-cta-button:hover {
+ background: var(--gray-100);
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(255, 255, 255, 0.15);
+}
+
+.landing-cta-button:active {
+ transform: translateY(0);
+}
+
+.landing-cta-links {
+ margin-top: var(--space-6);
+ display: flex;
+ justify-content: center;
+ gap: var(--space-6);
+}
+
+.landing-cta-links a {
+ color: var(--gray-500);
+ text-decoration: none;
+ font-size: 0.8125rem;
+ transition: color 0.15s ease;
+}
+
+.landing-cta-links a:hover {
+ color: #ffffff;
+}
+
+.scroll-indicator {
+ position: absolute;
+ bottom: 18%;
+ left: 0;
+ right: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-2);
+ cursor: pointer;
+ opacity: 0;
+ animation: fadeIn 0.8s ease-out 3s forwards;
+ transition: opacity 0.2s ease;
+}
+
+@keyframes fadeIn {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+
+.scroll-indicator:hover {
+ opacity: 0.8;
+}
+
+.scroll-indicator-mouse {
+ width: 24px;
+ height: 38px;
+ border: 2px solid var(--gray-600);
+ border-radius: 12px;
+ position: relative;
+}
+
+.scroll-indicator-wheel {
+ width: 3px;
+ height: 8px;
+ background: var(--gray-500);
+ border-radius: 2px;
+ position: absolute;
+ top: 6px;
+ left: 50%;
+ transform: translateX(-50%);
+ animation: scrollWheel 2s ease-in-out infinite;
+}
+
+.scroll-indicator-text {
+ font-size: 0.625rem;
+ color: var(--gray-600);
+ text-transform: uppercase;
+ letter-spacing: 0.15em;
+}
+
+@keyframes scrollWheel {
+ 0% {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+ 100% {
+ opacity: 0;
+ transform: translateX(-50%) translateY(12px);
+ }
+}
+
+@media (max-width: 768px) {
+ .landing-intro-tagline {
+ font-size: 1.75rem;
+ }
+
+ .landing-features-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .landing-trust-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .landing-coming-soon-item {
+ flex: 1 1 100%;
+ }
+
+ .landing-steps {
+ grid-template-columns: 1fr;
+ gap: var(--space-4);
+ }
+
+ .landing-section-title {
+ font-size: 1.5rem;
+ }
+
+ .landing-pricing-amount {
+ font-size: 2rem;
+ }
+
+ .landing-content {
+ padding: 0 var(--space-2);
+ }
+
+ .landing-feature-card {
+ padding: var(--space-3);
+ border-radius: var(--radius-md);
+ }
+
+ .landing-feature-grid-list {
+ grid-template-columns: 1fr;
+ }
+
+ .landing-feature-description {
+ font-size: 0.75rem;
+ }
+
+ .landing-feature-title {
+ font-size: 1rem;
+ }
+
+ .landing-pricing-card {
+ padding: var(--space-8);
+ }
+
+ .landing-pricing-detail,
+ .landing-pricing-value {
+ font-size: 0.75rem;
+ }
+
+ .landing-pricing-features li {
+ font-size: 0.75rem;
+ }
+}
+
+/* Demo War Dashboard */
+
+.demo-war-container {
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-md);
+ background: var(--gray-1000);
+ overflow: hidden;
+}
+
+.demo-war-filters {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ border-bottom: 1px solid var(--gray-800);
+ flex-wrap: wrap;
+ background: var(--gray-900);
+}
+
+.demo-war-filter-group {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+}
+
+.demo-war-filter-sep {
+ width: 1px;
+ height: 16px;
+ background: var(--gray-700);
+ margin: 0 var(--space-1);
+}
+
+.demo-filter-toggle {
+ font-size: 0.5625rem;
+ padding: 2px 6px;
+ border-radius: 9999px;
+ cursor: pointer;
+ user-select: none;
+ border: 1px solid;
+ transition: opacity 0.15s ease;
+ font-family: "Geist Mono", monospace;
+}
+
+.demo-filter-disabled {
+ opacity: 0.35;
+ text-decoration: line-through;
+}
+
+.demo-filter-okay { background: rgba(34, 197, 94, 0.15); color: rgb(34, 197, 94); border-color: rgba(34, 197, 94, 0.3); }
+.demo-filter-hospital { background: rgba(239, 68, 68, 0.15); color: rgb(239, 68, 68); border-color: rgba(239, 68, 68, 0.3); }
+.demo-filter-traveling { background: rgba(59, 130, 246, 0.15); color: rgb(59, 130, 246); border-color: rgba(59, 130, 246, 0.3); }
+.demo-filter-online { background: rgba(34, 197, 94, 0.15); color: rgb(34, 197, 94); border-color: rgba(34, 197, 94, 0.3); }
+.demo-filter-idle { background: rgba(234, 179, 8, 0.15); color: rgb(234, 179, 8); border-color: rgba(234, 179, 8, 0.3); }
+.demo-filter-offline { background: rgba(107, 114, 128, 0.15); color: rgb(156, 163, 175); border-color: rgba(107, 114, 128, 0.3); }
+
+.demo-filter-count {
+ font-size: 0.5625rem;
+ color: var(--gray-500);
+ margin-left: auto;
+ font-family: "Geist Mono", monospace;
+ white-space: nowrap;
+}
+
+.demo-war-table-wrap {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.demo-war-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.625rem;
+ font-family: "Geist Mono", monospace;
+}
+
+.demo-war-table thead {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+.demo-war-table th {
+ background: var(--gray-900);
+ color: var(--gray-400);
+ font-weight: 600;
+ font-size: 0.5625rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 4px 6px;
+ text-align: left;
+ white-space: nowrap;
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.demo-sortable {
+ cursor: pointer;
+ user-select: none;
+}
+
+.demo-sortable:hover {
+ color: var(--gray-200);
+}
+
+.demo-sort-indicator {
+ font-size: 0.5rem;
+ margin-left: 2px;
+ color: #ffffff;
+}
+
+.demo-war-table td {
+ padding: 3px 6px;
+ border-bottom: 1px solid var(--gray-900);
+ white-space: nowrap;
+ color: var(--gray-300);
+}
+
+.demo-war-table tbody tr:hover {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.demo-row-hospital {
+ background: rgba(239, 68, 68, 0.03);
+}
+
+.demo-player-name {
+ color: var(--gray-200);
+ font-weight: 500;
+}
+
+.demo-stat-value {
+ color: var(--gray-400);
+}
+
+.demo-stat-total {
+ color: var(--gray-200);
+ font-weight: 600;
+}
+
+.demo-member-status,
+.demo-action-badge {
+ display: inline-block;
+ font-size: 0.5625rem;
+ padding: 1px 5px;
+ border-radius: 9999px;
+ border: 1px solid;
+ font-family: "Geist Mono", monospace;
+}
+
+.demo-status-okay { background: rgba(34, 197, 94, 0.15); color: rgb(34, 197, 94); border-color: rgba(34, 197, 94, 0.3); }
+.demo-status-hospital { background: rgba(239, 68, 68, 0.15); color: rgb(239, 68, 68); border-color: rgba(239, 68, 68, 0.3); }
+.demo-status-traveling { background: rgba(59, 130, 246, 0.15); color: rgb(59, 130, 246); border-color: rgba(59, 130, 246, 0.3); }
+
+.demo-action-online { background: rgba(34, 197, 94, 0.15); color: rgb(34, 197, 94); border-color: rgba(34, 197, 94, 0.3); }
+.demo-action-idle { background: rgba(234, 179, 8, 0.15); color: rgb(234, 179, 8); border-color: rgba(234, 179, 8, 0.3); }
+.demo-action-offline { background: rgba(107, 114, 128, 0.15); color: rgb(156, 163, 175); border-color: rgba(107, 114, 128, 0.3); }
+
+.demo-hospital-timer {
+ color: rgb(239, 68, 68);
+ font-weight: 600;
+}
+
+.demo-timer-expiring {
+ color: rgb(234, 179, 8);
+ animation: demo-pulse 1s ease-in-out infinite;
+}
+
+.demo-travel-timer {
+ color: rgb(59, 130, 246);
+ font-weight: 600;
+}
+
+.demo-travel-landing {
+ color: rgb(34, 197, 94);
+ animation: demo-pulse 1s ease-in-out infinite;
+}
+
+.demo-no-data {
+ color: var(--gray-700);
+}
+
+@keyframes demo-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+
+@media (max-width: 768px) {
+ .demo-col-level {
+ display: none;
+ }
+
+ .demo-war-filters {
+ padding: var(--space-1) var(--space-2);
+ gap: var(--space-1);
+ }
+
+ .demo-filter-toggle {
+ font-size: 0.5rem;
+ padding: 1px 4px;
+ }
+
+ .demo-filter-count {
+ font-size: 0.5rem;
+ }
+
+ .demo-war-table {
+ font-size: 0.5rem;
+ }
+
+ .demo-war-table th {
+ font-size: 0.4375rem;
+ padding: 3px 4px;
+ }
+
+ .demo-war-table td {
+ padding: 2px 4px;
+ }
+
+ .demo-member-status,
+ .demo-action-badge {
+ font-size: 0.4375rem;
+ padding: 1px 3px;
+ }
+
+ .demo-hospital-timer,
+ .demo-travel-timer,
+ .demo-travel-landing {
+ font-size: 0.5rem;
+ }
+}
+
+/* Demo Compliance Table */
+
+.demo-ct-container {
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-md);
+ background: var(--gray-1000);
+ overflow: hidden;
+}
+
+.demo-ct-summary {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ border-bottom: 1px solid var(--gray-800);
+ background: var(--gray-900);
+}
+
+.demo-ct-summary-box {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 0.5625rem;
+ font-family: "Geist Mono", monospace;
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+ border: 1px solid;
+}
+
+.demo-ct-summary-compliant {
+ background: rgba(34, 197, 94, 0.1);
+ border-color: rgba(34, 197, 94, 0.3);
+ color: rgb(34, 197, 94);
+}
+
+.demo-ct-summary-warning {
+ background: rgba(234, 179, 8, 0.1);
+ border-color: rgba(234, 179, 8, 0.3);
+ color: rgb(234, 179, 8);
+}
+
+.demo-ct-summary-danger {
+ background: rgba(239, 68, 68, 0.1);
+ border-color: rgba(239, 68, 68, 0.3);
+ color: rgb(239, 68, 68);
+}
+
+.demo-ct-summary-count {
+ font-weight: 700;
+ font-size: 0.6875rem;
+}
+
+.demo-ct-summary-label {
+ color: inherit;
+ opacity: 0.8;
+}
+
+.demo-ct-table-wrap {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+}
+
+.demo-ct-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.625rem;
+ font-family: "Geist Mono", monospace;
+}
+
+.demo-ct-table th {
+ background: var(--gray-900);
+ color: var(--gray-400);
+ font-weight: 600;
+ font-size: 0.5625rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ padding: 4px 6px;
+ text-align: left;
+ white-space: nowrap;
+ border-bottom: 1px solid var(--gray-800);
+ vertical-align: bottom;
+}
+
+.demo-ct-sub {
+ display: block;
+ font-size: 0.5rem;
+ font-weight: 400;
+ text-transform: none;
+ letter-spacing: 0;
+ color: var(--gray-600);
+ margin-top: 1px;
+}
+
+.demo-ct-th-status {
+ width: 28px;
+}
+
+.demo-ct-table td {
+ padding: 3px 6px;
+ border-bottom: 1px solid var(--gray-900);
+ white-space: nowrap;
+ color: var(--gray-300);
+}
+
+.demo-ct-table tbody tr:hover {
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.demo-ct-td-status {
+ text-align: center;
+}
+
+.demo-ct-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ font-size: 0.5rem;
+ border: 1px solid;
+}
+
+.demo-ct-badge-compliant {
+ background: rgba(34, 197, 94, 0.2);
+ color: rgb(34, 197, 94);
+ border-color: rgba(34, 197, 94, 0.4);
+}
+
+.demo-ct-badge-warning {
+ background: rgba(234, 179, 8, 0.2);
+ color: rgb(234, 179, 8);
+ border-color: rgba(234, 179, 8, 0.4);
+}
+
+.demo-ct-badge-danger {
+ background: rgba(239, 68, 68, 0.2);
+ color: rgb(239, 68, 68);
+ border-color: rgba(239, 68, 68, 0.4);
+}
+
+.demo-ct-row-compliant { background: rgba(34, 197, 94, 0.03); }
+.demo-ct-row-warning { background: rgba(234, 179, 8, 0.03); }
+.demo-ct-row-danger { background: rgba(239, 68, 68, 0.03); }
+
+.demo-ct-stat {
+ display: flex;
+ flex-direction: column;
+}
+
+.demo-ct-stat-daily {
+ font-size: 0.5625rem;
+ color: var(--gray-500);
+}
+
+.demo-ct-c-green { color: rgb(34, 197, 94); font-weight: 600; }
+.demo-ct-c-yellow { color: rgb(234, 179, 8); font-weight: 600; }
+.demo-ct-c-red { color: rgb(239, 68, 68); font-weight: 600; }
+
+.demo-ct-ssl {
+ display: inline-block;
+ font-size: 0.4375rem;
+ padding: 0 3px;
+ border-radius: 3px;
+ background: rgba(139, 92, 246, 0.15);
+ color: rgb(167, 139, 250);
+ margin-left: 3px;
+ vertical-align: middle;
+ font-weight: 600;
+}
+
+.demo-ct-exempt {
+ font-size: 0.5625rem;
+ font-style: italic;
+ color: rgb(167, 139, 250);
+}
+
+@media (max-width: 768px) {
+ .demo-ct-hide-mobile {
+ display: none;
+ }
+
+ .demo-ct-summary {
+ padding: var(--space-1) var(--space-2);
+ gap: var(--space-2);
+ }
+
+ .demo-ct-summary-box {
+ font-size: 0.5rem;
+ padding: 1px 4px;
+ }
+
+ .demo-ct-summary-count {
+ font-size: 0.5625rem;
+ }
+
+ .demo-ct-table {
+ font-size: 0.5rem;
+ }
+
+ .demo-ct-table th {
+ font-size: 0.4375rem;
+ padding: 3px 4px;
+ }
+
+ .demo-ct-sub {
+ font-size: 0.4375rem;
+ }
+
+ .demo-ct-table td {
+ padding: 2px 4px;
+ }
+
+ .demo-ct-badge {
+ width: 14px;
+ height: 14px;
+ font-size: 0.4375rem;
+ }
+
+ .demo-ct-stat-daily {
+ font-size: 0.5rem;
+ }
+
+ .demo-ct-ssl {
+ font-size: 0.375rem;
+ padding: 0 2px;
+ }
+
+ .demo-ct-exempt {
+ font-size: 0.5rem;
+ }
+}
+
+
diff --git a/app/assets/stylesheets/pages/leadership.css b/app/assets/stylesheets/pages/leadership.css
new file mode 100644
index 0000000..800525e
--- /dev/null
+++ b/app/assets/stylesheets/pages/leadership.css
@@ -0,0 +1,1518 @@
+/* Leadership Stats Grid — 5 columns */
+.leadership-page .dashboard-stats-grid {
+ grid-template-columns: repeat(5, 1fr);
+}
+
+@media (max-width: 768px) {
+ .leadership-page .dashboard-stats-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+/* Leadership Dashboard Tabs */
+.leadership-tabs {
+ margin-top: 1.5rem;
+}
+
+.leadership-tab-buttons {
+ display: flex;
+ gap: 0.5rem;
+ border-bottom: 1px solid var(--color-border);
+ margin-bottom: 1.5rem;
+}
+
+.leadership-tab-btn {
+ padding: 0.75rem 1.25rem;
+ background: transparent;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: var(--color-text-secondary);
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ margin-bottom: -1px;
+}
+
+.leadership-tab-btn:hover {
+ color: var(--color-text);
+}
+
+.leadership-tab-btn.active {
+ color: var(--color-text);
+ border-bottom-color: var(--color-text);
+}
+
+.leadership-tab-content {
+ display: none;
+}
+
+.leadership-tab-content.active {
+ display: block;
+}
+
+/* Setup Page - Modern Design */
+.setup-page {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: var(--space-6) var(--space-6) var(--space-8);
+}
+
+.setup-tos-wrapper {
+ width: 100%;
+ max-width: 900px;
+}
+
+.setup-container {
+ width: 100%;
+ max-width: 640px;
+}
+
+.setup-header {
+ text-align: center;
+ margin-bottom: var(--space-6);
+}
+
+.setup-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 64px;
+ height: 64px;
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-xl);
+ color: var(--gray-400);
+ margin-bottom: var(--space-3);
+}
+
+.setup-icon svg {
+ width: 32px;
+ height: 32px;
+}
+
+.setup-header h1 {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--gray-100);
+ margin: 0 0 var(--space-1);
+ letter-spacing: -0.02em;
+}
+
+.setup-subtitle {
+ font-size: 0.875rem;
+ color: var(--gray-500);
+ margin: 0;
+}
+
+.setup-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+}
+
+.setup-fields {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.setup-field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.setup-label {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 0.8125rem;
+ font-weight: 500;
+ color: var(--gray-300);
+}
+
+.setup-required {
+ font-size: 0.625rem;
+ font-weight: 500;
+ padding: 2px 6px;
+ background: rgba(239, 68, 68, 0.15);
+ color: #ef4444;
+ border-radius: var(--radius-sm);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.setup-optional {
+ font-size: 0.625rem;
+ font-weight: 500;
+ padding: 2px 6px;
+ background: var(--gray-800);
+ color: var(--gray-500);
+ border-radius: var(--radius-sm);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.setup-input {
+ 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-size: 0.875rem;
+ font-family: "Geist Mono", monospace;
+ transition: border-color 0.15s ease, background 0.15s ease;
+}
+
+.setup-input:focus {
+ outline: none;
+ border-color: var(--gray-700);
+ background: var(--gray-850);
+}
+
+.setup-input::placeholder {
+ color: var(--gray-600);
+ font-family: inherit;
+}
+
+.setup-hint {
+ font-size: 0.75rem;
+ color: var(--gray-500);
+ margin: 0;
+}
+
+.setup-hint a {
+ color: var(--gray-400);
+ text-decoration: none;
+ transition: color 0.15s ease;
+}
+
+.setup-hint a:hover {
+ color: var(--gray-200);
+}
+
+.setup-actions {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-3);
+ margin-top: var(--space-2);
+}
+
+.setup-submit {
+ width: 100%;
+}
+
+.setup-cancel {
+ font-size: 0.8125rem;
+ color: var(--gray-500);
+ text-decoration: none;
+ transition: color 0.15s ease;
+}
+
+.setup-cancel:hover {
+ color: var(--gray-300);
+}
+
+.setup-error {
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: var(--radius-md);
+ color: #f87171;
+ padding: var(--space-3) var(--space-4);
+ font-size: 0.8125rem;
+ margin-bottom: var(--space-6);
+}
+
+.setup-info {
+ margin-bottom: var(--space-6);
+}
+
+.setup-info p {
+ font-size: 0.875rem;
+ color: var(--gray-400);
+ margin: 0 0 var(--space-3);
+ line-height: 1.5;
+}
+
+.setup-info-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.setup-info-list li {
+ font-size: 0.8125rem;
+ color: var(--gray-400);
+ line-height: 1.5;
+ padding-left: var(--space-4);
+ position: relative;
+}
+
+.setup-info-list li::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0.5em;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--gray-600);
+}
+
+.setup-info-list li strong {
+ color: var(--gray-200);
+}
+
+.setup-details {
+ margin-top: var(--space-8);
+ padding-top: var(--space-6);
+ border-top: 1px solid var(--gray-900);
+}
+
+.setup-details summary {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: 0.8125rem;
+ color: var(--gray-500);
+ cursor: pointer;
+ list-style: none;
+ transition: color 0.15s ease;
+}
+
+.setup-details summary::-webkit-details-marker {
+ display: none;
+}
+
+.setup-details summary:hover {
+ color: var(--gray-400);
+}
+
+.setup-details summary svg {
+ flex-shrink: 0;
+}
+
+.setup-details-content {
+ margin-top: var(--space-4);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.setup-detail-item h4 {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--gray-300);
+ margin: 0 0 var(--space-1);
+}
+
+.setup-detail-item p {
+ font-size: 0.75rem;
+ color: var(--gray-500);
+ margin: 0;
+ line-height: 1.5;
+}
+
+/* Navbar disabled items */
+.navbar-dropdown-item-disabled {
+ color: var(--color-text-tertiary) !important;
+ cursor: not-allowed;
+ pointer-events: none;
+}
+
+/* ===========================================
+ Leadership Dashboard - Full Viewport Design
+ =========================================== */
+
+.leadership-dashboard {
+ margin: calc(var(--space-12) * -1) calc(var(--space-6) * -1);
+ width: calc(100% + var(--space-6) * 2);
+ max-width: none;
+}
+
+/* Stat muted color */
+.stat-muted {
+ color: var(--gray-500);
+}
+
+/* War Banner in Hero */
+.leadership-war-banner {
+ background: rgba(34, 197, 94, 0.1);
+ border: 1px solid rgba(34, 197, 94, 0.3);
+ border-radius: var(--radius-lg);
+ padding: var(--space-3) var(--space-4);
+}
+
+.leadership-war-banner-content {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+}
+
+.leadership-war-banner-text {
+ color: var(--gray-300);
+ font-size: 0.875rem;
+}
+
+.leadership-war-banner-score {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--gray-200);
+}
+
+/* Scroll cards - 3 columns for leadership */
+.leadership-dashboard .dashboard-scroll-cards {
+ grid-template-columns: repeat(3, 1fr);
+}
+
+@media (max-width: 768px) {
+ .leadership-dashboard .dashboard-scroll-cards {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Wars Section */
+.leadership-wars-content {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+}
+
+/* Import Bar for Spies */
+.leadership-import-bar {
+ display: flex;
+ align-items: center;
+ 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-lg);
+ margin-bottom: var(--space-4);
+ flex-shrink: 0;
+}
+
+.leadership-import-form {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ width: 100%;
+}
+
+.leadership-import-label {
+ font-size: 0.875rem;
+ color: var(--gray-400);
+ white-space: nowrap;
+}
+
+.leadership-import-input {
+ flex: 1;
+ max-width: 200px;
+ padding: var(--space-2) var(--space-3);
+ background: var(--gray-800);
+ border: 1px solid var(--gray-700);
+ border-radius: var(--radius-md);
+ color: var(--gray-200);
+ font-size: 0.875rem;
+}
+
+.leadership-import-input:focus {
+ outline: none;
+ border-color: var(--gray-600);
+}
+
+.leadership-import-hint {
+ font-size: 0.875rem;
+ color: var(--gray-500);
+ margin: 0;
+}
+
+/* Table wrapper for spies */
+.leadership-table-wrapper {
+ flex: 1;
+ overflow: auto;
+ min-height: 0;
+}
+
+.leadership-table-wrapper table {
+ width: 100%;
+}
+
+/* Settings Layout */
+.leadership-settings-layout {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+.leadership-settings-main {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.leadership-settings-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-4);
+}
+
+@media (max-width: 700px) {
+ .leadership-settings-row {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Legacy grid (unused, kept for other pages) */
+.leadership-settings-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--space-4);
+}
+
+@media (max-width: 1100px) {
+ .leadership-settings-grid {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+@media (max-width: 700px) {
+ .leadership-settings-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.leadership-settings-card {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-lg);
+ padding: var(--space-4);
+}
+
+.leadership-settings-card h3 {
+ margin: 0 0 var(--space-1);
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--gray-200);
+}
+
+.leadership-settings-description {
+ font-size: 0.75rem;
+ color: var(--gray-500);
+ margin: 0 0 var(--space-3);
+ line-height: 1.4;
+}
+
+.leadership-settings-hint {
+ font-size: 0.75rem;
+ color: var(--gray-500);
+ margin: 0;
+}
+
+/* Coming Soon Card */
+.leadership-settings-card-muted {
+ opacity: 0.5;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ min-height: 160px;
+}
+
+/* Delete Faction Data Card */
+.leadership-settings-card-danger {
+ border-color: rgba(220, 38, 38, 0.3);
+}
+
+.leadership-danger-title {
+ color: #fca5a5 !important;
+}
+
+.leadership-danger-list {
+ font-size: 0.75rem;
+ color: var(--gray-400);
+ margin: 0 0 var(--space-3);
+ padding-left: var(--space-4);
+ line-height: 1.6;
+}
+
+.leadership-danger-list li {
+ margin-bottom: var(--space-1);
+}
+
+.leadership-danger-preserve {
+ font-size: 0.75rem;
+ color: var(--gray-400);
+ margin: 0 0 var(--space-2);
+ line-height: 1.4;
+}
+
+.leadership-danger-preserve strong {
+ color: #fca5a5;
+}
+
+/* API Keys - Compact Layout */
+.leadership-settings-card-compact {
+ padding: var(--space-4);
+}
+
+/* Current Keys Display */
+.leadership-api-current {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding-bottom: var(--space-3);
+ margin-bottom: var(--space-3);
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.leadership-api-current-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.leadership-api-current-info {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.leadership-api-current-value {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.leadership-api-current-value code {
+ font-size: 0.6875rem;
+ color: var(--gray-500);
+ background: var(--gray-800);
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+}
+
+.leadership-api-key-label {
+ font-size: 0.6875rem;
+ color: var(--gray-500);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.leadership-api-key-badge {
+ font-size: 0.5rem;
+ padding: 1px 4px;
+ background: rgba(34, 197, 94, 0.15);
+ color: #22c55e;
+ border-radius: var(--radius-sm);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.leadership-key-delete-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ background: transparent;
+ border: 1px solid var(--gray-700);
+ border-radius: var(--radius-sm);
+ color: var(--gray-500);
+ cursor: pointer;
+ transition: all 0.15s ease;
+ flex-shrink: 0;
+}
+
+.leadership-key-delete-btn:hover {
+ background: rgba(239, 68, 68, 0.1);
+ border-color: rgba(239, 68, 68, 0.3);
+ color: #ef4444;
+}
+
+/* Update Keys Form */
+.leadership-api-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.leadership-api-form > .btn {
+ margin-top: var(--space-1);
+}
+
+.leadership-api-fields {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.leadership-api-input {
+ width: 100%;
+ padding: var(--space-2) var(--space-3);
+ background: var(--gray-800);
+ border: 1px solid var(--gray-700);
+ border-radius: var(--radius-md);
+ color: var(--gray-200);
+ font-size: 0.75rem;
+ font-family: "Geist Mono", monospace;
+}
+
+.leadership-api-input:focus {
+ outline: none;
+ border-color: var(--gray-600);
+}
+
+.leadership-api-input::placeholder {
+ color: var(--gray-600);
+ font-family: inherit;
+}
+
+/* Polling Status */
+.leadership-polling-status {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+}
+
+.leadership-polling-active {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: 0.8125rem;
+ color: var(--gray-300);
+}
+
+.leadership-polling-idle {
+ font-size: 0.75rem;
+ color: var(--gray-500);
+ margin: 0;
+}
+
+/* Subscription */
+.leadership-subscription-info {
+ display: flex;
+ gap: var(--space-3);
+ margin-bottom: var(--space-3);
+ font-size: 0.75rem;
+ color: var(--gray-400);
+}
+
+.leadership-sub-how {
+ margin-bottom: var(--space-3);
+}
+
+.leadership-sub-steps {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ margin-top: 0.375rem;
+}
+
+.leadership-sub-step {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.75rem;
+ color: var(--color-text-secondary);
+}
+
+.leadership-sub-step-num {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.25rem;
+ height: 1.25rem;
+ border-radius: 50%;
+ background: var(--gray-800);
+ color: var(--color-text);
+ font-size: 0.625rem;
+ font-weight: 600;
+ flex-shrink: 0;
+}
+
+.leadership-sub-rate {
+ font-size: 0.75rem;
+ margin: 0.75rem 0;
+ padding: 0.5rem 0.75rem;
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: 6px;
+}
+
+.leadership-sub-rate-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.25rem 0;
+ color: var(--color-text-secondary);
+}
+
+.leadership-sub-rate-row strong {
+ color: var(--color-text);
+}
+
+.leadership-share-slider {
+ margin-bottom: var(--space-2);
+}
+
+.leadership-share-labels {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.625rem;
+ color: var(--gray-500);
+ margin-top: var(--space-1);
+}
+
+.leadership-share-preview {
+ font-size: 0.75rem;
+ color: var(--gray-400);
+ margin: 0 0 var(--space-2);
+}
+
+/* Extra small button */
+.btn-xs {
+ padding: var(--space-1) var(--space-2);
+ font-size: 0.6875rem;
+}
+
+/* Leadership Access card - fill height */
+.leadership-settings-card:has(.whitelist-add-form) {
+ display: flex;
+ flex-direction: column;
+}
+
+.leadership-settings-card:has(.whitelist-add-form) > h3,
+.leadership-settings-card:has(.whitelist-add-form) > p {
+ flex-shrink: 0;
+}
+
+.leadership-settings-card:has(.whitelist-add-form) > turbo-frame {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+/* Compact whitelist styles for leadership */
+.leadership-settings-card .whitelist-add-form {
+ margin-bottom: var(--space-3);
+ flex-shrink: 0;
+}
+
+.leadership-settings-card .api-key-form-inline {
+ display: flex;
+ gap: var(--space-2);
+}
+
+.leadership-settings-card .whitelist-select {
+ flex: 1;
+ padding: var(--space-2) var(--space-3);
+ font-size: 0.75rem;
+ background: var(--gray-800);
+ border: 1px solid var(--gray-700);
+ border-radius: var(--radius-md);
+ color: var(--gray-200);
+}
+
+.leadership-settings-card .api-key-form-inline .btn-primary {
+ padding: var(--space-2) var(--space-3);
+ font-size: 0.75rem;
+ white-space: nowrap;
+}
+
+.leadership-settings-card .whitelist-list-wrapper {
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+}
+
+.leadership-settings-card .whitelist-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ height: 100%;
+ overflow-y: auto;
+}
+
+.leadership-settings-card .whitelist-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-2) var(--space-3);
+ font-size: 0.75rem;
+ background: var(--gray-800);
+ border-radius: var(--radius-md);
+}
+
+.leadership-settings-card .whitelist-user-name {
+ color: var(--gray-300);
+}
+
+.leadership-settings-card .whitelist-remove-btn {
+ padding: var(--space-1) var(--space-2);
+ font-size: 0.625rem;
+ background: transparent;
+ border: 1px solid var(--gray-700);
+ border-radius: var(--radius-sm);
+ color: var(--gray-500);
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.leadership-settings-card .whitelist-remove-btn:hover {
+ background: rgba(239, 68, 68, 0.1);
+ border-color: rgba(239, 68, 68, 0.3);
+ color: #ef4444;
+}
+
+.leadership-settings-card .whitelist-remove-btn-disabled {
+ padding: var(--space-1) var(--space-2);
+ font-size: 0.625rem;
+ color: var(--gray-700);
+ cursor: not-allowed;
+}
+
+.leadership-settings-card .whitelist-empty {
+ font-size: 0.75rem;
+ color: var(--gray-500);
+ margin: 0;
+}
+
+/* Wars/Spies sections - fit in viewport */
+#wars.dashboard-fullpage,
+#spies.dashboard-fullpage {
+ height: calc(100vh - 60px);
+ min-height: calc(100vh - 60px);
+ max-height: calc(100vh - 60px);
+ overflow: hidden;
+ padding-top: var(--space-6);
+ padding-bottom: var(--space-4);
+}
+
+#spies .dashboard-fullpage-content {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ overflow: hidden;
+}
+
+#wars .training-section-header,
+#spies .training-section-header {
+ flex-shrink: 0;
+}
+
+/* Settings section - natural scroll */
+#settings.dashboard-fullpage {
+ min-height: auto;
+ padding-top: var(--space-6);
+ padding-bottom: var(--space-4);
+}
+
+/* Leadership War History */
+.war-history-header {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: var(--space-4);
+}
+
+/* Leadership Data Coverage */
+.coverage-stats-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: var(--space-4);
+ margin-bottom: var(--space-6);
+}
+
+.coverage-stat-card {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-lg);
+ padding: var(--space-5);
+ text-align: center;
+}
+
+.coverage-stat-label {
+ font-size: 0.8125rem;
+ color: var(--gray-400);
+ margin-bottom: var(--space-2);
+}
+
+.coverage-stat-value {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--gray-100);
+ font-family: "Geist Mono", monospace;
+}
+
+.coverage-stat-value-sm {
+ font-size: 1rem;
+}
+
+.coverage-stat-hint {
+ font-size: 0.6875rem;
+ color: var(--gray-500);
+ margin-top: var(--space-1);
+}
+
+.coverage-table-container {
+ display: flex;
+ flex-direction: column;
+ max-height: 600px;
+}
+
+.coverage-table-container h2 {
+ flex-shrink: 0;
+}
+
+.coverage-table-wrapper {
+ flex: 1;
+ overflow: auto;
+ min-height: 0;
+ scrollbar-color: var(--gray-700) transparent;
+}
+
+.coverage-table-wrapper::-webkit-scrollbar {
+ width: 6px;
+}
+
+.coverage-table-wrapper::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.coverage-table-wrapper::-webkit-scrollbar-thumb {
+ background: var(--gray-800);
+ border-radius: 3px;
+}
+
+.coverage-table-wrapper::-webkit-scrollbar-thumb:hover {
+ background: var(--gray-700);
+}
+
+.coverage-table-wrapper thead {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: var(--gray-900);
+}
+
+.coverage-member-row {
+ cursor: pointer;
+}
+
+.coverage-member-row:hover {
+ background-color: rgba(255, 255, 255, 0.05);
+}
+
+.coverage-detail-row td {
+ background: var(--gray-1000);
+ padding: var(--space-4) var(--space-6);
+}
+
+.coverage-detail-content {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.coverage-date-tags {
+ margin-top: var(--space-2);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+
+.coverage-date-tag {
+ display: inline-block;
+ padding: 2px 8px;
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-sm);
+ font-size: 0.75rem;
+ font-family: "Geist Mono", monospace;
+ color: var(--gray-400);
+}
+
+.coverage-backfill-action {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+}
+
+.coverage-backfill-info {
+ font-size: 0.8125rem;
+ color: var(--gray-400);
+ margin: 0;
+}
+
+.coverage-backfill-result {
+ font-size: 0.8125rem;
+ color: #22c55e;
+}
+
+.stat-compliant { color: #22c55e; }
+.stat-warning { color: #eab308; }
+
+/* War Reports */
+.war-reports-layout {
+ display: grid;
+ grid-template-columns: 280px 1fr;
+ gap: var(--space-6);
+ margin-bottom: var(--space-6);
+}
+
+@media (max-width: 768px) {
+ .war-reports-layout {
+ grid-template-columns: 1fr;
+ }
+}
+
+.war-reports-header h2 {
+ margin: 0 0 var(--space-3);
+}
+
+.war-reports-left {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-lg);
+ padding: var(--space-4);
+}
+
+.war-reports-left 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);
+}
+
+/* Rewards Inline */
+.war-reports-rewards-inline {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+ margin-top: var(--space-4);
+}
+
+.war-reports-reward-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ font-size: 0.75rem;
+ color: var(--color-text-secondary);
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: 4px;
+ padding: 0.25rem 0.5rem;
+}
+
+.war-reports-reward-price {
+ color: var(--color-text);
+ font-weight: 500;
+}
+
+.war-reports-reward-total {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: rgb(34, 197, 94);
+}
+
+/* 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;
+ align-items: flex-end;
+ gap: var(--space-4);
+ margin-bottom: var(--space-3);
+}
+
+.payout-field {
+ flex: 1;
+}
+
+.payout-field-action {
+ flex: 0;
+ white-space: nowrap;
+}
+
+.payout-field-narrow {
+ flex: 0 0 120px;
+}
+
+.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/assets/stylesheets/pages/legal.css b/app/assets/stylesheets/pages/legal.css
new file mode 100644
index 0000000..505e5f8
--- /dev/null
+++ b/app/assets/stylesheets/pages/legal.css
@@ -0,0 +1,267 @@
+.legal-layout {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 0 var(--space-6) 0 250px;
+}
+
+.legal-sidebar {
+ position: fixed;
+ top: 80px;
+ left: max(var(--space-6), calc((100vw - 1200px) / 2));
+ width: 220px;
+}
+
+.legal-toc {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ overflow-y: auto;
+ overflow-x: hidden;
+ max-height: calc(100vh - 240px);
+}
+
+.legal-toc::-webkit-scrollbar {
+ width: 4px;
+}
+
+.legal-toc::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.legal-toc::-webkit-scrollbar-thumb {
+ background: var(--gray-800);
+ border-radius: 2px;
+}
+
+.legal-toc::-webkit-scrollbar-thumb:hover {
+ background: var(--gray-700);
+}
+
+.legal-content {
+ min-width: 0;
+ max-width: 800px;
+}
+
+.legal-toc-group {
+ margin-bottom: var(--space-6);
+}
+
+.legal-toc-heading {
+ display: block;
+ font-size: 0.6875rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--gray-400);
+ text-decoration: none;
+ padding: var(--space-2) 0;
+ margin-bottom: var(--space-1);
+ transition: color 0.15s ease;
+}
+
+.legal-toc-heading:hover {
+ color: var(--gray-200);
+}
+
+.legal-toc-heading.active {
+ color: #ffffff;
+}
+
+.legal-toc-items {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.legal-toc-item {
+ margin: 0;
+}
+
+.legal-toc-link {
+ display: block;
+ font-size: 0.8125rem;
+ color: var(--gray-500);
+ text-decoration: none;
+ padding: 5px 0 5px var(--space-3);
+ border-left: 2px solid transparent;
+ transition: color 0.15s ease, border-color 0.15s ease;
+ line-height: 1.4;
+}
+
+.legal-toc-link:hover {
+ color: var(--gray-200);
+}
+
+.legal-toc-link.active {
+ color: #ffffff;
+ border-left-color: #ffffff;
+}
+
+.legal-document {
+ margin-bottom: var(--space-16);
+ scroll-margin-top: var(--space-4);
+}
+
+.legal-document:last-child {
+ margin-bottom: 0;
+}
+
+.legal-document-title {
+ font-size: 2rem;
+ font-weight: 700;
+ color: #ffffff;
+ letter-spacing: -0.03em;
+ margin-bottom: var(--space-1);
+ padding-top: var(--space-8);
+}
+
+.legal-document-subtitle {
+ color: var(--gray-500);
+ font-size: 0.8125rem;
+ margin-bottom: var(--space-8);
+}
+
+.legal-divider {
+ border: none;
+ border-top: 1px solid var(--gray-800);
+ margin: var(--space-16) 0;
+}
+
+.tos-table {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ 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);
+}
+
+@media (max-width: 1024px) {
+ .legal-layout {
+ padding-left: var(--space-6);
+ }
+
+ .legal-sidebar {
+ position: static;
+ width: 100%;
+ padding-bottom: var(--space-6);
+ border-bottom: 1px solid var(--gray-800);
+ margin-bottom: var(--space-8);
+ }
+
+ .legal-toc {
+ max-height: none;
+ overflow-y: visible;
+ }
+
+ .legal-toc-group {
+ margin-bottom: var(--space-3);
+ }
+
+ .legal-toc-items {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0 var(--space-4);
+ }
+
+ .legal-toc-link {
+ border-left: none;
+ padding-left: 0;
+ }
+}
+
+@media (max-width: 768px) {
+ .legal-layout {
+ padding: 0 var(--space-4);
+ }
+
+ .legal-sidebar {
+ display: none;
+ }
+
+ .legal-document-title {
+ font-size: 1.5rem;
+ padding-top: var(--space-4);
+ }
+
+ .legal-document-subtitle {
+ margin-bottom: var(--space-6);
+ }
+
+ .legal-document {
+ margin-bottom: var(--space-12);
+ }
+
+ .legal-divider {
+ margin: var(--space-12) 0;
+ }
+
+ .content-section {
+ margin-bottom: var(--space-8);
+ }
+
+ .content-section h2 {
+ font-size: 1.125rem;
+ margin-top: var(--space-6);
+ margin-bottom: var(--space-3);
+ }
+
+ .content-section h3 {
+ font-size: 0.9375rem;
+ margin-top: var(--space-4);
+ }
+
+ .content-section h4 {
+ font-size: 0.8125rem;
+ }
+
+ .content-section p,
+ .content-section ul {
+ font-size: 0.8125rem;
+ line-height: 1.6;
+ }
+
+ .content-section code {
+ font-size: 0.75rem;
+ }
+
+ .tos-table {
+ margin: var(--space-4) calc(-1 * var(--space-4));
+ padding: 0 var(--space-4);
+ }
+
+ .tos-table table {
+ font-size: 0.75rem;
+ }
+
+ .tos-table th,
+ .tos-table td {
+ padding: var(--space-2);
+ }
+
+ .tos-table th {
+ font-size: 0.6875rem;
+ }
+}
diff --git a/app/assets/stylesheets/pages/live_war_dashboard.css b/app/assets/stylesheets/pages/live_war_dashboard.css
new file mode 100644
index 0000000..9688162
--- /dev/null
+++ b/app/assets/stylesheets/pages/live_war_dashboard.css
@@ -0,0 +1,933 @@
+/*
+ * Live War Dashboard styles
+ * Real-time enemy member tracking during ranked wars
+ */
+
+.live-war-dashboard {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+/* Minimal Score Bar */
+.live-war-scores-minimal {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-8);
+ padding: var(--space-4) 0;
+}
+
+.live-war-score-side {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 2px;
+ min-width: 120px;
+}
+
+.live-war-score-side.our-side {
+ align-items: flex-end;
+}
+
+.live-war-score-side.their-side {
+ align-items: flex-start;
+}
+
+.live-score-value {
+ font-size: 3rem;
+ font-weight: 700;
+ font-family: "Geist Mono", monospace;
+ line-height: 1;
+ color: var(--gray-100);
+ transition: color 0.3s ease;
+}
+
+.live-score-value.winning {
+ color: rgb(34, 197, 94);
+}
+
+.live-score-value.losing {
+ color: rgb(239, 68, 68);
+}
+
+.live-score-value.tied {
+ color: var(--gray-300);
+}
+
+.live-score-label {
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--gray-400);
+}
+
+.live-score-label .faction-link {
+ color: var(--gray-400);
+ text-decoration: none;
+}
+
+.live-score-label .faction-link:hover {
+ color: var(--gray-200);
+}
+
+.live-war-score-center {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 280px;
+}
+
+.live-war-status-badge {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: 0.75rem;
+ color: var(--gray-400);
+}
+
+.war-status-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+}
+
+.war-status-dot.live {
+ background: rgb(34, 197, 94);
+ box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
+ animation: pulse-dot 2s infinite;
+}
+
+.war-status-dot.scheduled {
+ background: rgb(234, 179, 8);
+}
+
+.live-war-target {
+ font-family: "Geist Mono", monospace;
+ font-size: 1.125rem;
+ font-weight: 600;
+}
+
+.target-current {
+ color: var(--gray-100);
+}
+
+.target-separator {
+ color: var(--gray-600);
+ margin: 0 4px;
+}
+
+.target-goal {
+ color: var(--gray-500);
+}
+
+.live-war-progress {
+ width: 100%;
+ height: 4px;
+ background: var(--gray-800);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.live-war-progress-fill {
+ height: 100%;
+ background: rgb(34, 197, 94);
+ border-radius: 2px;
+ transition: width 0.3s ease;
+}
+
+.live-war-progress-fill.losing {
+ background: rgb(239, 68, 68);
+}
+
+/* Table Card with Integrated Filters */
+.live-war-table-card {
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+}
+
+.live-war-table-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.live-war-table-title {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.table-title-text {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--gray-200);
+}
+
+.table-title-count {
+ font-size: 0.75rem;
+ color: var(--gray-500);
+ font-family: "Geist Mono", monospace;
+}
+
+.table-title-filter-count {
+ font-size: 0.75rem;
+ color: var(--gray-500);
+}
+
+.live-war-table-status {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ font-size: 0.75rem;
+ color: var(--gray-500);
+ font-family: "Geist Mono", monospace;
+}
+
+/* Inline Filters */
+.live-war-filters {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-5);
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--gray-800);
+ background: rgba(0, 0, 0, 0.2);
+}
+
+.live-war-filters .war-filter-group {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.live-war-filters .war-filter-label {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: var(--gray-600);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.live-war-filters .war-filter-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1);
+}
+
+.live-war-filters .war-filter-stats {
+ margin-left: auto;
+}
+
+.live-war-filters .war-filter-slider {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.live-war-filters .war-filter-slider input[type="range"] {
+ width: 120px;
+}
+
+/* Table Scroll */
+.live-war-table-scroll {
+ max-height: calc(100vh - 320px);
+ min-height: 300px;
+ overflow-y: auto;
+}
+
+.live-war-table-scroll table {
+ width: 100%;
+}
+
+/* Legacy styles for backwards compatibility */
+.live-war-timer {
+ font-size: 0.8125rem;
+ color: var(--gray-500);
+ font-family: "Geist Mono", monospace;
+}
+
+.live-war-connection {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ font-size: 0.6875rem;
+}
+
+.connection-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ flex-shrink: 0;
+}
+
+.connection-dot.connected {
+ background: rgb(34, 197, 94);
+ box-shadow: 0 0 4px rgba(34, 197, 94, 0.5);
+}
+
+.connection-dot.connecting {
+ background: rgb(234, 179, 8);
+ animation: pulse-dot 1s infinite;
+}
+
+.connection-dot.offline {
+ background: var(--gray-600);
+}
+
+@keyframes pulse-dot {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.3; }
+}
+
+.connection-text {
+ color: var(--gray-500);
+}
+
+/* Live War Update Status */
+.live-war-updated {
+ font-size: 0.75rem;
+ color: var(--gray-400);
+ font-family: "Geist Mono", monospace;
+}
+
+.live-war-countdown {
+ font-size: 0.75rem;
+ color: var(--gray-500);
+ font-family: "Geist Mono", monospace;
+}
+
+/* Live Polling Status */
+.live-polling-status {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.live-polling-dot {
+ position: relative;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: rgb(34, 197, 94);
+}
+
+.live-polling-dot::before {
+ content: '';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ background: rgb(34, 197, 94);
+ animation: ping 1.5s cubic-bezier(0, 0, 0.2, 1) infinite;
+}
+
+@keyframes ping {
+ 0% {
+ transform: translate(-50%, -50%) scale(1);
+ opacity: 0.8;
+ }
+ 75%, 100% {
+ transform: translate(-50%, -50%) scale(2.5);
+ opacity: 0;
+ }
+}
+
+.live-polling-text {
+ font-size: 0.75rem;
+ font-weight: 500;
+ color: rgb(34, 197, 94);
+}
+
+/* Connecting state */
+.live-polling-status.connecting .live-polling-dot {
+ background: rgb(234, 179, 8);
+}
+
+.live-polling-status.connecting .live-polling-dot::before {
+ background: rgb(234, 179, 8);
+}
+
+.live-polling-status.connecting .live-polling-text {
+ color: rgb(234, 179, 8);
+}
+
+/* Offline state */
+.live-polling-status.offline .live-polling-dot {
+ background: var(--gray-600);
+}
+
+.live-polling-status.offline .live-polling-dot::before {
+ display: none;
+}
+
+.live-polling-status.offline .live-polling-text {
+ color: var(--gray-500);
+}
+
+/* Sortable headers */
+th.sortable {
+ cursor: pointer;
+ user-select: none;
+ white-space: nowrap;
+ transition: color 0.15s ease;
+}
+
+th.sortable:hover {
+ color: var(--gray-100);
+}
+
+.sort-indicator {
+ display: inline-block;
+ width: 12px;
+ font-size: 0.625rem;
+ vertical-align: middle;
+ color: var(--gray-500);
+}
+
+.sort-indicator.asc::after {
+ content: "\25B2";
+ color: var(--gray-300);
+}
+
+.sort-indicator.desc::after {
+ content: "\25BC";
+ color: var(--gray-300);
+}
+
+/* Status badges */
+.member-status {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+ font-size: 0.6875rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ white-space: nowrap;
+}
+
+.member-status.status-okay {
+ background: rgba(34, 197, 94, 0.15);
+ color: rgb(34, 197, 94);
+ border: 1px solid rgba(34, 197, 94, 0.3);
+}
+
+.member-status.status-hospital {
+ background: rgba(239, 68, 68, 0.15);
+ color: rgb(239, 68, 68);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+}
+
+.member-status.status-jail {
+ background: rgba(234, 179, 8, 0.15);
+ color: rgb(234, 179, 8);
+ border: 1px solid rgba(234, 179, 8, 0.3);
+}
+
+.member-status.status-traveling {
+ background: rgba(59, 130, 246, 0.15);
+ color: rgb(59, 130, 246);
+ border: 1px solid rgba(59, 130, 246, 0.3);
+}
+
+.member-status.status-abroad {
+ background: rgba(167, 139, 250, 0.15);
+ color: rgb(167, 139, 250);
+ border: 1px solid rgba(167, 139, 250, 0.3);
+}
+
+.member-status.status-fallen {
+ background: rgba(107, 114, 128, 0.15);
+ color: rgb(156, 163, 175);
+ border: 1px solid rgba(107, 114, 128, 0.3);
+}
+
+.member-status.status-unknown {
+ background: rgba(107, 114, 128, 0.1);
+ color: var(--gray-500);
+ border: 1px solid rgba(107, 114, 128, 0.2);
+}
+
+/* Hospital timer in table */
+.hospital-timer {
+ font-family: "Geist Mono", monospace;
+ font-size: 0.75rem;
+ color: rgb(239, 68, 68);
+ white-space: nowrap;
+}
+
+.hospital-timer.expiring-soon {
+ color: rgb(234, 179, 8);
+ animation: pulse-timer 1s infinite;
+}
+
+@keyframes pulse-timer {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.5; }
+}
+
+/* Travel timer in table */
+.travel-timer {
+ font-family: "Geist Mono", monospace;
+ font-size: 0.75rem;
+ color: rgb(59, 130, 246);
+ white-space: nowrap;
+}
+
+.travel-timer.expiring-soon {
+ color: rgb(234, 179, 8);
+ animation: pulse-timer 1s infinite;
+}
+
+.travel-timer.about-to-land {
+ color: rgb(34, 197, 94);
+ animation: pulse-land 1.5s ease-in-out infinite;
+}
+
+@keyframes pulse-land {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.3; }
+}
+
+/* Abroad timer in table */
+.abroad-timer {
+ font-family: "Geist Mono", monospace;
+ font-size: 0.75rem;
+ color: rgb(167, 139, 250);
+ white-space: nowrap;
+}
+
+.travel-fast {
+ color: rgb(59, 130, 246);
+}
+
+.travel-separator {
+ color: var(--gray-600);
+ font-size: 0.625rem;
+}
+
+.travel-slow {
+ color: var(--gray-500);
+ font-size: 0.6875rem;
+}
+
+/* Stat cells */
+.stat-value {
+ font-family: "Geist Mono", monospace;
+ font-size: 0.8125rem;
+ color: var(--gray-300);
+ white-space: nowrap;
+}
+
+.stat-value.no-data {
+ color: var(--gray-600);
+}
+
+.stat-value.stat-total {
+ color: var(--gray-100);
+ font-weight: 600;
+}
+
+/* Editable stat cells */
+.stat-editable {
+ cursor: pointer;
+ transition: background 0.15s ease;
+ position: relative;
+}
+
+.stat-editable:hover {
+ background: var(--gray-800);
+}
+
+.stat-editable.no-data:hover::after {
+ content: "click to add";
+ position: absolute;
+ font-size: 0.5625rem;
+ color: var(--gray-500);
+ white-space: nowrap;
+ bottom: 1px;
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.stat-edit-input {
+ width: 100%;
+ min-width: 60px;
+ background: var(--gray-800);
+ border: 1px solid var(--blue-500);
+ border-radius: 3px;
+ color: var(--gray-100);
+ font-family: "Geist Mono", monospace;
+ font-size: 0.75rem;
+ padding: 2px 4px;
+ outline: none;
+ text-align: right;
+}
+
+/* Attack link */
+.attack-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: var(--radius-sm);
+ color: var(--gray-500);
+ border: 1px solid var(--gray-800);
+ transition: all 0.15s ease;
+ text-decoration: none;
+}
+
+.attack-link:hover {
+ color: rgb(239, 68, 68);
+ border-color: rgba(239, 68, 68, 0.4);
+ background: rgba(239, 68, 68, 0.1);
+}
+
+.attack-link.disabled {
+ opacity: 0.3;
+ pointer-events: none;
+}
+
+/* Row highlight for status */
+tr.row-hospital {
+ background: rgba(239, 68, 68, 0.03);
+}
+
+tr.row-okay {
+ background: transparent;
+}
+
+tr.row-okay:hover,
+tr.row-hospital:hover {
+ background: var(--gray-850, rgba(255, 255, 255, 0.02));
+}
+
+/* Flash on update */
+@keyframes row-flash {
+ 0% { background: rgba(59, 130, 246, 0.1); }
+ 100% { background: transparent; }
+}
+
+tr.row-updated {
+ animation: row-flash 0.6s ease-out;
+}
+
+/* Activity badges */
+.action-badge {
+ display: inline-flex;
+ align-items: center;
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+ font-size: 0.6875rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ white-space: nowrap;
+}
+
+.action-badge.action-online {
+ background: rgba(34, 197, 94, 0.15);
+ color: rgb(34, 197, 94);
+ border: 1px solid rgba(34, 197, 94, 0.3);
+}
+
+.action-badge.action-idle {
+ background: rgba(234, 179, 8, 0.15);
+ color: rgb(234, 179, 8);
+ border: 1px solid rgba(234, 179, 8, 0.3);
+}
+
+.action-badge.action-offline {
+ background: rgba(107, 114, 128, 0.15);
+ color: rgb(156, 163, 175);
+ border: 1px solid rgba(107, 114, 128, 0.3);
+}
+
+/* Filter styles */
+.war-filter-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.war-filter-label {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: var(--gray-500);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.war-filter-options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-2);
+}
+
+.war-filter-toggle {
+ cursor: pointer;
+ user-select: none;
+ transition: opacity 0.2s ease, text-decoration-color 0.2s ease;
+ text-decoration: line-through transparent;
+}
+
+.war-filter-toggle:hover {
+ opacity: 0.8;
+}
+
+.war-filter-toggle.filter-disabled {
+ opacity: 0.35;
+ text-decoration: line-through currentColor;
+}
+
+.war-filter-slider {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.war-filter-slider input[type="range"] {
+ width: 180px;
+ height: 4px;
+ appearance: none;
+ background: var(--gray-800);
+ border-radius: 2px;
+ outline: none;
+ cursor: pointer;
+}
+
+.war-filter-slider input[type="range"]::-webkit-slider-thumb {
+ appearance: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--gray-300);
+ border: 2px solid var(--gray-700);
+ cursor: pointer;
+}
+
+.war-filter-slider input[type="range"]::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--gray-300);
+ border: 2px solid var(--gray-700);
+ cursor: pointer;
+}
+
+.war-filter-slider-value {
+ font-size: 0.75rem;
+ font-family: "Geist Mono", monospace;
+ color: var(--gray-400);
+ min-width: 60px;
+}
+
+/* Column Help Tooltip */
+.column-help {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ margin-left: 2px;
+ cursor: help;
+ vertical-align: middle;
+}
+
+.column-help-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--gray-700);
+ color: var(--gray-400);
+ font-size: 0.625rem;
+ font-weight: 600;
+ line-height: 1;
+ position: relative;
+ top: -1px;
+ transition: background 0.15s ease, color 0.15s ease;
+}
+
+.column-help:hover .column-help-icon,
+.column-help:focus .column-help-icon {
+ background: var(--gray-600);
+ color: var(--gray-200);
+}
+
+.column-help-tooltip {
+ display: none;
+ position: absolute;
+ top: calc(100% + 8px);
+ left: 50%;
+ transform: translateX(-50%);
+ width: 280px;
+ padding: var(--space-3);
+ background: var(--gray-800);
+ border: 1px solid var(--gray-700);
+ border-radius: var(--radius-md);
+ color: var(--gray-300);
+ font-size: 0.75rem;
+ font-weight: 400;
+ line-height: 1.5;
+ white-space: normal;
+ text-align: left;
+ z-index: 100;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
+ cursor: default;
+}
+
+.column-help-tooltip strong {
+ color: var(--gray-100);
+}
+
+.column-help:hover .column-help-tooltip,
+.column-help:focus .column-help-tooltip {
+ display: block;
+}
+
+/* Responsive */
+@media (max-width: 768px) {
+ .live-war-scores-minimal {
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-3) 0;
+ }
+
+ .live-war-score-side {
+ min-width: auto;
+ }
+
+ .live-war-score-side.our-side,
+ .live-war-score-side.their-side {
+ align-items: center;
+ }
+
+ .live-score-value {
+ font-size: 2rem;
+ }
+
+ .live-war-score-center {
+ min-width: auto;
+ gap: var(--space-1);
+ }
+
+ .live-war-table-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--space-2);
+ padding: var(--space-3);
+ }
+
+ .live-war-table-status {
+ gap: var(--space-2);
+ flex-wrap: wrap;
+ }
+
+ .live-war-filters {
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-3);
+ }
+
+ .live-war-filters .war-filter-group {
+ align-items: center;
+ }
+
+ .live-war-filters .war-filter-options {
+ justify-content: center;
+ }
+
+ .live-war-filters .war-filter-stats {
+ margin-left: 0;
+ align-items: center;
+ }
+
+ .live-war-filters .war-filter-slider {
+ justify-content: center;
+ }
+
+ .live-war-filters .war-filter-slider input[type="range"] {
+ width: 140px;
+ }
+
+ .live-war-table-scroll {
+ max-height: calc(100vh - 280px);
+ }
+
+ /* Hide individual stat columns (STR, DEF, SPD, DEX) — keep Total */
+ .live-war-table-scroll th:nth-child(7),
+ .live-war-table-scroll td:nth-child(7),
+ .live-war-table-scroll th:nth-child(8),
+ .live-war-table-scroll td:nth-child(8),
+ .live-war-table-scroll th:nth-child(9),
+ .live-war-table-scroll td:nth-child(9),
+ .live-war-table-scroll th:nth-child(10),
+ .live-war-table-scroll td:nth-child(10) {
+ display: none;
+ }
+
+ /* Tighter padding on table cells */
+ .live-war-table-scroll th,
+ .live-war-table-scroll td {
+ padding: var(--space-2);
+ font-size: 0.75rem;
+ }
+
+ .stat-value {
+ font-size: 0.75rem;
+ }
+
+ .member-status,
+ .action-badge {
+ font-size: 0.625rem;
+ padding: 1px 4px;
+ }
+
+ .hospital-timer,
+ .travel-timer,
+ .abroad-timer {
+ font-size: 0.6875rem;
+ }
+
+ .attack-link {
+ width: 24px;
+ height: 24px;
+ }
+
+ .column-help-tooltip {
+ width: 220px;
+ left: auto;
+ right: 0;
+ transform: none;
+ }
+}
+
+@media (max-width: 480px) {
+ .live-score-value {
+ font-size: 1.5rem;
+ }
+
+ .live-score-label {
+ font-size: 0.75rem;
+ }
+
+ .live-war-target {
+ font-size: 0.875rem;
+ }
+
+ /* Also hide Lvl column on very small screens */
+ .live-war-table-scroll th:nth-child(2),
+ .live-war-table-scroll td:nth-child(2) {
+ display: none;
+ }
+}
diff --git a/app/assets/stylesheets/pages/login.css b/app/assets/stylesheets/pages/login.css
new file mode 100644
index 0000000..a829563
--- /dev/null
+++ b/app/assets/stylesheets/pages/login.css
@@ -0,0 +1,350 @@
+/*
+ * Login Page Styles
+ */
+
+.login-page {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ min-height: 100vh;
+ width: 100%;
+ padding: var(--space-8) var(--space-6);
+ gap: var(--space-6);
+}
+
+.login-container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ 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-tos-wrapper {
+ width: 100%;
+ max-width: 900px;
+ margin-bottom: var(--space-16);
+}
+
+/* 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-icon {
+ color: var(--gray-500);
+ margin-bottom: var(--space-3);
+}
+
+.login-title {
+ font-size: 1.75rem;
+ font-weight: 600;
+ color: #ffffff;
+ margin-bottom: var(--space-1);
+ letter-spacing: -0.025em;
+}
+
+.login-subtitle {
+ font-size: 0.8125rem;
+ color: var(--gray-500);
+ 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;
+ position: relative;
+}
+
+.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: -12px;
+ right: -100px;
+ background: linear-gradient(135deg, #e63946 0%, #c0212e 100%);
+ color: #ffffff;
+ padding: 0.6rem 1.4rem;
+ border-radius: 999px;
+ font-size: 0.875rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ text-decoration: none;
+ opacity: 0;
+ animation: fadeInPulse 1s ease-out 1.8s forwards, pulse 2.5s ease-in-out infinite 2.8s;
+ box-shadow: 0 0 0 0 rgba(230, 57, 70, 0.7);
+ transition: transform 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
+}
+
+.hero-signin-button:hover {
+ background: linear-gradient(135deg, #ff4d5a 0%, #e63946 100%);
+ transform: translateY(-1px) scale(1.05);
+}
+
+.hero-signin-button:active {
+ transform: translateY(0) scale(0.98);
+}
+
+@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.6);
+ }
+ 40% {
+ box-shadow: 0 0 0 12px rgba(230, 57, 70, 0);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(230, 57, 70, 0);
+ }
+}
+
+@media (max-width: 768px) {
+ .hero-wrapper {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ }
+
+ .hero-title {
+ font-size: 3rem;
+ }
+
+ .hero-signin-button {
+ position: static;
+ margin-top: 2rem;
+ animation: fadeInPulse 1s ease-out 1.8s forwards, pulse 2.5s ease-in-out infinite 2.8s;
+ }
+}
+
+@media (max-width: 480px) {
+ .hero-title {
+ font-size: 2.5rem;
+ }
+}
diff --git a/app/assets/stylesheets/pages/public_wars.css b/app/assets/stylesheets/pages/public_wars.css
new file mode 100644
index 0000000..31f9d89
--- /dev/null
+++ b/app/assets/stylesheets/pages/public_wars.css
@@ -0,0 +1,484 @@
+/*
+ * Public War Lobbies styles
+ */
+
+/* Page title */
+.public-wars-title {
+ text-align: center;
+ margin-bottom: var(--space-6);
+}
+
+/* Main grid layout — 4 columns, fixed row heights */
+.public-wars-layout {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ grid-template-rows: repeat(3, 1fr);
+ gap: var(--space-3);
+}
+
+/* Create lobby card — spans 1 col, 2 rows */
+.public-wars-create-card {
+ grid-row: span 2;
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-md);
+ padding: var(--space-4);
+ display: flex;
+ flex-direction: column;
+}
+
+.public-wars-create-info {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-2);
+ margin-bottom: var(--space-3);
+ padding-bottom: var(--space-3);
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.public-wars-create-info svg {
+ flex-shrink: 0;
+ color: rgb(59, 130, 246);
+ margin-top: 1px;
+}
+
+.public-wars-create-info p {
+ margin: 0;
+ font-size: 0.6875rem;
+ line-height: 1.5;
+ color: var(--gray-500);
+}
+
+/* Create form */
+.public-wars-create-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ flex: 1;
+}
+
+.public-wars-create-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-2);
+}
+
+.public-wars-create-field {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.public-wars-create-label {
+ font-size: 0.625rem;
+ font-weight: 600;
+ color: var(--gray-500);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.public-wars-optional {
+ font-weight: 400;
+ text-transform: none;
+ letter-spacing: normal;
+ color: var(--gray-600);
+}
+
+.public-wars-create-form .setup-input {
+ padding: 6px 10px;
+ font-size: 0.75rem;
+}
+
+.public-wars-api-note {
+ margin: 0;
+ font-size: 0.625rem;
+ color: var(--gray-600);
+ line-height: 1.4;
+ margin-top: auto;
+}
+
+.public-wars-api-note a {
+ color: var(--blue-500);
+}
+
+.public-wars-tos-label {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+ font-size: 0.625rem;
+ color: var(--gray-500);
+ cursor: pointer;
+}
+
+.public-wars-tos-label input[type="checkbox"] {
+ accent-color: var(--blue-500);
+ width: 13px;
+ height: 13px;
+ cursor: pointer;
+}
+
+.public-wars-tos-label a {
+ color: var(--blue-500);
+}
+
+.public-wars-create-form .btn {
+ font-size: 0.75rem;
+ padding: 8px 16px;
+}
+
+/* Lobby card (clickable link) */
+a.public-war-card {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-md);
+ padding: var(--space-3) var(--space-4);
+ text-decoration: none;
+ color: inherit;
+ transition: border-color 0.15s ease, background 0.15s ease;
+}
+
+a.public-war-card:hover {
+ border-color: var(--gray-600);
+ background: var(--gray-800);
+}
+
+.public-war-card-matchup {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 0;
+}
+
+.public-war-faction {
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--gray-100);
+}
+
+.public-war-vs {
+ font-size: 0.625rem;
+ color: var(--gray-500);
+ font-weight: 400;
+ line-height: 1.6;
+}
+
+.public-war-card-bottom {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--space-2);
+ margin-top: var(--space-2);
+}
+
+.public-war-card-meta-text {
+ font-size: 0.625rem;
+ color: var(--gray-500);
+}
+
+.public-war-card-meta-text strong {
+ color: var(--gray-400);
+}
+
+.public-war-card-badges {
+ display: flex;
+ gap: var(--space-1);
+ flex-shrink: 0;
+}
+
+/* Empty lobby slot */
+.public-war-card-empty {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px dashed var(--gray-800);
+ border-radius: var(--radius-md);
+}
+
+.public-war-empty-label {
+ font-size: 0.6875rem;
+ color: var(--gray-700);
+}
+
+/* Badges */
+.public-war-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 8px;
+ border-radius: var(--radius-sm);
+ font-size: 0.625rem;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+}
+
+.public-war-badge--active {
+ background: rgba(34, 197, 94, 0.12);
+ color: rgb(34, 197, 94);
+ border: 1px solid rgba(34, 197, 94, 0.25);
+}
+
+.public-war-badge-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: rgb(34, 197, 94);
+ animation: pulse-dot 2s ease-in-out infinite;
+}
+
+@keyframes pulse-dot {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.4; }
+}
+
+.public-war-badge--inactive {
+ background: rgba(107, 114, 128, 0.12);
+ color: rgb(156, 163, 175);
+ border: 1px solid rgba(107, 114, 128, 0.25);
+}
+
+.public-war-badge--locked {
+ background: rgba(234, 179, 8, 0.12);
+ color: rgb(234, 179, 8);
+ border: 1px solid rgba(234, 179, 8, 0.25);
+ padding: 2px 6px;
+}
+
+/* Locked lobby card (non-link, clickable div) */
+.public-war-card--locked {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-md);
+ padding: var(--space-3) var(--space-4);
+ cursor: pointer;
+ transition: border-color 0.15s ease, background 0.15s ease;
+}
+
+.public-war-card--locked:hover {
+ border-color: var(--gray-600);
+ background: var(--gray-800);
+}
+
+/* Password unlock modal */
+.public-war-modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+ align-items: center;
+ justify-content: center;
+ z-index: 100;
+ backdrop-filter: blur(2px);
+ display: none;
+}
+
+.public-war-modal-backdrop:not([hidden]) {
+ display: flex;
+}
+
+.public-war-modal {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-3);
+ background: var(--gray-900);
+ border: 1px solid var(--gray-800);
+ border-radius: var(--radius-lg);
+ padding: var(--space-6) var(--space-8);
+ max-width: 360px;
+ width: 100%;
+}
+
+.public-war-modal svg {
+ color: var(--gray-500);
+}
+
+.public-war-modal h3 {
+ margin: 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--gray-300);
+}
+
+.public-war-modal form {
+ width: 100%;
+}
+
+.public-war-modal-input {
+ display: flex;
+ gap: var(--space-2);
+ width: 100%;
+}
+
+.public-war-modal-input .setup-input {
+ flex: 1;
+}
+
+.public-war-modal-input .btn {
+ flex-shrink: 0;
+}
+
+.public-war-modal-error {
+ margin: 0;
+ font-size: 0.8125rem;
+ color: rgb(239, 68, 68);
+}
+
+/* Danger zone (delete lobby) */
+.public-war-danger-zone {
+ margin-top: var(--space-8);
+ padding: var(--space-4);
+ background: rgba(239, 68, 68, 0.05);
+ border: 1px solid rgba(239, 68, 68, 0.2);
+ border-radius: var(--radius-lg);
+}
+
+.public-war-danger-zone h3 {
+ margin: 0 0 var(--space-2);
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: rgb(239, 68, 68);
+}
+
+.public-war-danger-zone p {
+ margin: 0 0 var(--space-3);
+ font-size: 0.8125rem;
+ color: var(--gray-400);
+}
+
+.public-war-delete-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.public-war-delete-field label {
+ display: block;
+ font-size: 0.8125rem;
+ color: var(--gray-400);
+ margin-bottom: var(--space-2);
+}
+
+.public-war-delete-field code {
+ background: rgba(239, 68, 68, 0.1);
+ color: rgb(239, 68, 68);
+ padding: 1px 4px;
+ border-radius: 3px;
+ font-size: 0.8125rem;
+}
+
+.btn-danger {
+ padding: 8px 16px;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: #fff;
+ background: rgb(239, 68, 68);
+ border: none;
+ border-radius: var(--radius-md);
+ cursor: pointer;
+ transition: background 0.15s ease, opacity 0.15s ease;
+ align-self: flex-start;
+}
+
+.btn-danger:hover:not(:disabled) {
+ background: rgb(220, 38, 38);
+}
+
+.btn-danger:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/* Public lobby dashboard table — more vertical space since no page header */
+.live-war-scores-minimal ~ .live-war-table-card .live-war-table-scroll {
+ max-height: calc(100vh - 220px);
+}
+
+/* Scores minimal header (public lobby dashboard) */
+.live-war-scores-minimal {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: var(--space-6);
+ padding: var(--space-4) 0;
+ margin-bottom: var(--space-4);
+}
+
+.live-war-scores-minimal .live-score-label {
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: var(--gray-200);
+}
+
+.live-war-scores-minimal .live-score-label .faction-link {
+ color: var(--gray-200);
+}
+
+.live-war-scores-minimal .live-score-label .faction-link:hover {
+ color: white;
+}
+
+/* Responsive */
+@media (max-width: 1024px) {
+ .public-wars-layout {
+ grid-template-columns: repeat(3, 1fr);
+ grid-template-rows: auto;
+ }
+}
+
+@media (max-width: 768px) {
+ .public-wars-layout {
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: auto;
+ }
+
+ .public-wars-create-card {
+ grid-column: span 2;
+ grid-row: span 1;
+ }
+
+ .public-war-card-matchup {
+ gap: 2px;
+ }
+
+ .live-war-scores-minimal .live-score-label {
+ font-size: 1rem;
+ }
+
+ .live-war-scores-minimal ~ .live-war-table-card .live-war-table-scroll {
+ max-height: calc(100vh - 260px);
+ }
+
+ .public-war-danger-zone {
+ margin-top: var(--space-6);
+ padding: var(--space-3);
+ }
+
+ .public-war-delete-form {
+ gap: var(--space-2);
+ }
+
+ .public-war-modal {
+ margin: 0 var(--space-4);
+ padding: var(--space-4) var(--space-5);
+ }
+}
+
+@media (max-width: 480px) {
+ .public-wars-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .public-wars-create-card {
+ grid-column: span 1;
+ }
+
+ .public-wars-create-row {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/app/assets/stylesheets/pages/ranked_war.css b/app/assets/stylesheets/pages/ranked_war.css
new file mode 100644
index 0000000..f21bc90
--- /dev/null
+++ b/app/assets/stylesheets/pages/ranked_war.css
@@ -0,0 +1,815 @@
+/*
+ * 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;
+}
+
+/* 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-4);
+ margin-bottom: var(--space-6);
+}
+
+.scheduled-war-countdown {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-3);
+ color: rgb(234, 179, 8);
+ font-size: 1.125rem;
+}
+
+.scheduled-war-countdown svg {
+ flex-shrink: 0;
+}
+
+.scheduled-war-countdown strong {
+ color: rgb(250, 204, 21);
+}
+
+/* Scheduled war enemy table */
+.stats-total {
+ font-weight: 600;
+ font-family: "Geist Mono", monospace;
+ color: var(--gray-100);
+}
+
+.stats-age {
+ color: var(--gray-500);
+ font-size: 0.75rem;
+ margin-left: var(--space-1);
+}
+
+.no-stats {
+ color: var(--gray-600);
+ font-style: italic;
+}
+
+.last-action {
+ color: var(--gray-400);
+ font-size: 0.875rem;
+}
+
+.attack-cell {
+ width: 40px;
+ text-align: center;
+}
+
+.attack-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--space-1);
+ color: var(--gray-500);
+ border-radius: var(--radius-sm);
+ transition: color 0.15s, background 0.15s;
+}
+
+.attack-link:hover {
+ color: rgb(239, 68, 68);
+ background: rgba(239, 68, 68, 0.1);
+}
+
+/* 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);
+ }
+}
+
+/* War Detail Page */
+.war-detail-page {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: var(--space-6) var(--space-4);
+}
+
+.war-detail-footer {
+ text-align: center;
+ padding: var(--space-6) 0;
+ font-size: 0.8125rem;
+}
diff --git a/app/assets/stylesheets/pages/settings.css b/app/assets/stylesheets/pages/settings.css
new file mode 100644
index 0000000..75c8f4a
--- /dev/null
+++ b/app/assets/stylesheets/pages/settings.css
@@ -0,0 +1,188 @@
+/*
+ * 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);
+}
+
+/* Top Grid: API Key + Subscription side by side */
+.settings-top-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1.5rem;
+ align-items: stretch;
+}
+
+.settings-grid-column {
+ display: flex;
+ flex-direction: column;
+}
+
+.settings-grid-card {
+ flex: 1;
+}
+
+@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;
+}
+
+
+
+/* Data Stats List */
+.data-stats-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.data-stats-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.625rem 0;
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.data-stats-row:last-child {
+ border-bottom: none;
+}
+
+.data-stats-label {
+ font-size: 0.875rem;
+ color: var(--gray-400);
+}
+
+.data-stats-value {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--gray-100);
+}
+
+.data-stats-footer {
+ margin-top: 1rem;
+ margin-bottom: 0;
+ font-size: 0.8125rem;
+ color: var(--gray-500);
+ line-height: 1.5;
+}
+
+.data-stats-footer a {
+ color: var(--blue-500);
+ text-decoration: none;
+}
+
+.data-stats-footer a:hover {
+ text-decoration: underline;
+}
+
+/* Data Management Actions */
+.data-actions {
+ display: flex;
+ align-items: stretch;
+ gap: 0.75rem;
+ margin: 1rem 0;
+}
+
+.data-actions form {
+ display: flex;
+ align-items: stretch;
+}
+
+.data-actions .btn-outline,
+.data-actions .btn-danger {
+ display: inline-flex;
+ align-items: center;
+}
+
+@media (max-width: 480px) {
+ .data-actions {
+ flex-direction: column;
+ }
+}
+
+/* War polling active indicator */
+.war-polling-active-indicator {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: 0.875rem;
+ color: var(--gray-300);
+ margin-bottom: var(--space-3);
+}
diff --git a/app/assets/stylesheets/pages/spy_stats.css b/app/assets/stylesheets/pages/spy_stats.css
new file mode 100644
index 0000000..66125f8
--- /dev/null
+++ b/app/assets/stylesheets/pages/spy_stats.css
@@ -0,0 +1,187 @@
+/*
+ * 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;
+}
+
+/* Spy Reports */
+.spy-reports-container {
+ display: flex;
+ flex-direction: column;
+ max-height: 600px;
+}
+
+.spy-reports-container h2 {
+ flex-shrink: 0;
+}
+
+.spy-reports-wrapper {
+ flex: 1;
+ overflow: auto;
+ min-height: 0;
+ scrollbar-color: var(--gray-700) transparent;
+}
+
+.spy-reports-wrapper::-webkit-scrollbar {
+ width: 6px;
+}
+
+.spy-reports-wrapper::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.spy-reports-wrapper::-webkit-scrollbar-thumb {
+ background: var(--gray-800);
+ border-radius: 3px;
+}
+
+.spy-reports-wrapper::-webkit-scrollbar-thumb:hover {
+ background: var(--gray-700);
+}
+
+.spy-reports-wrapper thead {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: var(--gray-900);
+}
+
+.inline-edit-input {
+ width: 100%;
+ background: var(--gray-1000);
+ border: 1px solid var(--gray-600);
+ border-radius: var(--radius-sm);
+ color: var(--gray-100);
+ font-family: "Geist Mono", monospace;
+ font-size: 0.8125rem;
+ padding: 2px 6px;
+ text-align: right;
+}
+
+.inline-edit-input:focus {
+ outline: none;
+ border-color: var(--gray-400);
+}
+
+.spy-stats-number {
+ cursor: text;
+}
+
+.spy-stats-number:hover {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.spy-delete-btn {
+ background: transparent;
+ border: none;
+ color: var(--gray-600);
+ cursor: pointer;
+ padding: 4px;
+ border-radius: var(--radius-sm);
+ display: flex;
+ align-items: center;
+}
+
+.spy-actions-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+}
+
+.spy-actions-row .leadership-import-form {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.spy-actions-row .leadership-import-hint {
+ margin: 0;
+}
+
+.spy-actions-row .btn {
+ white-space: nowrap;
+}
+
+.spinner {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+ vertical-align: middle;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+.spy-delete-btn:hover {
+ color: #ef4444;
+ background: rgba(239, 68, 68, 0.1);
+}
diff --git a/app/assets/stylesheets/pages/userscript.css b/app/assets/stylesheets/pages/userscript.css
new file mode 100644
index 0000000..ea4c714
--- /dev/null
+++ b/app/assets/stylesheets/pages/userscript.css
@@ -0,0 +1,62 @@
+/*
+ * Userscript Page Styles
+ */
+
+.userscript-requirements {
+ margin: 0.5rem 0 1rem 1.25rem;
+ color: var(--gray-300);
+ font-size: 0.875rem;
+ line-height: 1.8;
+}
+
+.userscript-requirements a {
+ color: var(--blue-500);
+ text-decoration: none;
+}
+
+.userscript-requirements a:hover {
+ text-decoration: underline;
+}
+
+/* Version History Table */
+.version-history-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.version-history-table th {
+ text-align: left;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--gray-500);
+ padding: 0.5rem 0.75rem;
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.version-history-table td {
+ padding: 0.75rem;
+ font-size: 0.875rem;
+ color: var(--gray-300);
+ border-bottom: 1px solid var(--gray-800);
+}
+
+.version-history-table tr:last-child td {
+ border-bottom: none;
+}
+
+.version-number {
+ font-weight: 600;
+ color: var(--gray-100);
+ white-space: nowrap;
+}
+
+.version-date {
+ white-space: nowrap;
+ color: var(--gray-500);
+}
+
+.version-changelog {
+ color: var(--gray-400);
+}
diff --git a/app/channels/api_rate_monitor_channel.rb b/app/channels/api_rate_monitor_channel.rb
new file mode 100644
index 0000000..9bc27e6
--- /dev/null
+++ b/app/channels/api_rate_monitor_channel.rb
@@ -0,0 +1,8 @@
+class ApiRateMonitorChannel < ApplicationCable::Channel
+ def subscribed
+ stream_for current_user
+ end
+
+ def unsubscribed
+ end
+end
diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb
new file mode 100644
index 0000000..d672697
--- /dev/null
+++ b/app/channels/application_cable/channel.rb
@@ -0,0 +1,4 @@
+module ApplicationCable
+ class Channel < ActionCable::Channel::Base
+ end
+end
diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb
index 4264c74..636eb43 100644
--- a/app/channels/application_cable/connection.rb
+++ b/app/channels/application_cable/connection.rb
@@ -7,10 +7,11 @@ def connect
end
private
- def set_current_user
- if session = Session.find_by(id: cookies.signed[:session_id])
- self.current_user = session.user
- end
+
+ def set_current_user
+ if session = Session.find_by(id: cookies.signed[:session_id])
+ self.current_user = session.user
end
+ end
end
end
diff --git a/app/channels/war_channel.rb b/app/channels/war_channel.rb
new file mode 100644
index 0000000..95cc3ed
--- /dev/null
+++ b/app/channels/war_channel.rb
@@ -0,0 +1,14 @@
+class WarChannel < ApplicationCable::Channel
+ def subscribed
+ faction = current_user.faction
+
+ if faction
+ stream_from "war:faction:#{faction.id}"
+ else
+ reject
+ end
+ end
+
+ def unsubscribed
+ end
+end
diff --git a/app/controllers/admin/api_logs_controller.rb b/app/controllers/admin/api_logs_controller.rb
new file mode 100644
index 0000000..a6cd781
--- /dev/null
+++ b/app/controllers/admin/api_logs_controller.rb
@@ -0,0 +1,39 @@
+module Admin
+ class ApiLogsController < ApplicationController
+ before_action :require_admin
+
+ def index
+ admin_api_key = AdminCredentials.api_key
+ admin_user = User.find_by(torn_id: User::ADMIN_TORN_ID)
+
+ base_scope = ApiCall
+ .where(api_key: admin_api_key)
+ .where(user_id: admin_user&.id)
+
+ @api_logs = base_scope.recent.limit(500)
+
+ @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_today = base_scope.today.count
+ @calls_last_24h = base_scope.last_24_hours.count
+
+ @peak_rate_today = calculate_peak_rate(base_scope.today)
+ end
+
+ private
+
+ def calculate_peak_rate(scope)
+ 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/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..0f762da
--- /dev/null
+++ b/app/controllers/admin/factions_controller.rb
@@ -0,0 +1,108 @@
+module Admin
+ class FactionsController < ApplicationController
+ before_action :require_admin
+ before_action :set_faction, only: [ :edit, :update, :destroy, :toggle_ssl, :toggle_public_wars, :backfill_armory_news ]
+
+ 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 = AdminCredentials.api_key
+ faction_info = TornApi::Faction::Basic.new(api_key, torn_id).fetch
+
+ @faction = Faction.new(
+ torn_id: torn_id,
+ name: faction_info["name"]
+ )
+
+ if @faction.save
+ SyncFactionMembersJob.perform_now(@faction.id)
+ BackfillRankedWarsJob.perform_later(@faction.id, limit: 20)
+ @faction.reload
+
+ respond_to do |format|
+ format.turbo_stream
+ format.html { redirect_to admin_factions_path, notice: "Faction '#{@faction.name}' added with #{@faction.users.active.count} members. War history backfill queued." }
+ 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
+ @faction.delete_all_data!
+ redirect_to admin_factions_path, notice: "Faction '#{@faction.name}' reset. Setup required to reconfigure."
+ end
+
+ def edit
+ end
+
+ def update
+ if @faction.update(faction_params)
+ redirect_to admin_factions_path, notice: "Faction targets updated successfully."
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def toggle_ssl
+ user = @faction.users.find_by(id: params[:user_id])
+
+ unless user
+ render json: { success: false, error: "User not found" }, status: :not_found
+ return
+ end
+
+ user.update!(ssl_user: !user.ssl_user)
+ render json: { success: true, ssl_user: user.ssl_user, user_name: user.name }
+ rescue StandardError => e
+ render json: { success: false, error: e.message }, status: :unprocessable_entity
+ 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
+
+ def toggle_public_wars
+ @faction.update!(public_wars: !@faction.public_wars)
+ render json: { success: true, public_wars: @faction.public_wars }
+ rescue StandardError => e
+ render json: { success: false, error: e.message }, status: :unprocessable_entity
+ end
+
+ private
+
+ def set_faction
+ @faction = Faction.find_by!(torn_id: params[:id])
+ end
+
+ def faction_params
+ params.require(:faction).permit(:xanax_target, :energy_refill_target, :nerve_refill_target)
+ end
+ end
+end
diff --git a/app/controllers/admin/impersonation_controller.rb b/app/controllers/admin/impersonation_controller.rb
new file mode 100644
index 0000000..1b61f9d
--- /dev/null
+++ b/app/controllers/admin/impersonation_controller.rb
@@ -0,0 +1,11 @@
+module Admin
+ class ImpersonationController < ApplicationController
+ before_action :require_admin
+
+ def create
+ user = User.find(params[:id])
+ start_new_session_for(user)
+ redirect_to root_path, notice: "Now impersonating #{user.name} [#{user.torn_id}]"
+ end
+ end
+end
diff --git a/app/controllers/admin/recon_controller.rb b/app/controllers/admin/recon_controller.rb
new file mode 100644
index 0000000..535a921
--- /dev/null
+++ b/app/controllers/admin/recon_controller.rb
@@ -0,0 +1,261 @@
+module Admin
+ class ReconController < ApplicationController
+ before_action :require_admin
+
+ def show
+ @training_sample_count = Recon::TrainingSample.count
+ @latest_sample = Recon::TrainingSample.order(created_at: :desc).first
+ @samples = Recon::TrainingSample.order(created_at: :desc).limit(100)
+ @import_ends_at = Rails.cache.read("recon:import_ends_at")
+ @import_in_progress = @import_ends_at.present? && @import_ends_at > Time.current
+ end
+
+ def stats
+ @clip_pct = (params[:clip] || 1).to_f.clamp(0, 10)
+ all_samples = Recon::TrainingSample.where.not(xantaken: nil)
+ @total = all_samples.count
+ return if @total == 0
+
+ @complete_samples = @total
+ @incomplete_samples = Recon::TrainingSample.where(xantaken: nil).count
+
+ # Remove outliers based on total_stats percentile
+ if @clip_pct > 0
+ totals = all_samples.pluck(Arel.sql("strength + defense + speed + dexterity")).sort
+ lower = totals[(totals.size * @clip_pct / 100).to_i]
+ upper = totals[(totals.size * (100 - @clip_pct) / 100).to_i]
+ samples = all_samples.where("(strength + defense + speed + dexterity) BETWEEN ? AND ?", lower, upper)
+ @clipped_count = @total - samples.count
+ else
+ samples = all_samples
+ @clipped_count = 0
+ end
+
+ @sample_count = samples.count
+ all_columns = Recon::TrainingSample::FEATURE_COLUMNS + Recon::TrainingSample::LABEL_COLUMNS
+
+ @distributions = {}
+ @warnings = []
+
+ all_columns.each do |col|
+ values = samples.pluck(col).compact
+ next if values.empty?
+
+ d = compute_distribution(col, values)
+ @distributions[col] = d
+
+ if d[:zero_pct] > 70
+ @warnings << { feature: col, type: :high_zeros, message: "#{d[:zero_pct]}% zeros - consider binary encoding (0 vs >0)" }
+ elsif d[:zero_pct] > 30
+ @warnings << { feature: col, type: :moderate_zeros, message: "#{d[:zero_pct]}% zeros - may reduce predictive power" }
+ end
+
+ if d[:std] < 1 || (d[:p25] == d[:p75] && d[:p25] == d[:median])
+ @warnings << { feature: col, type: :low_variance, message: "Near-zero variance - consider dropping" }
+ end
+
+ skewness = d[:mean] > 0 ? (d[:mean] - d[:median]).abs / [ d[:std], 1 ].max : 0
+ if skewness > 1 && d[:max] > d[:p75] * 10
+ @warnings << { feature: col, type: :skewed, message: "Heavily right-skewed - consider log transform" }
+ end
+ end
+
+ @warnings.sort_by! { |w| { high_zeros: 0, low_variance: 1, skewed: 2, moderate_zeros: 3 }[w[:type]] }
+
+ total_stats = samples.pluck(Arel.sql("strength + defense + speed + dexterity")).compact.sort
+ @total_stats_dist = compute_distribution("total_stats", total_stats)
+
+ # Compute normal curve overlay
+ mean = @total_stats_dist[:mean]
+ std = [ @total_stats_dist[:std], 1 ].max
+ bin_min = @total_stats_dist[:bin_min]
+ bin_width = @total_stats_dist[:bin_width]
+ @normal_curve = (0...20).map do |i|
+ x = bin_min + (i + 0.5) * bin_width
+ y = Math.exp(-0.5 * ((x - mean) / std)**2)
+ y
+ end
+ normal_max = @normal_curve.max || 1
+ hist_max = @total_stats_dist[:histogram].max || 1
+ @normal_curve = @normal_curve.map { |y| (y / normal_max * hist_max).round(1) }
+ end
+
+ 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|
+ 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
+ mean = sorted.sum.to_f / count
+ median = count.odd? ? sorted[count / 2] : (sorted[count / 2 - 1] + sorted[count / 2]) / 2.0
+ min = sorted.first
+ max = sorted.last
+ std = Math.sqrt(sorted.sum { |v| (v - mean)**2 } / count)
+ p25 = sorted[(count * 0.25).to_i]
+ p75 = sorted[(count * 0.75).to_i]
+ zeros = sorted.count(0)
+
+ bins = 20
+ bin_width = max > min ? (max - min).to_f / bins : 1
+ histogram = Array.new(bins, 0)
+ sorted.each do |v|
+ bin = [ ((v - min) / bin_width).to_i, bins - 1 ].min
+ histogram[bin] += 1
+ end
+
+ {
+ count: count, mean: mean.round(1), median: median.round(1),
+ min: min, max: max, std: std.round(1),
+ p25: p25, p75: p75, zeros: zeros,
+ zero_pct: (zeros * 100.0 / count).round(1),
+ histogram: histogram,
+ bin_width: bin_width.round(1), bin_min: min
+ }
+ end
+
+ 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 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_file
+ file = params[:file]
+ unless file.present?
+ return redirect_to admin_recon_path, alert: "Please select a file to upload."
+ end
+
+ 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 file format."
+ end
+
+ import_rows(rows)
+ end
+
+ def import
+ raw_data = params[:spy_data]
+
+ if raw_data.blank?
+ return redirect_to admin_recon_path, alert: "No data provided."
+ end
+
+ rows = Recon::SpyDataParser.parse(raw_data)
+
+ if rows.empty?
+ return redirect_to admin_recon_path, alert: "Could not parse any rows. Check the format."
+ end
+
+ import_rows(rows)
+ end
+ end
+end
diff --git a/app/controllers/admin/script_versions_controller.rb b/app/controllers/admin/script_versions_controller.rb
new file mode 100644
index 0000000..2d53dc2
--- /dev/null
+++ b/app/controllers/admin/script_versions_controller.rb
@@ -0,0 +1,63 @@
+module Admin
+ class ScriptVersionsController < ApplicationController
+ before_action :require_admin
+ before_action :set_script_version, only: [ :edit, :update, :destroy ]
+
+ def index
+ @script_versions = ScriptVersion.ordered
+ @script_version = ScriptVersion.new(released_at: Date.current)
+ end
+
+ def new
+ @script_version = ScriptVersion.new(released_at: Date.current)
+ end
+
+ def create
+ @script_version = ScriptVersion.new(script_version_params)
+ assign_script_file
+
+ if @script_version.save
+ redirect_to admin_script_versions_path, notice: "Script version #{@script_version.version} created."
+ else
+ @script_versions = ScriptVersion.ordered
+ render :index, status: :unprocessable_entity
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ @script_version.assign_attributes(script_version_params)
+ assign_script_file
+
+ if @script_version.save
+ redirect_to admin_script_versions_path, notice: "Script version #{@script_version.version} updated."
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ version = @script_version.version
+ @script_version.destroy
+ redirect_to admin_script_versions_path, notice: "Script version #{version} deleted."
+ end
+
+ private
+
+ def set_script_version
+ @script_version = ScriptVersion.find(params[:id])
+ end
+
+ def script_version_params
+ params.require(:script_version).permit(:version, :changelog, :released_at)
+ end
+
+ def assign_script_file
+ return unless params[:script_version][:script_file].present?
+
+ @script_version.script_content = params[:script_version][:script_file].read.force_encoding("UTF-8")
+ end
+ end
+end
diff --git a/app/controllers/admin/snapshot_management_controller.rb b/app/controllers/admin/snapshot_management_controller.rb
new file mode 100644
index 0000000..cdafcb1
--- /dev/null
+++ b/app/controllers/admin/snapshot_management_controller.rb
@@ -0,0 +1,104 @@
+module Admin
+ class SnapshotManagementController < ApplicationController
+ before_action :require_admin
+
+ SECONDS_PER_API_CALL = 1.1
+
+ def index
+ 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 = resolve_api_key(user)
+
+ return render json: { success: false, message: "No API key available for #{user.name}" }, status: :unprocessable_entity if api_key.blank?
+
+ existing_queued_jobs = SolidQueue::Job.where(queue_name: "faction", finished_at: nil).count
+
+ missing_dates.each_with_index do |date, index|
+ BackfillSingleStatJob.set(wait: index.seconds).perform_later(user.id, date.to_s, faction_id: user.faction_id, api_key: api_key)
+ end
+
+ 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
+
+ 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?
+
+ existing_snapshots = PersonalStatSnapshot
+ .where(user_id: tracked_user_ids)
+ .pluck(:user_id, :date)
+ .group_by(&:first)
+ .transform_values { |pairs| pairs.map(&:last).to_set }
+
+ expected_dates = expected_date_range.to_a
+
+ users_data = User.tracked_for_stats.includes(:faction).filter_map do |user|
+ next if user.backfill_in_progress?
+
+ user_snapshots = existing_snapshots[user.id] || Set.new
+ missing = expected_dates - user_snapshots.to_a
+
+ next if missing.empty?
+
+ {
+ user: user,
+ missing_count: missing.size,
+ missing_dates: missing.sort.reverse,
+ latest_snapshot: user_snapshots.max,
+ oldest_missing: missing.min
+ }
+ end
+
+ users_data.sort_by { |u| -u[:missing_count] }
+ end
+
+ def missing_dates_for_user(user)
+ existing = user.personal_stat_snapshots.pluck(:date).to_set
+ expected_date_range.to_a - existing.to_a
+ end
+
+ def expected_date_range
+ PersonalStatSnapshot.tracking_start_date..PersonalStatSnapshot.tracking_end_date
+ end
+
+ def calculate_summary
+ tracked_count = User.tracked_for_stats.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,
+ total_expected: total_expected,
+ total_existing: total_existing,
+ total_missing: total_expected - total_existing,
+ coverage_percent: total_expected > 0 ? ((total_existing.to_f / total_expected) * 100).round(1) : 0
+ }
+ end
+ end
+end
diff --git a/app/controllers/admin/stats_controller.rb b/app/controllers/admin/stats_controller.rb
new file mode 100644
index 0000000..56e6aa0
--- /dev/null
+++ b/app/controllers/admin/stats_controller.rb
@@ -0,0 +1,290 @@
+module Admin
+ class StatsController < ApplicationController
+ before_action :require_admin
+
+ def index
+ load_user_stats
+ load_subscription_stats
+ load_faction_stats
+ load_snapshot_stats
+ load_activity_stats
+ load_data_health
+ load_api_stats
+ load_sign_in_stats
+ load_armory_stats
+ load_pipeline_stats
+ end
+
+ private
+
+ def load_user_stats
+ @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
+ @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
+
+ 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")
+
+ @faction_rows = details.map do |f|
+ status, stale_days =
+ if f.setup_completed?
+ [ :active, nil ]
+ 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 ]
+ 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
+ @total_snapshots = PersonalStatSnapshot.count
+ @earliest_snapshot = PersonalStatSnapshot.minimum(:timestamp)
+ @latest_snapshot = PersonalStatSnapshot.maximum(:timestamp)
+ @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 = 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 = activity[:earliest]
+ @activity_latest = activity[:latest]
+ @activity_daily_growth = @activity_total_polls > 0 ? (@activity_total_snapshots / [ @activity_total_polls, 1 ].max) * 96 : 0
+ end
+
+ def load_activity_stats
+ 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
+
+ @daily_snapshots = PersonalStatSnapshot
+ .where("timestamp >= ?", week_start)
+ .group(Arel.sql("DATE(timestamp, 'unixepoch')"))
+ .count
+ .sort_by { |date, _| date }
+ .last(7)
+ end
+
+ def load_data_health
+ users_with_yesterday_snapshot = PersonalStatSnapshot
+ .where(date: Date.yesterday)
+ .distinct
+ .pluck(:user_id)
+ @users_missing_yesterday = User.tracked_for_stats.where.not(id: users_with_yesterday_snapshot).count
+
+ calculate_snapshot_gaps
+
+ @incomplete_snapshots = PersonalStatSnapshot.partial.count
+ @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
+ @total_api_calls = ApiCall.count
+ @api_peak_rate_all_time = peak_rate(ApiCall.all)
+ @api_peak_rate_today = peak_rate(ApiCall.today)
+
+ admin_calls = ApiCall.where(api_key: AdminCredentials.api_key)
+ @admin_api_total = admin_calls.count
+ @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(
+ "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_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)
+ scope
+ .group(ApiCall::MINUTE_BUCKET)
+ .order(Arel.sql("COUNT(*) DESC"))
+ .limit(1)
+ .pick(Arel.sql("COUNT(*)")) || 0
+ end
+
+ def load_sign_in_stats
+ week_ago = 7.days.ago
+
+ @sign_ins_this_week = Session.where("created_at >= ?", week_ago).count
+ @unique_sign_ins_this_week = Session.where("created_at >= ?", week_ago).distinct.count(:user_id)
+
+ first_session_dates = Session
+ .group(:user_id)
+ .minimum(:created_at)
+ .select { |_, first_at| first_at >= week_ago }
+
+ @first_session_dates = first_session_dates
+ @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)
+
+ 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|
+ {
+ faction: f,
+ count: counts.fetch(f.id, 0),
+ earliest: earliest[f.id],
+ latest: latest[f.id],
+ backfill_pending: f.armory_backfill_pending?
+ }
+ 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?
+
+ start_date = PersonalStatSnapshot.tracking_start_date
+ end_date = PersonalStatSnapshot.tracking_end_date
+ 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
+ @users_with_gaps = 0
+ @total_missing_snapshot_days = 0
+ @missing_dates_summary = []
+ 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..29c612d
--- /dev/null
+++ b/app/controllers/admin/subscriptions_controller.rb
@@ -0,0 +1,64 @@
+module Admin
+ class SubscriptionsController < ApplicationController
+ before_action :require_admin
+
+ def index
+ @faction_subscriptions = Subscription.where(subscribable_type: "Faction")
+ .includes(:subscribable)
+ .order(expires_at: :desc)
+ @individual_subscriptions = Subscription.where(subscribable_type: "User")
+ .where("expires_at > ?", Time.current)
+ .includes(:subscribable)
+ .order(expires_at: :desc)
+ @recent_payments = XanaxPayment.includes(:sender, :recipient).recent.limit(50)
+ end
+
+ def grant
+ target_type = params[:target_type] || "User"
+ torn_id = params[:torn_id].to_i
+ weeks = params[:weeks].to_i
+
+ if torn_id <= 0 || weeks <= 0
+ return redirect_to admin_subscriptions_path, alert: "Invalid Torn ID or weeks."
+ end
+
+ if target_type == "Faction"
+ faction = Faction.find_by(torn_id: torn_id)
+ unless faction
+ return redirect_to admin_subscriptions_path, alert: "Faction with Torn ID #{torn_id} not found."
+ end
+
+ if faction.subscription
+ faction.subscription.extend!(weeks)
+ else
+ faction.create_subscription!(expires_at: Time.current + weeks.weeks)
+ end
+ redirect_to admin_subscriptions_path, notice: "Granted #{weeks} week(s) to faction #{faction.name} [#{faction.torn_id}]."
+ else
+ user = User.find_by(torn_id: torn_id)
+ unless user
+ return redirect_to admin_subscriptions_path, alert: "User with Torn ID #{torn_id} not found."
+ end
+
+ user.extend_subscription!(weeks)
+ redirect_to admin_subscriptions_path, notice: "Granted #{weeks} week(s) to #{user.name} [#{user.torn_id}]."
+ end
+ rescue => e
+ redirect_to admin_subscriptions_path, alert: "Failed: #{e.message}"
+ end
+
+ def update_days
+ subscription = Subscription.find(params[:id])
+ days = params[:days].to_i
+
+ if days >= 0
+ subscription.update!(expires_at: days.days.from_now)
+ render json: { success: true, new_expires_at: 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
+ end
+end
diff --git a/app/controllers/api/current_war_controller.rb b/app/controllers/api/current_war_controller.rb
new file mode 100644
index 0000000..cd1c97d
--- /dev/null
+++ b/app/controllers/api/current_war_controller.rb
@@ -0,0 +1,32 @@
+module Api
+ class CurrentWarController < ActionController::API
+ before_action :require_api_key
+ before_action :set_user
+
+ def show
+ faction = @user.faction
+ unless faction
+ return render json: { error: "You are not a member of any faction." }, status: :unprocessable_entity
+ end
+
+ war_data = Rails.cache.read(faction.war_cache_key)
+
+ if war_data
+ render json: { war: war_data }
+ else
+ render json: { war: nil }
+ end
+ end
+
+ private
+
+ def require_api_key
+ render json: { error: "API key is required" }, status: :bad_request if params[:api_key].blank?
+ end
+
+ def set_user
+ @user = User.find_by_api_key(params[:api_key].to_s.strip)
+ render json: { error: "Unknown API key. Please sign in first." }, status: :not_found unless @user
+ end
+ end
+end
diff --git a/app/controllers/api/sessions_controller.rb b/app/controllers/api/sessions_controller.rb
new file mode 100644
index 0000000..fe60861
--- /dev/null
+++ b/app/controllers/api/sessions_controller.rb
@@ -0,0 +1,43 @@
+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,
+ name: profile.name,
+ level: profile.level,
+ profile_image: profile.image
+ )
+ user.save!
+ user.set_api_key!(api_key, key_info.access.type)
+
+ 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/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb
new file mode 100644
index 0000000..a4e8400
--- /dev/null
+++ b/app/controllers/api/subscriptions_controller.rb
@@ -0,0 +1,55 @@
+module Api
+ class SubscriptionsController < ActionController::API
+ PAYMENT_CHECK_COOLDOWN = 5.minutes
+ CACHE_KEY = "api:payment_check:last_run"
+
+ before_action :require_api_key
+ before_action :set_user
+
+ def show
+ refresh_payments! if params[:refresh].present?
+ return if performed?
+
+ render json: {
+ subscription: {
+ active: @user.subscribed?,
+ expires_at: @user.effective_subscription_expires_at&.iso8601
+ }
+ }
+ end
+
+ private
+
+ def require_api_key
+ render json: { error: "API key is required" }, status: :bad_request if params[:api_key].blank?
+ end
+
+ def set_user
+ @user = User.find_by_api_key(params[:api_key].to_s.strip)
+ render json: { error: "Unknown API key. Please sign in first." }, status: :not_found unless @user
+ end
+
+ def refresh_payments!
+ if rate_limited?
+ render json: { error: "Payment check was run recently. Try again in #{seconds_until_available} seconds." },
+ status: :too_many_requests
+ else
+ Rails.cache.write(CACHE_KEY, Time.current, expires_in: PAYMENT_CHECK_COOLDOWN)
+ Daily::XanaxPaymentsJob.perform_now
+ @user.reload
+ end
+ 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/controllers/api/wars_controller.rb b/app/controllers/api/wars_controller.rb
new file mode 100644
index 0000000..1842c14
--- /dev/null
+++ b/app/controllers/api/wars_controller.rb
@@ -0,0 +1,97 @@
+module Api
+ class WarsController < ActionController::API
+ 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
+
+ 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
+
+ unless faction.torn_api_key.present?
+ return render json: { error: "Faction API keys not configured. Ask your faction leader to set them up on tornmanager.com." }, status: :unprocessable_entity
+ end
+
+ spy_reports = faction.spy_reports.for_targets(torn_ids).index_by(&:torn_id)
+
+ members_status = {}
+ if enemy_faction_id.present?
+ members_status = fetch_enemy_status(faction.torn_api_key.key, enemy_faction_id)
+ end
+
+ 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)
+ 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)
+ 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?
+
+ 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/application_controller.rb b/app/controllers/application_controller.rb
index 5f38f02..21fc144 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,8 +1,6 @@
class ApplicationController < ActionController::Base
include Authentication
- # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
- allow_browser versions: :modern
- # Changes to the importmap will invalidate the etag for HTML responses
+ allow_browser versions: :modern
stale_when_importmap_changes
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/concerns/faction_access.rb b/app/controllers/concerns/faction_access.rb
new file mode 100644
index 0000000..703f1d1
--- /dev/null
+++ b/app/controllers/concerns/faction_access.rb
@@ -0,0 +1,44 @@
+module FactionAccess
+ extend ActiveSupport::Concern
+
+ private
+
+ def require_faction_member
+ find_faction
+ return if performed?
+
+ unless Current.user.admin? || Current.user.faction == @faction
+ redirect_to root_path, alert: "You don't have access to this faction."
+ end
+ end
+
+ def require_setup_completed
+ find_faction
+ return if performed?
+
+ return if @faction.setup_completed?
+
+ if Current.user.admin? || Current.user.faction_leader?
+ redirect_to setup_faction_path(@faction)
+ else
+ redirect_to setup_unavailable_faction_path(@faction)
+ end
+ end
+
+ def require_faction_leadership
+ find_faction
+ return if performed?
+
+ return if Current.user.admin?
+ return if @faction.leadership.include?(Current.user)
+
+ redirect_to faction_path(@faction), notice: "You don't have access to the Leadership dashboard. Ask your faction leader for access."
+ 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
+end
diff --git a/app/controllers/faction_controller.rb b/app/controllers/faction_controller.rb
deleted file mode 100644
index 344f776..0000000
--- a/app/controllers/faction_controller.rb
+++ /dev/null
@@ -1,4 +0,0 @@
-class FactionController < ApplicationController
- def index
- end
-end
diff --git a/app/controllers/factions/leadership/activity_controller.rb b/app/controllers/factions/leadership/activity_controller.rb
new file mode 100644
index 0000000..395060f
--- /dev/null
+++ b/app/controllers/factions/leadership/activity_controller.rb
@@ -0,0 +1,69 @@
+class Factions::Leadership::ActivityController < Factions::Leadership::BaseController
+ MIN_POLLS_FOR_DATA = 96 # 24 hours * 4 polls per hour
+
+ def show
+ poll_count = @faction.member_activity_snapshots.distinct.count(:recorded_at)
+
+ unless poll_count >= MIN_POLLS_FOR_DATA
+ redirect_to faction_leadership_path(@faction), notice: "Activity data is still being collected. Check back in a few hours."
+ return
+ end
+
+ @dates = ((Date.current - 13)..Date.current).to_a
+ @member_count = @faction.users.active.count
+ @calendar = MemberActivitySnapshot.calendar_heatmap(@faction.id, @dates.first, @dates.last)
+ @max_heatmap_value = @calendar.values.max || 1
+ @members = MemberActivitySnapshot.member_summary(@faction.id)
+ @earliest_snapshot = @faction.member_activity_snapshots.minimum(:recorded_at)
+ @first_data_date = @earliest_snapshot&.to_date || Date.current
+ @member_hourly = MemberActivitySnapshot.member_hourly_summary(@faction.id)
+ build_chain_coverage
+ end
+
+ private
+
+ def build_chain_coverage
+ polls_per_hour = 4
+ @hourly_avg = (0..23).map do |hour|
+ counts = @dates.map { |date| (@calendar[[ date.to_s, hour ]] || 0) / polls_per_hour }
+ avg = counts.sum.to_f / counts.size
+ { hour: hour, avg: avg.round, min: counts.min, max: counts.max }
+ end
+
+ threshold = @member_count * 0.25
+ @danger_windows = []
+ current_window = nil
+
+ @hourly_avg.each do |h|
+ if h[:avg] < threshold
+ if current_window
+ current_window[:end_hour] = (h[:hour] + 1) % 24
+ current_window[:hours] << h
+ else
+ current_window = {
+ start_hour: h[:hour],
+ end_hour: (h[:hour] + 1) % 24,
+ hours: [ h ]
+ }
+ end
+ else
+ if current_window
+ finalize_window(current_window)
+ @danger_windows << current_window
+ current_window = nil
+ end
+ end
+ end
+
+ if current_window
+ finalize_window(current_window)
+ @danger_windows << current_window
+ end
+ end
+
+ def finalize_window(window)
+ window[:avg] = (window[:hours].sum { |x| x[:avg] } / window[:hours].size).round
+ window[:min] = window[:hours].map { |x| x[:min] }.min
+ window[:duration] = window[:hours].size
+ end
+end
diff --git a/app/controllers/factions/leadership/api_keys_controller.rb b/app/controllers/factions/leadership/api_keys_controller.rb
new file mode 100644
index 0000000..dc6e72b
--- /dev/null
+++ b/app/controllers/factions/leadership/api_keys_controller.rb
@@ -0,0 +1,66 @@
+class Factions::Leadership::ApiKeysController < Factions::Leadership::BaseController
+ def update
+ new_torn_key = params.dig(:faction_setting, :torn_api_key).presence
+ new_tornstats_key = params.dig(:faction_setting, :tornstats_api_key).presence
+
+ changes_made = false
+
+ if new_torn_key
+ begin
+ key_info = TornApi::Key::Info.new(new_torn_key).fetch
+
+ unless key_info.access.type == "Limited Access"
+ return redirect_to faction_leadership_settings_path(@faction),
+ alert: "Only Limited Access keys are allowed."
+ end
+
+ unless key_info.access.faction == true
+ return redirect_to faction_leadership_settings_path(@faction),
+ alert: "This API key does not have faction access. Please enable faction access in your Torn API key settings."
+ end
+
+ unless Current.user.admin? || key_info.user.id == Current.user.torn_id
+ return redirect_to faction_leadership_settings_path(@faction), alert: "This API key does not belong to you."
+ end
+
+ torn_record = @faction.torn_api_key || @faction.build_torn_api_key
+ torn_record.update!(
+ key: new_torn_key,
+ access_type: key_info.access.type,
+ faction_access: key_info.access.faction == true
+ )
+ changes_made = true
+ rescue TornApi::InvalidKeyError
+ return redirect_to faction_leadership_settings_path(@faction), alert: "Invalid Torn API key."
+ rescue TornApi::ApiError => e
+ return redirect_to faction_leadership_settings_path(@faction), alert: "Could not validate Torn API key: #{e.message}"
+ end
+ end
+
+ if new_tornstats_key
+ ts_record = @faction.tornstats_api_key || @faction.build_tornstats_api_key
+ ts_record.update!(key: new_tornstats_key)
+ changes_made = true
+ end
+
+ if changes_made
+ redirect_to faction_leadership_settings_path(@faction), notice: "API keys saved successfully."
+ else
+ redirect_to faction_leadership_settings_path(@faction), notice: "No changes made."
+ end
+ end
+
+ def destroy
+ case params[:key]
+ when "torn"
+ @faction.torn_api_key&.destroy!
+ @faction.update!(setup_completed: false)
+ redirect_to faction_path(@faction), notice: "Torn API key deleted. Faction setup has been reset."
+ when "tornstats"
+ @faction.tornstats_api_key&.destroy!
+ redirect_to faction_leadership_settings_path(@faction), notice: "TornStats API key deleted."
+ else
+ redirect_to faction_leadership_settings_path(@faction), alert: "Unknown key type."
+ end
+ end
+end
diff --git a/app/controllers/factions/leadership/api_logs_controller.rb b/app/controllers/factions/leadership/api_logs_controller.rb
new file mode 100644
index 0000000..9dd3f62
--- /dev/null
+++ b/app/controllers/factions/leadership/api_logs_controller.rb
@@ -0,0 +1,34 @@
+class Factions::Leadership::ApiLogsController < Factions::Leadership::BaseController
+ def show
+ load_api_logs_data
+ end
+
+ private
+
+ def load_api_logs_data
+ base_scope = @faction.api_calls
+
+ @api_logs = base_scope.recent.limit(500)
+
+ @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_today = base_scope.today.count
+ @calls_last_24h = base_scope.last_24_hours.count
+
+ @peak_rate_today = calculate_api_peak_rate(base_scope.today)
+ end
+
+ def calculate_api_peak_rate(scope)
+ 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
diff --git a/app/controllers/factions/leadership/armory_controller.rb b/app/controllers/factions/leadership/armory_controller.rb
new file mode 100644
index 0000000..118b05e
--- /dev/null
+++ b/app/controllers/factions/leadership/armory_controller.rb
@@ -0,0 +1,92 @@
+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) }
+
+ @backfill_in_progress = @faction.armory_backfill_pending?
+
+ 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] }
+
+ 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
+ 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/base_controller.rb b/app/controllers/factions/leadership/base_controller.rb
new file mode 100644
index 0000000..93759d1
--- /dev/null
+++ b/app/controllers/factions/leadership/base_controller.rb
@@ -0,0 +1,171 @@
+class Factions::Leadership::BaseController < ApplicationController
+ include FactionAccess
+
+ before_action :require_setup_completed
+ before_action :require_faction_leadership
+ before_action :require_api_keys_configured
+
+ private
+
+ def require_api_keys_configured
+ return if performed?
+ find_faction unless @faction
+ return if performed?
+ return if @faction.torn_api_key.present?
+
+ redirect_to faction_leadership_setup_path(@faction)
+ end
+
+ def load_wars_data
+ @wars = @faction.ranked_wars.recent.includes(:faction)
+
+ current_year_wars = @wars.completed.where(started_at: Date.current.beginning_of_year..)
+ @wins = current_year_wars.won.count
+ @losses = current_year_wars.lost.count
+
+ @ongoing_war = @wars.ongoing.select(&:in_progress?).first
+ @scheduled_war = @wars.ongoing.select(&:scheduled?).first
+
+ @member_performance = calculate_member_performance(current_year_wars)
+ end
+
+ def load_spy_stats_data
+ @spy_reports = @faction.spy_reports.order(total: :desc)
+ @spy_report_count = @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 load_settings_data
+ @faction_setting = @faction.faction_setting || @faction.build_faction_setting
+ @torn_api_key_masked = mask_key(@faction.torn_api_key&.key)
+ @tornstats_api_key_masked = mask_key(@faction.tornstats_api_key&.key)
+ @torn_api_key = @faction.torn_api_key
+ @tornstats_api_key = @faction.tornstats_api_key
+ @leadership_users = @faction.leadership.order(:name)
+ @faction_members = @faction.users.active.where(leadership_access: false).order(:name)
+ @subscription_weeks_remaining = Current.user.subscription_weeks_remaining
+ @faction_member_count = @faction.users.active.count
+ faction_sub = @faction.subscription
+ @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 / 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
+
+ def load_api_peak_rate
+ counts_by_minute = @faction.api_calls
+ .today
+ .group("strftime('%Y-%m-%d %H:%M', created_at)")
+ .count
+
+ @api_peak_rate = counts_by_minute.values.max || 0
+ end
+
+ def load_data_coverage
+ faction_user_ids = @faction.users.active.pluck(:id)
+
+ if faction_user_ids.empty?
+ @data_coverage_rate = 0.0
+ @data_missing_yesterday = 0
+ @data_total_missing_days = 0
+ return
+ end
+
+ start_date = PersonalStatSnapshot.tracking_start_date
+ end_date = PersonalStatSnapshot.tracking_end_date
+ expected_days = (start_date..end_date).count
+
+ total_expected = faction_user_ids.size * expected_days
+ total_existing = PersonalStatSnapshot
+ .where(user_id: faction_user_ids)
+ .where(date: start_date..end_date)
+ .count
+
+ @data_coverage_rate = total_expected > 0 ? (total_existing.to_f / total_expected * 100).round(1) : 0.0
+
+ yesterday_user_ids = PersonalStatSnapshot
+ .where(user_id: faction_user_ids, date: Date.yesterday)
+ .distinct
+ .pluck(:user_id)
+ @data_missing_yesterday = faction_user_ids.size - yesterday_user_ids.size
+
+ @data_total_missing_days = total_expected - total_existing
+ end
+
+ def load_activity_data
+ @activity_poll_count = @faction.member_activity_snapshots.distinct.count(:recorded_at)
+ @activity_ready = @activity_poll_count >= 96
+ if @activity_ready
+ first = @faction.member_activity_snapshots.minimum(:recorded_at)
+ @activity_days = first ? (Date.current - first.to_date).to_i : 0
+ else
+ first_snapshot = @faction.member_activity_snapshots.minimum(:recorded_at)
+ data_ready_at = first_snapshot ? first_snapshot + 24.hours : Time.current + 24.hours
+ @activity_seconds_remaining = [ (data_ready_at - Time.current).to_i, 0 ].max
+ end
+ 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
+
+ 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
+
+ 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
+
+ IMPORT_COOLDOWN = 1.minute
+end
diff --git a/app/controllers/factions/leadership/data_coverage_controller.rb b/app/controllers/factions/leadership/data_coverage_controller.rb
new file mode 100644
index 0000000..5b2de5b
--- /dev/null
+++ b/app/controllers/factions/leadership/data_coverage_controller.rb
@@ -0,0 +1,71 @@
+class Factions::Leadership::DataCoverageController < Factions::Leadership::BaseController
+ SECONDS_PER_API_CALL = 1.1
+
+ def show
+ load_data_coverage
+ load_member_coverage
+ end
+
+ def backfill_user
+ user = @faction.users.find(params[:user_id])
+ missing_dates = missing_dates_for(user)
+ api_key = @faction.torn_api_key&.key
+ return render json: { success: false, message: "No API key configured for this faction" }, status: :unprocessable_entity if api_key.blank?
+
+ existing_queued_jobs = SolidQueue::Job.where(queue_name: "faction", finished_at: nil).count
+
+ missing_dates.each_with_index do |date, index|
+ BackfillSingleStatJob.set(wait: index.seconds).perform_later(user.id, date.to_s, faction_id: @faction.id, api_key: api_key)
+ end
+
+ 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
+
+ def load_member_coverage
+ members = @faction.users.active.order(:name)
+ start_date = PersonalStatSnapshot.tracking_start_date
+ end_date = PersonalStatSnapshot.tracking_end_date
+ @expected_days = (start_date..end_date).count
+ expected_dates = (start_date..end_date).to_a
+
+ existing_snapshots = PersonalStatSnapshot
+ .where(user_id: members.pluck(:id))
+ .where(date: start_date..end_date)
+ .pluck(:user_id, :date)
+ .group_by(&:first)
+ .transform_values { |pairs| pairs.map(&:last).to_set }
+
+ @member_coverage = members.filter_map do |member|
+ user_dates = existing_snapshots[member.id] || Set.new
+ existing = user_dates.size
+ missing_dates = expected_dates - user_dates.to_a
+ next if missing_dates.empty?
+
+ rate = @expected_days > 0 ? (existing.to_f / @expected_days * 100).round(1) : 0.0
+
+ {
+ user: member,
+ existing: existing,
+ missing: missing_dates.size,
+ missing_dates: missing_dates.sort,
+ rate: rate
+ }
+ end.sort_by { |m| m[:rate] }
+
+ @tracking_start = start_date
+ @tracking_end = end_date
+ end
+
+ def missing_dates_for(user)
+ existing = user.personal_stat_snapshots.pluck(:date).to_set
+ expected = (PersonalStatSnapshot.tracking_start_date..PersonalStatSnapshot.tracking_end_date).to_a
+ expected - existing.to_a
+ end
+end
diff --git a/app/controllers/factions/leadership/faction_data_controller.rb b/app/controllers/factions/leadership/faction_data_controller.rb
new file mode 100644
index 0000000..799cf8a
--- /dev/null
+++ b/app/controllers/factions/leadership/faction_data_controller.rb
@@ -0,0 +1,10 @@
+class Factions::Leadership::FactionDataController < Factions::Leadership::BaseController
+ def destroy
+ @faction.delete_all_data!
+
+ redirect_to faction_path(@faction), notice: "All faction data has been deleted. Subscription time has been preserved."
+ rescue => e
+ Rails.logger.error("Delete faction data failed for faction #{@faction.torn_id}: #{e.class} - #{e.message}")
+ redirect_to faction_leadership_settings_path(@faction), alert: "Failed to delete faction data: #{e.message}"
+ end
+end
diff --git a/app/controllers/factions/leadership/leadership_access_controller.rb b/app/controllers/factions/leadership/leadership_access_controller.rb
new file mode 100644
index 0000000..7d3d432
--- /dev/null
+++ b/app/controllers/factions/leadership/leadership_access_controller.rb
@@ -0,0 +1,58 @@
+class Factions::Leadership::LeadershipAccessController < Factions::Leadership::BaseController
+ def create
+ user = @faction.users.find_by(id: params[:user_id])
+
+ if user.nil?
+ @flash_type = "alert"
+ @flash_message = "User not found in this faction."
+ elsif user.leadership_access?
+ @flash_type = "notice"
+ @flash_message = "#{user.name} already has access."
+ else
+ user.update!(leadership_access: true)
+ @flash_type = "notice"
+ @flash_message = "#{user.name} has been granted access."
+ end
+
+ load_settings_data
+ respond_to do |format|
+ format.turbo_stream do
+ render turbo_stream: [
+ turbo_stream.update("leadership-access", partial: "factions/leadership/leadership_access"),
+ turbo_stream.append("flash-notifications", partial: "layouts/flash", locals: { type: @flash_type, message: @flash_message })
+ ]
+ end
+ format.html { redirect_to faction_leadership_settings_path(@faction), @flash_type.to_sym => @flash_message }
+ end
+ end
+
+ def destroy
+ user = @faction.leadership.find_by(id: params[:user_id])
+
+ if user.nil?
+ @flash_type = "alert"
+ @flash_message = "User not found in leadership."
+ elsif user == Current.user
+ @flash_type = "alert"
+ @flash_message = "You cannot remove your own access."
+ elsif user.faction_leader?
+ @flash_type = "alert"
+ @flash_message = "#{user.name} is #{user.position} and cannot be removed."
+ else
+ user.update!(leadership_access: false)
+ @flash_type = "notice"
+ @flash_message = "#{user.name}'s access has been removed."
+ end
+
+ load_settings_data
+ respond_to do |format|
+ format.turbo_stream do
+ render turbo_stream: [
+ turbo_stream.update("leadership-access", partial: "factions/leadership/leadership_access"),
+ turbo_stream.append("flash-notifications", partial: "layouts/flash", locals: { type: @flash_type, message: @flash_message })
+ ]
+ end
+ format.html { redirect_to faction_leadership_settings_path(@faction), @flash_type.to_sym => @flash_message }
+ end
+ end
+end
diff --git a/app/controllers/factions/leadership/settings_controller.rb b/app/controllers/factions/leadership/settings_controller.rb
new file mode 100644
index 0000000..93aa5aa
--- /dev/null
+++ b/app/controllers/factions/leadership/settings_controller.rb
@@ -0,0 +1,5 @@
+class Factions::Leadership::SettingsController < Factions::Leadership::BaseController
+ def show
+ load_settings_data
+ end
+end
diff --git a/app/controllers/factions/leadership/setup_controller.rb b/app/controllers/factions/leadership/setup_controller.rb
new file mode 100644
index 0000000..c584cab
--- /dev/null
+++ b/app/controllers/factions/leadership/setup_controller.rb
@@ -0,0 +1,57 @@
+class Factions::Leadership::SetupController < Factions::Leadership::BaseController
+ skip_before_action :require_setup_completed
+ skip_before_action :require_faction_leadership
+ skip_before_action :require_api_keys_configured
+ before_action :find_faction
+
+ def show
+ @faction_setting = @faction.faction_setting || @faction.build_faction_setting
+ end
+
+ def update
+ new_torn_key = params.dig(:faction_setting, :torn_api_key).presence
+ new_tornstats_key = params.dig(:faction_setting, :tornstats_api_key).presence
+
+ unless new_torn_key.present?
+ return redirect_to faction_leadership_setup_path(@faction), alert: "Torn API key is required."
+ end
+
+ begin
+ key_info = TornApi::Key::Info.new(new_torn_key).fetch
+
+ unless key_info.access.type == "Limited Access"
+ return redirect_to faction_leadership_setup_path(@faction),
+ alert: "Only Limited Access keys are allowed. Please create a Limited Access key in your Torn settings."
+ end
+
+ unless key_info.access.faction == true
+ return redirect_to faction_leadership_setup_path(@faction),
+ alert: "This API key does not have faction access. Please enable faction access in your Torn API key settings."
+ end
+
+ unless Current.user.admin? || key_info.user.id == Current.user.torn_id
+ return redirect_to faction_leadership_setup_path(@faction), alert: "This API key does not belong to you."
+ end
+
+ @faction.create_faction_setting! unless @faction.faction_setting
+
+ torn_record = @faction.torn_api_key || @faction.build_torn_api_key
+ torn_record.update!(
+ key: new_torn_key,
+ access_type: key_info.access.type,
+ faction_access: key_info.access.faction == true
+ )
+
+ if new_tornstats_key.present?
+ ts_record = @faction.tornstats_api_key || @faction.build_tornstats_api_key
+ ts_record.update!(key: new_tornstats_key)
+ end
+
+ redirect_to faction_leadership_path(@faction), notice: "Faction configured successfully! You now have access to war tracking and analytics."
+ rescue TornApi::InvalidKeyError
+ redirect_to faction_leadership_setup_path(@faction), alert: "Invalid Torn API key."
+ rescue TornApi::ApiError => e
+ redirect_to faction_leadership_setup_path(@faction), alert: "Could not validate Torn API key: #{e.message}"
+ end
+ end
+end
diff --git a/app/controllers/factions/leadership/spy_imports_controller.rb b/app/controllers/factions/leadership/spy_imports_controller.rb
new file mode 100644
index 0000000..5a7f67f
--- /dev/null
+++ b/app/controllers/factions/leadership/spy_imports_controller.rb
@@ -0,0 +1,43 @@
+class Factions::Leadership::SpyImportsController < Factions::Leadership::BaseController
+ def create
+ target_faction_id = params[:target_faction_id].to_s.strip
+
+ if target_faction_id.blank?
+ return redirect_to faction_leadership_spy_reports_path(@faction), alert: "Please enter a faction ID to import spy data for."
+ end
+
+ unless @faction.tornstats_api_key&.key.present?
+ return redirect_to faction_leadership_spy_reports_path(@faction), alert: "TornStats API key must be configured before importing spy data."
+ end
+
+ if rate_limited?
+ return redirect_to faction_leadership_spy_reports_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
+ spies = TornStatsApi::SpyFaction.new(
+ @faction.tornstats_api_key.key,
+ faction_id: target_faction_id
+ ).fetch
+
+ imported = 0
+ spies.each do |spy|
+ @faction.import_spy_report(spy)
+ imported += 1
+ end
+
+ Rails.cache.delete(@faction.war_cache_key)
+
+ redirect_to faction_leadership_spy_reports_path(@faction), notice: "Successfully imported #{imported} spy reports."
+ rescue TornStatsApi::NotFoundError => e
+ redirect_to faction_leadership_spy_reports_path(@faction), alert: "No spy data found: #{e.message}"
+ rescue TornStatsApi::InvalidKeyError => e
+ redirect_to faction_leadership_spy_reports_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_leadership_spy_reports_path(@faction), alert: "Import failed: #{e.message}"
+ end
+ end
+end
diff --git a/app/controllers/factions/leadership/spy_reports_controller.rb b/app/controllers/factions/leadership/spy_reports_controller.rb
new file mode 100644
index 0000000..c05d6b2
--- /dev/null
+++ b/app/controllers/factions/leadership/spy_reports_controller.rb
@@ -0,0 +1,65 @@
+class Factions::Leadership::SpyReportsController < Factions::Leadership::BaseController
+ def show
+ unless @faction.tornstats_api_key.present?
+ return redirect_to faction_leadership_path(@faction), alert: "Configure your TornStats API key in Settings to access spy reports."
+ end
+
+ load_spy_stats_data
+ load_settings_data
+ @current_war = @faction.current_war
+ end
+
+ def update
+ report = @faction.spy_reports.find(params[:id])
+ report.update!(spy_report_params)
+
+ render json: { success: true }
+ rescue ActiveRecord::RecordInvalid => e
+ render json: { success: false, message: e.message }, status: :unprocessable_entity
+ end
+
+ def destroy
+ report = @faction.spy_reports.find(params[:id])
+ report.destroy!
+
+ redirect_to faction_leadership_spy_reports_path(@faction), notice: "Spy report deleted."
+ end
+
+ def fetch_enemy
+ war = @faction.current_war
+ unless war
+ return redirect_to faction_leadership_spy_reports_path(@faction), alert: "No active war found."
+ end
+
+ unless @faction.tornstats_api_key&.key.present?
+ return redirect_to faction_leadership_spy_reports_path(@faction), alert: "TornStats API key must be configured."
+ end
+
+ spies = TornStatsApi::SpyFaction.new(
+ @faction.tornstats_api_key.key,
+ faction_id: war.opponent_faction_id
+ ).fetch
+
+ imported = 0
+ spies.each do |spy|
+ @faction.import_spy_report(spy)
+ imported += 1
+ end
+
+ Rails.cache.delete(@faction.war_cache_key)
+
+ redirect_to faction_leadership_spy_reports_path(@faction), notice: "Successfully imported #{imported} spy reports for #{war.opponent_faction_name}."
+ rescue TornStatsApi::NotFoundError => e
+ redirect_to faction_leadership_spy_reports_path(@faction), alert: "No spy data found: #{e.message}"
+ rescue TornStatsApi::InvalidKeyError => e
+ redirect_to faction_leadership_spy_reports_path(@faction), alert: "Invalid TornStats API key: #{e.message}"
+ rescue TornStatsApi::ApiError => e
+ redirect_to faction_leadership_spy_reports_path(@faction), alert: "Import failed: #{e.message}"
+ end
+
+ private
+
+ def spy_report_params
+ params.require(:spy_report).permit(:strength, :defense, :speed, :dexterity)
+ end
+end
diff --git a/app/controllers/factions/leadership/subscriptions_controller.rb b/app/controllers/factions/leadership/subscriptions_controller.rb
new file mode 100644
index 0000000..ecac781
--- /dev/null
+++ b/app/controllers/factions/leadership/subscriptions_controller.rb
@@ -0,0 +1,49 @@
+class Factions::Leadership::SubscriptionsController < Factions::Leadership::BaseController
+ def create
+ faction_weeks = params[:weeks].to_i
+
+ if faction_weeks <= 0
+ return redirect_to faction_leadership_settings_path(@faction), alert: "Please enter a valid number of weeks."
+ end
+
+ personal_cost = faction_weeks * faction_week_cost
+ available = Current.user.subscription_weeks_remaining
+
+ unless available >= personal_cost
+ return redirect_to faction_leadership_settings_path(@faction),
+ alert: "You need #{personal_cost} personal weeks but only have #{available}."
+ end
+
+ ActiveRecord::Base.transaction do
+ Current.user.deduct_subscription!(personal_cost)
+
+ if @faction.subscription
+ @faction.subscription.extend!(faction_weeks)
+ else
+ @faction.create_subscription!(expires_at: Time.current + faction_weeks.weeks)
+ end
+
+ FactionSubscriptionGrant.create!(
+ torn_faction_id: @faction.torn_id,
+ faction: @faction,
+ faction_name: @faction.name,
+ weeks_granted: faction_weeks,
+ granted_by: Current.user,
+ granted_at: Time.current
+ )
+ end
+
+ redirect_to faction_leadership_settings_path(@faction),
+ notice: "Extended faction subscription by #{faction_weeks} week(s) (#{personal_cost} personal weeks used)."
+ rescue => e
+ Rails.logger.error("Extend faction subscription failed for user #{Current.user.torn_id}: #{e.class} - #{e.message}")
+ redirect_to faction_leadership_settings_path(@faction), alert: "Failed to extend subscription: #{e.message}"
+ end
+
+ private
+
+ def faction_week_cost
+ (@faction.users.active.count / 4.0).ceil.clamp(1, 100)
+ end
+ helper_method :faction_week_cost
+end
diff --git a/app/controllers/factions/leadership/war_history_controller.rb b/app/controllers/factions/leadership/war_history_controller.rb
new file mode 100644
index 0000000..aedae8f
--- /dev/null
+++ b/app/controllers/factions/leadership/war_history_controller.rb
@@ -0,0 +1,49 @@
+class Factions::Leadership::WarHistoryController < Factions::Leadership::BaseController
+ REFRESH_COOLDOWN = 60.seconds
+
+ def show
+ load_wars_data
+ load_refresh_state
+ end
+
+ def refresh
+ if refresh_on_cooldown?
+ return redirect_to faction_leadership_war_history_path(@faction), alert: "Please wait before refreshing again."
+ end
+
+ Rails.cache.write(refresh_cache_key, Time.current, expires_in: REFRESH_COOLDOWN)
+
+ api_key = @faction.torn_api_key&.key
+ if api_key.present?
+ BackfillRankedWarsJob.perform_now(@faction.id)
+ end
+
+ load_wars_data
+ load_refresh_state
+
+ render :show
+ end
+
+ private
+
+ def load_refresh_state
+ @can_refresh = !refresh_on_cooldown?
+ @refresh_seconds_remaining = refresh_seconds_remaining
+ end
+
+ def refresh_cache_key
+ "faction:#{@faction.id}:war_history_refresh"
+ end
+
+ def refresh_on_cooldown?
+ Rails.cache.read(refresh_cache_key).present?
+ end
+
+ def refresh_seconds_remaining
+ last_refresh = Rails.cache.read(refresh_cache_key)
+ return 0 unless last_refresh
+
+ remaining = REFRESH_COOLDOWN - (Time.current - last_refresh)
+ [ remaining.to_i, 0 ].max
+ end
+end
diff --git a/app/controllers/factions/leadership/war_polling_controller.rb b/app/controllers/factions/leadership/war_polling_controller.rb
new file mode 100644
index 0000000..217f18c
--- /dev/null
+++ b/app/controllers/factions/leadership/war_polling_controller.rb
@@ -0,0 +1,17 @@
+class Factions::Leadership::WarPollingController < Factions::Leadership::BaseController
+ def start
+ war = @faction.current_war
+ unless war
+ redirect_to faction_leadership_path(@faction), alert: "No active ranked war to poll."
+ return
+ end
+
+ @faction.start_war_polling!
+ redirect_to faction_leadership_path(@faction), notice: "War polling started."
+ end
+
+ def stop
+ @faction.stop_war_polling!
+ redirect_to faction_leadership_path(@faction), notice: "War polling stopped."
+ end
+end
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..08c540b
--- /dev/null
+++ b/app/controllers/factions/leadership/war_reports_controller.rb
@@ -0,0 +1,111 @@
+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
+
+ @payout_settings = @faction.faction_setting || @faction.build_faction_setting
+ load_war_report if @selected_war
+ end
+
+ def save_payout_settings
+ setting = @faction.faction_setting || @faction.create_faction_setting!
+ setting.update!(
+ payout_faction_cut: params[:faction_cut],
+ payout_assist_value: params[:assist_value]
+ )
+
+ render json: { success: true }
+ rescue ActiveRecord::RecordInvalid => e
+ render json: { success: false, message: e.message }, status: :unprocessable_entity
+ 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)
+ war.calculate_reward_value!(@faction.torn_api_key.key)
+
+ redirect_to faction_leadership_war_reports_path(@faction, war: war.torn_war_id),
+ notice: "Fetched attack logs and reward prices 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/controllers/factions/leadership_controller.rb b/app/controllers/factions/leadership_controller.rb
new file mode 100644
index 0000000..310e69a
--- /dev/null
+++ b/app/controllers/factions/leadership_controller.rb
@@ -0,0 +1,41 @@
+class Factions::LeadershipController < Factions::Leadership::BaseController
+ include FactionHelper
+
+ skip_before_action :require_api_keys_configured, only: [ :war_data ]
+
+ def show
+ load_wars_data
+ load_spy_stats_data
+ load_settings_data
+ 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
+ war_data = Rails.cache.read(@faction.war_cache_key)
+
+ if war_data
+ render json: war_data
+ else
+ render json: {}, status: :no_content
+ end
+ end
+end
diff --git a/app/controllers/factions/public_war_controller.rb b/app/controllers/factions/public_war_controller.rb
new file mode 100644
index 0000000..67d3935
--- /dev/null
+++ b/app/controllers/factions/public_war_controller.rb
@@ -0,0 +1,34 @@
+class Factions::PublicWarController < ApplicationController
+ include FactionAccess
+ include FactionHelper
+
+ allow_unauthenticated_access only: [ :show ]
+ before_action :find_public_faction
+
+ def show
+ @current_war = @faction.current_war
+
+ unless @current_war
+ redirect_to root_path, alert: "No active war to display."
+ return
+ end
+
+ @war_data = Rails.cache.read(@faction.war_cache_key)
+ end
+
+ private
+
+ def find_public_faction
+ torn_id = params[:faction_torn_id] || params[:torn_id]
+ @faction = Faction.find_by(torn_id: torn_id)
+
+ unless @faction
+ redirect_to root_path, alert: "Faction not found."
+ return
+ end
+
+ unless @faction.public_wars
+ redirect_to root_path, alert: "This faction does not have public wars enabled."
+ end
+ end
+end
diff --git a/app/controllers/factions/ranked_wars_controller.rb b/app/controllers/factions/ranked_wars_controller.rb
new file mode 100644
index 0000000..65a766c
--- /dev/null
+++ b/app/controllers/factions/ranked_wars_controller.rb
@@ -0,0 +1,20 @@
+class Factions::RankedWarsController < ApplicationController
+ include FactionAccess
+
+ before_action :require_faction_member
+ before_action :find_war
+
+ def show
+ @our_members = @war.our_members.sort_by { |m| -m["score"].to_f }
+ @their_members = @war.their_members.sort_by { |m| -m["score"].to_f }
+ @our_non_participants = @war.our_non_participants
+ end
+
+ private
+
+ def find_war
+ @war = @faction.ranked_wars.find_by!(torn_war_id: params[:id])
+ rescue ActiveRecord::RecordNotFound
+ redirect_to root_path, alert: "War not found."
+ end
+end
diff --git a/app/controllers/factions/war_history_controller.rb b/app/controllers/factions/war_history_controller.rb
new file mode 100644
index 0000000..dcce4e0
--- /dev/null
+++ b/app/controllers/factions/war_history_controller.rb
@@ -0,0 +1,54 @@
+class Factions::WarHistoryController < ApplicationController
+ include FactionAccess
+
+ before_action :require_faction_member
+
+ def show
+ @wars = @faction.ranked_wars.recent.includes(:faction)
+
+ current_year_wars = @wars.completed.where(started_at: Date.current.beginning_of_year..)
+ @wins = current_year_wars.won.count
+ @losses = current_year_wars.lost.count
+
+ @member_performance = calculate_member_performance(current_year_wars)
+ end
+
+ private
+
+ 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
+
+ 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/factions_controller.rb b/app/controllers/factions_controller.rb
new file mode 100644
index 0000000..61d9dc3
--- /dev/null
+++ b/app/controllers/factions_controller.rb
@@ -0,0 +1,260 @@
+class FactionsController < ApplicationController
+ include FactionAccess
+ 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 :require_faction_member, only: [ :war_data ]
+ before_action :find_faction_and_check_access, only: [ :show ]
+ before_action :require_setup_completed, only: [ :show ]
+ before_action :find_faction_for_setup, only: [ :setup, :create, :setup_unavailable ]
+ before_action :require_faction_leader_for_setup, only: [ :setup, :create ]
+
+ def index
+ if Current.user.faction.present?
+ redirect_to faction_path(Current.user.faction)
+ else
+ redirect_to stocks_path
+ end
+ end
+
+ def show
+ unless Current.user.subscribed? || Current.user.admin?
+ return render :subscription_expired
+ end
+
+ load_hero_data
+ load_training_data
+ load_war_data
+ load_data_coverage
+ end
+
+ def setup
+ @api_key_prefill = Current.user.has_limited_access? ? Current.user.api_key : nil
+ end
+
+ def setup_unavailable
+ end
+
+ MAX_FACTIONS = 15
+
+ def create
+ if Faction.where(setup_completed: true).count >= MAX_FACTIONS
+ flash.now[:alert] = "Maximum number of factions (#{MAX_FACTIONS}) has been reached. Join our Discord from the menu and mention it in #support."
+ return render :setup, status: :unprocessable_entity
+ end
+
+ api_key = params[:api_key].to_s.strip
+
+ begin
+ key_info = TornApi::Key::Info.new(api_key).fetch
+ rescue TornApi::InvalidKeyError
+ flash.now[:alert] = "Invalid API key. Please check and try again."
+ return render :setup, status: :unprocessable_entity
+ end
+
+ 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
+
+ unless key_info.access.faction == true
+ flash.now[:alert] = "This API key does not have faction access. Please enable faction access in your Torn API key settings."
+ return render :setup, status: :unprocessable_entity
+ end
+
+ 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
+
+ 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
+
+ @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)
+
+ Current.user.update!(leadership_access: true)
+
+ members = TornApi::Faction::Members.new(api_key, @faction.torn_id).fetch
+
+ @faction.create_subscription!(expires_at: 14.days.from_now) unless @faction.subscription
+
+ members.each do |member|
+ user = User.find_by(torn_id: member.id)
+ next unless user
+
+ attrs = { position: member.position }
+ attrs[:leadership_access] = true if %w[Leader Co-leader].include?(member.position)
+ user.update!(attrs)
+ end
+
+ 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
+ estimated_seconds = [ total_api_calls * BackfillPersonalStatsJob::SECONDS_PER_API_CALL, 1 ].max.to_i
+
+ @faction.update!(
+ backfill_ends_at: Time.current + estimated_seconds.seconds,
+ backfill_target_date: start_date
+ )
+
+ @faction.update!(armory_backfill_pending: true)
+ BackfillArmoryNewsJob.perform_later(@faction.id)
+ BackfillRankedWarsJob.perform_later(@faction.id)
+ BackfillPersonalStatsJob.perform_later(
+ @faction.id,
+ start_date.to_s,
+ end_date.to_s
+ )
+
+ @faction.update!(setup_completed: true)
+
+ redirect_to faction_path(@faction), notice: "Your faction has been set up. Welcome to TornManager!"
+ end
+
+ def war_data
+ war_data = Rails.cache.read(@faction.war_cache_key)
+
+ if war_data
+ render json: war_data
+ else
+ render json: {}, status: :no_content
+ end
+ end
+
+ helper_method :sort_link
+
+ private
+
+ def find_faction_and_check_access
+ find_faction
+ return if performed?
+
+ unless Current.user.admin? || Current.user.faction == @faction
+ redirect_to root_path, alert: "You don't have access to this faction."
+ end
+ end
+
+ def require_faction_leader_for_setup
+ return if performed?
+ return if Current.user.admin? || Current.user.faction_leader?
+
+ redirect_to setup_unavailable_faction_path(@faction)
+ end
+
+ def find_faction_for_setup
+ torn_id = params[:torn_id]
+ @faction = Faction.find_by(torn_id: torn_id)
+
+ unless @faction
+ redirect_to root_path, alert: "Faction not found."
+ return
+ end
+
+ unless Current.user.faction == @faction
+ redirect_to root_path, alert: "You cannot set up this faction."
+ return
+ end
+
+ if @faction.setup_completed?
+ redirect_to root_path, alert: "This faction is already set up."
+ end
+ end
+
+ def load_hero_data
+ @member_count = @faction.users.active.count
+
+ 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
+
+ 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)
+ @weekly_top_performers = weekly_summary.member_rows
+ .sort_by { |row| -row[:xanax_daily] }
+ .first(10)
+ @week_start = last_week_start
+ @week_end = last_week_end
+ end
+
+ def load_training_data
+ @earliest_date = PersonalStatSnapshot.tracking_start_date
+ @latest_date = PersonalStatSnapshot.tracking_end_date
+
+ @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
+
+ @backfilling_members = @faction.users.where("backfill_ends_at > ?", Time.current)
+
+ summary = ComplianceSummary.new(@faction, start_date: @start_date, end_date: @end_date)
+
+ @total_days_tracked = summary.total_days
+ @member_rows = summary.member_rows
+ @compliant_members_count = summary.compliant_count
+ @warning_members_count = summary.warning_count
+ @non_compliant_members_count = summary.non_compliant_count
+
+ @sort_column = SORTABLE_COLUMNS.include?(params[:sort]) ? params[:sort] : "compliance_score"
+ @sort_direction = params[:direction] == "asc" ? "asc" : "desc"
+
+ @member_rows = @member_rows.sort_by { |row| row[@sort_column.to_sym] || 0 }
+ @member_rows = @member_rows.reverse if @sort_direction == "desc"
+
+ @xanax_target = @faction.xanax_target
+ @energy_target = @faction.energy_refill_target
+ @nerve_target = @faction.nerve_refill_target
+ end
+
+ def load_war_data
+ @current_war = @faction.current_war
+ @latest_war = @faction.ranked_wars.completed.recent.first unless @current_war
+ @api_keys_configured = @faction.torn_api_key.present?
+
+ return unless @current_war && @api_keys_configured
+
+ ensure_war_polling_active
+
+ @war_data = Rails.cache.read(@faction.war_cache_key)
+ end
+
+ def ensure_war_polling_active
+ return unless @faction.torn_api_key.present?
+ return if @faction.war_polling_active?
+
+ @faction.start_war_polling!
+ end
+
+ def load_data_coverage
+ faction_user_ids = @faction.users.active.pluck(:id)
+
+ if faction_user_ids.empty?
+ @data_coverage_rate = 100.0
+ return
+ end
+
+ start_date = PersonalStatSnapshot.tracking_start_date
+ end_date = PersonalStatSnapshot.tracking_end_date
+ expected_days = (start_date..end_date).count
+
+ total_expected = faction_user_ids.size * expected_days
+ total_existing = PersonalStatSnapshot
+ .where(user_id: faction_user_ids)
+ .where(date: start_date..end_date)
+ .count
+
+ @data_coverage_rate = total_expected > 0 ? (total_existing.to_f / total_expected * 100).round(1) : 100.0
+ @data_total_missing_days = total_expected - total_existing
+ 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 }
+ end
+end
diff --git a/app/controllers/hall_of_famers_controller.rb b/app/controllers/hall_of_famers_controller.rb
new file mode 100644
index 0000000..5aa7985
--- /dev/null
+++ b/app/controllers/hall_of_famers_controller.rb
@@ -0,0 +1,74 @@
+class HallOfFamersController < ApplicationController
+ HOF_OWNER_TORN_ID = 2685512
+ SORTABLE_COLUMNS = %w[name xanax_gained energy_drinks_gained networth_gained total_se se_gained].freeze
+
+ before_action :require_hof_access
+
+ def index
+ @earliest_date = PersonalStatSnapshot.tracking_start_date
+ @latest_date = PersonalStatSnapshot.tracking_end_date
+
+ @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
+
+ @total_days_tracked = (@end_date - @start_date).to_i + 1
+
+ query_start_date = @start_date - 1.day
+
+ @table_rows = User.hof_stats_users.includes(:personal_stat_snapshots).filter_map do |user|
+ snapshots = user.personal_stat_snapshots
+ .where(date: query_start_date..@end_date)
+ .order(:date)
+
+ next if snapshots.size < 2
+
+ first = snapshots.first
+ latest = snapshots.last
+ actual_days = (latest.date - first.date).to_i
+
+ xanax_gained = (latest.drugs_xanax || 0) - (first.drugs_xanax || 0)
+ energy_drinks_gained = (latest.items_used_energy_drinks || 0) - (first.items_used_energy_drinks || 0)
+ se_gained = (latest.items_used_stat_enhancers || 0) - (first.items_used_stat_enhancers || 0)
+ networth_gained = (latest.networth_total || 0) - (first.networth_total || 0)
+
+ xanax_daily = actual_days > 0 ? (xanax_gained.to_f / actual_days).round(2) : 0
+ energy_drinks_daily = actual_days > 0 ? (energy_drinks_gained.to_f / actual_days).round(2) : 0
+ se_daily = actual_days > 0 ? (se_gained.to_f / actual_days).round(2) : 0
+ networth_daily = actual_days > 0 ? (networth_gained.to_f / actual_days).round(0) : 0
+
+ {
+ name: user.name,
+ torn_id: user.torn_id,
+ 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,
+ days_tracked: actual_days
+ }
+ end
+
+ @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 }
+ @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
+
+ def require_hof_access
+ redirect_to root_path, alert: "Access denied." unless Current.user&.admin? || Current.user&.torn_id == HOF_OWNER_TORN_ID
+ end
+end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index f643db5..0a87898 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -1,5 +1,15 @@
class HomeController < ApplicationController
allow_unauthenticated_access
+
def index
+ return unless authenticated?
+
+ faction = Current.user.faction
+
+ if faction
+ redirect_to faction_path(faction)
+ else
+ redirect_to stocks_path
+ end
end
end
diff --git a/app/controllers/key_log_controller.rb b/app/controllers/key_log_controller.rb
new file mode 100644
index 0000000..4e6fde6
--- /dev/null
+++ b/app/controllers/key_log_controller.rb
@@ -0,0 +1,31 @@
+class KeyLogController < ApplicationController
+ allow_unauthenticated_access
+
+ def index
+ 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
+ key_info = TornApi::Key::Info.new(api_key)
+ key_info.fetch
+
+ 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/controllers/pages_controller.rb b/app/controllers/pages_controller.rb
new file mode 100644
index 0000000..e7c2ea0
--- /dev/null
+++ b/app/controllers/pages_controller.rb
@@ -0,0 +1,6 @@
+class PagesController < ApplicationController
+ allow_unauthenticated_access
+
+ def legal
+ end
+end
diff --git a/app/controllers/progress_controller.rb b/app/controllers/progress_controller.rb
deleted file mode 100644
index 28c7b91..0000000
--- a/app/controllers/progress_controller.rb
+++ /dev/null
@@ -1,6 +0,0 @@
-class ProgressController < ApplicationController
- def index
- 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] }
- end
-end
diff --git a/app/controllers/public_wars_controller.rb b/app/controllers/public_wars_controller.rb
new file mode 100644
index 0000000..7bae276
--- /dev/null
+++ b/app/controllers/public_wars_controller.rb
@@ -0,0 +1,202 @@
+class PublicWarsController < ApplicationController
+ allow_unauthenticated_access
+
+ before_action :find_lobby, only: [ :show, :war_data, :unlock, :destroy, :stats ]
+
+ def index
+ @lobbies = PublicWarLobby.order(created_at: :desc)
+ flash.now[:alert] = "This lobby has been terminated." if params[:terminated].present?
+ end
+
+ def create
+ api_key = params[:api_key].to_s.strip
+ faction_torn_id = params[:faction_torn_id].to_i
+
+ unless params[:accept_terms] == "1"
+ flash.now[:alert] = "You must accept the Terms of Service and Privacy Policy."
+ return render_index_with_error
+ end
+
+ if api_key.blank?
+ flash.now[:alert] = "API key is required."
+ return render_index_with_error
+ end
+
+ if faction_torn_id <= 0
+ flash.now[:alert] = "Faction Torn ID is required."
+ return render_index_with_error
+ end
+
+ if PublicWarLobby.count >= PublicWarLobby::MAX_LOBBIES
+ flash.now[:alert] = "Maximum number of public lobbies (#{PublicWarLobby::MAX_LOBBIES}) reached. Try again later."
+ return render_index_with_error
+ end
+
+ begin
+ key_info = TornApi::Key::Info.new(api_key).fetch
+ creator = TornApi::User::Profile.new(api_key).fetch
+ rescue TornApi::InvalidKeyError
+ flash.now[:alert] = "Invalid API key. Please check and try again."
+ return render_index_with_error
+ rescue TornApi::ApiError => e
+ flash.now[:alert] = "Torn API error: #{e.message}"
+ return render_index_with_error
+ end
+
+ 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
+
+ begin
+ faction_info = TornApi::Faction::Basic.new(api_key, faction_torn_id).fetch
+ faction_name = faction_info["name"]
+ rescue TornApi::NotFoundError
+ flash.now[:alert] = "Faction not found. Please check the Torn ID."
+ return render_index_with_error
+ rescue TornApi::ApiError => e
+ flash.now[:alert] = "Could not fetch faction info: #{e.message}"
+ return render_index_with_error
+ end
+
+ begin
+ wars = TornApi::Faction::RankedWars.new(api_key, faction_torn_id).fetch(limit: 5)
+ active_war = wars.find { |w| w["end"].to_i == 0 }
+ rescue TornApi::ApiError => e
+ flash.now[:alert] = "Could not fetch ranked wars: #{e.message}"
+ return render_index_with_error
+ end
+
+ unless active_war
+ flash.now[:alert] = "No active ranked war found for this faction."
+ return render_index_with_error
+ end
+
+ opponent = active_war["factions"]&.find { |f| f["id"] != faction_torn_id }
+ opponent_name = opponent&.dig("name") || "Unknown"
+
+ 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
+
+ lobby = PublicWarLobby.new(
+ faction_torn_id: faction_torn_id,
+ faction_name: faction_name,
+ opponent_faction_name: opponent_name,
+ created_by_name: creator.name,
+ created_by_torn_id: creator.id,
+ password: params[:password].presence
+ )
+
+ unless lobby.save
+ flash.now[:alert] = lobby.errors.full_messages.to_sentence
+ return render_index_with_error
+ end
+
+ Rails.cache.write(lobby.api_key_cache_key, api_key)
+
+ PublicWarPollingJob.perform_later(lobby.id)
+
+ redirect_to public_war_path(lobby), notice: "Lobby created! Live polling has started."
+ end
+
+ def show
+ if @lobby.password_protected? && !lobby_unlocked?(@lobby)
+ return redirect_to public_wars_path, alert: "This lobby is password protected. Unlock it from the lobby list."
+ end
+
+ @war_data = Rails.cache.read(@lobby.war_cache_key)
+ end
+
+ def war_data
+ data = Rails.cache.read(@lobby.war_cache_key)
+
+ if data
+ render json: data
+ else
+ render json: {}, status: :no_content
+ end
+ end
+
+ def unlock
+ if @lobby.authenticate(params[:password].to_s)
+ session[:unlocked_lobbies] ||= []
+ session[:unlocked_lobbies] << @lobby.slug unless session[:unlocked_lobbies].include?(@lobby.slug)
+
+ respond_to do |format|
+ format.json { render json: { redirect_to: public_war_path(@lobby) } }
+ format.html { redirect_to public_war_path(@lobby) }
+ end
+ else
+ respond_to do |format|
+ format.json { render json: { error: "Incorrect password." }, status: :unprocessable_entity }
+ format.html do
+ flash.now[:alert] = "Incorrect password."
+ @lobbies = PublicWarLobby.order(created_at: :desc)
+ render :index, status: :unprocessable_entity
+ end
+ end
+ end
+ end
+
+ STAT_FIELDS = %w[strength defense speed dexterity total].freeze
+
+ def stats
+ torn_id = params[:torn_id].to_s
+ stat_params = params.permit(:strength, :defense, :speed, :dexterity, :total)
+
+ stats = stat_params.to_h.transform_values { |v| v.to_s.gsub(/[^0-9]/, "").to_i }
+ stats.reject! { |_, v| v <= 0 }
+
+ if torn_id.blank? || stats.empty?
+ return render json: { error: "Invalid stats data." }, status: :unprocessable_entity
+ end
+
+ spy_stats = Rails.cache.read(@lobby.spy_stats_cache_key) || {}
+ spy_stats[torn_id] = (spy_stats[torn_id] || {}).merge(stats.symbolize_keys)
+
+ individual = spy_stats[torn_id]
+ if !stats.key?(:total) && individual[:strength] && individual[:defense] && individual[:speed] && individual[:dexterity]
+ spy_stats[torn_id][:total] = individual[:strength] + individual[:defense] + individual[:speed] + individual[:dexterity]
+ end
+
+ Rails.cache.write(@lobby.spy_stats_cache_key, spy_stats)
+
+ render json: { stats: spy_stats[torn_id] }
+ end
+
+ def destroy
+ unless params[:confirmation].to_s.strip.downcase == "terminate"
+ flash.now[:alert] = "Confirmation text did not match. Please type: terminate"
+ @war_data = Rails.cache.read(@lobby.war_cache_key)
+ return render :show, status: :unprocessable_entity
+ end
+
+ @lobby.terminate!
+ redirect_to public_wars_path, notice: "Lobby has been terminated."
+ end
+
+ private
+
+ def find_lobby
+ @lobby = PublicWarLobby.find_by!(slug: params[:slug])
+ rescue ActiveRecord::RecordNotFound
+ respond_to do |format|
+ format.json { render json: { terminated: true }, status: :gone }
+ format.html { redirect_to public_wars_path, alert: "This lobby has been terminated." }
+ end
+ end
+
+ def lobby_unlocked?(lobby)
+ return true unless lobby.password_protected?
+
+ session[:unlocked_lobbies]&.include?(lobby.slug)
+ end
+ helper_method :lobby_unlocked?
+
+ def render_index_with_error
+ @lobbies = PublicWarLobby.order(created_at: :desc)
+ render :index, status: :unprocessable_entity
+ 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/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index f2f3336..0a7b6c9 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -3,19 +3,54 @@ class SessionsController < ApplicationController
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
def new
+ redirect_to root_path if authenticated?
end
def create
api_key = params[:api_key].to_s.strip
begin
+ key_info = TornApi::Key::Info.new(api_key).fetch
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"
+
+ if profile.nil?
+ return redirect_to new_session_path, alert: "Could not fetch profile from Torn API."
+ end
+
+ user = User.find_by(torn_id: profile.id) || User.new
+
+ user.assign_attributes(
+ torn_id: profile.id,
+ name: profile.name,
+ level: profile.level,
+ profile_image: profile.image
+ )
+
+ 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)
+
+ unless faction
+ begin
+ faction_name = TornApi::Faction::Basic.new(api_key, torn_faction_id).name
+ faction = Faction.create!(torn_id: torn_faction_id, name: faction_name, setup_completed: false)
+ sync_faction_members(faction)
+ rescue StandardError => e
+ Rails.logger.warn("Failed to create faction #{torn_faction_id} on login: #{e.message}")
+ faction = nil
+ end
+ end
+
+ user.faction_id = faction.id if faction
end
+
+ user.save!
+ user.set_api_key!(api_key, key_info.access.type)
+
+ start_new_session_for user
+ notify_sign_in(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 +64,36 @@ def create
def destroy
terminate_session
- redirect_to new_session_path, status: :see_other
+ redirect_to root_path, status: :see_other
+ end
+
+ private
+
+ 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
+
+ members.each do |member|
+ user = User.find_or_initialize_by(torn_id: member.id)
+ user.assign_attributes(
+ name: member.name,
+ level: member.level,
+ position: member.position,
+ faction_id: faction.id,
+ fallen: member.status_state == "Fallen"
+ )
+ user.save!
+ end
end
end
diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb
new file mode 100644
index 0000000..304d775
--- /dev/null
+++ b/app/controllers/settings_controller.rb
@@ -0,0 +1,175 @@
+class SettingsController < ApplicationController
+ REFRESH_COOLDOWN = 1.minute
+
+ def index
+ @sessions_count = Current.user.sessions.count
+ @api_calls_count = Current.user.api_calls.count
+ @payments_count = Current.user.sent_xanax_payments.count
+ @faction_grants_count = Current.user.subscription_grants.count
+
+ @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
+
+ load_api_key_info
+ end
+
+ def api_key_card
+ load_api_key_info
+ render partial: "api_key_card"
+ end
+
+ def update_api_key
+ new_api_key = params[:api_key]&.strip
+
+ if new_api_key.blank?
+ render json: { success: false, message: "API key cannot be blank." }
+ return
+ end
+
+ if new_api_key == Current.user.api_key
+ render json: { success: false, message: "This is already your current API key." }
+ return
+ end
+
+ begin
+ key_info = TornApi::Key::Info.new(new_api_key).fetch
+
+ if key_info.access.type == "Full Access"
+ render json: { success: false, message: "Full Access keys are not allowed. Please use a Limited Access key instead." }
+ return
+ end
+
+ 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." }
+ return
+ end
+
+ Current.user.set_api_key!(new_api_key, key_info.access.type)
+
+ 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}"
+ render json: { success: false, message: "Invalid API key." }
+ rescue TornApi::ApiError => e
+ Rails.logger.error "API error updating key for user #{Current.user.torn_id}: #{e.message}"
+ 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}"
+ render json: { success: false, message: "Failed to update API key. Please try again later." }
+ end
+ end
+
+ def purge_data
+ Rails.logger.info "User #{Current.user.torn_id} (#{Current.user.name}) initiated data purge"
+
+ Current.user.sessions.destroy_all
+ Current.user.api_calls.destroy_all
+ Current.user.set_api_key!(nil, nil)
+
+ Rails.logger.info "Data purge completed for user #{Current.user.torn_id}"
+
+ terminate_session
+ redirect_to root_path, notice: "All your collected data has been deleted and you've been signed out. Your subscription status has been preserved. You can log back in anytime using your Torn API key."
+ end
+
+ def export_data
+ user = Current.user
+
+ export = {
+ exported_at: Time.current.iso8601,
+ user: {
+ torn_id: user.torn_id,
+ name: user.name,
+ level: user.level,
+ profile_image: user.profile_image,
+ api_access_type: user.api_access_type,
+ subscription_expires_at: user.effective_subscription_expires_at&.iso8601,
+ created_at: user.created_at.iso8601,
+ updated_at: user.updated_at.iso8601
+ },
+ sessions: user.sessions.map do |session|
+ {
+ ip_address: session.ip_address,
+ user_agent: session.user_agent,
+ created_at: session.created_at.iso8601
+ }
+ end,
+ api_calls: user.api_calls.map do |call|
+ {
+ endpoint: call.endpoint,
+ selections: call.selections,
+ status: call.status,
+ response_time_ms: call.response_time,
+ error_message: call.error_message,
+ created_at: call.created_at.iso8601
+ }
+ end
+ }
+
+ send_data export.to_json,
+ filename: "tornmanager-data-#{user.torn_id}-#{Date.current}.json",
+ type: "application/json",
+ disposition: "attachment"
+ end
+
+ def refresh_subscription
+ unless can_refresh?
+ redirect_to settings_path, alert: "Please wait #{seconds_until_refresh} seconds before refreshing again."
+ return
+ end
+
+ session[:last_subscription_refresh_at] = Time.current.to_i
+ Daily::XanaxPaymentsJob.perform_now
+
+ 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}"
+ redirect_to settings_path, alert: "Failed to refresh subscription status. Please try again later."
+ end
+
+ private
+
+ def calculate_days_remaining
+ expires_at = Current.user.effective_subscription_expires_at
+ return 0 unless expires_at
+ (expires_at.to_date - Date.current).to_i
+ end
+
+ def can_refresh?
+ return true unless session[:last_subscription_refresh_at]
+ Time.current - Time.at(session[:last_subscription_refresh_at]) >= REFRESH_COOLDOWN
+ end
+
+ def seconds_until_refresh
+ return 0 unless session[:last_subscription_refresh_at]
+ remaining = REFRESH_COOLDOWN - (Time.current - Time.at(session[:last_subscription_refresh_at]))
+ [ remaining.to_i, 0 ].max
+ end
+
+ 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/stocks_controller.rb b/app/controllers/stocks_controller.rb
new file mode 100644
index 0000000..a814863
--- /dev/null
+++ b/app/controllers/stocks_controller.rb
@@ -0,0 +1,24 @@
+class StocksController < ApplicationController
+ before_action :ensure_user_authenticated
+
+ def index
+ @has_limited_access = Current.user.has_limited_access?
+
+ owned_stocks = @has_limited_access ? TornApi::User::Stocks.new(Current.user.api_key).fetch : []
+ @table_rows = Torn::Stock.money_rows(owned_stocks).sort_by do |row|
+ row[:days_to_break_even].infinite? ? Float::INFINITY : row[:days_to_break_even]
+ end
+ rescue TornApi::InvalidKeyError
+ redirect_to new_session_path, alert: "Invalid or expired API key. Please sign in again."
+ rescue TornApi::ApiError => e
+ redirect_to root_path, alert: "Could not fetch stock data: #{e.message}"
+ end
+
+ private
+
+ def ensure_user_authenticated
+ unless Current.user&.api_key.present?
+ redirect_to new_session_path, alert: "Please sign in to view stocks."
+ end
+ end
+end
diff --git a/app/controllers/user_api_calls_controller.rb b/app/controllers/user_api_calls_controller.rb
new file mode 100644
index 0000000..3b3d900
--- /dev/null
+++ b/app/controllers/user_api_calls_controller.rb
@@ -0,0 +1,7 @@
+class UserApiCallsController < ApplicationController
+ def index
+ @api_calls = Current.user.api_calls.recent.limit(100)
+ @peak_today = ApiCall.peak_rate_for(Current.user, scope: :today)
+ @peak_all_time = ApiCall.peak_rate_for(Current.user, scope: :all)
+ end
+end
diff --git a/app/controllers/userscript_controller.rb b/app/controllers/userscript_controller.rb
new file mode 100644
index 0000000..4ba21ee
--- /dev/null
+++ b/app/controllers/userscript_controller.rb
@@ -0,0 +1,21 @@
+class UserscriptController < ApplicationController
+ skip_forgery_protection only: :download
+
+ def index
+ @latest = ScriptVersion.latest
+ @versions = ScriptVersion.ordered
+ end
+
+ def download
+ latest = ScriptVersion.latest
+
+ if latest&.script_content.present?
+ send_data latest.script_content,
+ filename: "tornmanager.user.js",
+ type: "text/javascript",
+ disposition: "inline"
+ else
+ redirect_to userscript_path, alert: "No script available for download."
+ end
+ end
+end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index de6be79..254a8a9 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1,2 +1,16 @@
module ApplicationHelper
+ 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/helpers/faction_helper.rb b/app/helpers/faction_helper.rb
new file mode 100644
index 0000000..37a560b
--- /dev/null
+++ b/app/helpers/faction_helper.rb
@@ -0,0 +1,177 @@
+module FactionHelper
+ def stat_compliance(actual_daily, target)
+ return :green if target.nil? || target.zero?
+
+ ratio = actual_daily.to_f / target.to_f
+
+ if ratio >= 1.0
+ :green
+ elsif ratio >= 0.6
+ :yellow
+ else
+ :red
+ end
+ end
+
+ def member_compliance_level(xanax_status, energy_status, nerve_status)
+ statuses = [ xanax_status, energy_status, nerve_status ]
+
+ if statuses.all? { |s| s == :green }
+ :compliant
+ elsif statuses.any? { |s| s == :red }
+ :danger
+ else
+ :warning
+ end
+ end
+
+ def compliance_score(xanax_daily, energy_daily, nerve_daily, faction)
+ energy_disabled = faction.energy_refill_target.zero?
+ nerve_disabled = faction.nerve_refill_target.zero?
+
+ bonus = (energy_disabled ? 30 : 0) + (nerve_disabled ? 30 : 0)
+ xanax_weight = 40 + bonus
+ energy_weight = energy_disabled ? 0 : 30
+ nerve_weight = nerve_disabled ? 0 : 30
+
+ xanax_score = [ (xanax_daily.to_f / faction.xanax_target.to_f) * xanax_weight, xanax_weight ].min
+ energy_score = energy_disabled ? 0 : [ (energy_daily.to_f / faction.energy_refill_target.to_f) * energy_weight, energy_weight ].min
+ nerve_score = nerve_disabled ? 0 : [ (nerve_daily.to_f / faction.nerve_refill_target.to_f) * nerve_weight, nerve_weight ].min
+
+ (xanax_score + energy_score + nerve_score).round
+ end
+
+ def compliance_score_ssl(energy_daily, nerve_daily, faction)
+ energy_disabled = faction.energy_refill_target.zero?
+ nerve_disabled = faction.nerve_refill_target.zero?
+
+ if energy_disabled && nerve_disabled
+ 100
+ elsif energy_disabled
+ nerve_score = [ (nerve_daily.to_f / faction.nerve_refill_target.to_f) * 100, 100 ].min
+ nerve_score.round
+ elsif nerve_disabled
+ energy_score = [ (energy_daily.to_f / faction.energy_refill_target.to_f) * 100, 100 ].min
+ energy_score.round
+ else
+ energy_score = [ (energy_daily.to_f / faction.energy_refill_target.to_f) * 50, 50 ].min
+ nerve_score = [ (nerve_daily.to_f / faction.nerve_refill_target.to_f) * 50, 50 ].min
+ (energy_score + nerve_score).round
+ end
+ end
+
+ def compliance_icon(level)
+ case level
+ when :compliant
+ "✓"
+ when :warning
+ "⚠"
+ when :danger
+ "✗"
+ else
+ "?"
+ end
+ end
+
+ def compliance_class(actual_daily, target)
+ status = stat_compliance(actual_daily, target)
+ "compliance-#{status}"
+ end
+
+ def row_compliance_class(level)
+ "row-#{level}"
+ end
+
+ def member_stats_clipboard_text(row, days, faction, start_date: nil, end_date: nil)
+ lines = []
+
+ 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)"
+ if faction.energy_refill_target > 0
+ lines << "- Used #{number_with_delimiter(row[:energy_refills_gained])} energy refills (#{row[:energy_refills_daily]}/day, target: #{faction.energy_refill_target}/day)"
+ else
+ lines << "- Used #{number_with_delimiter(row[:energy_refills_gained])} energy refills (#{row[:energy_refills_daily]}/day)"
+ end
+ if faction.nerve_refill_target > 0
+ lines << "- Used #{number_with_delimiter(row[:nerve_refills_gained])} nerve refills (#{row[:nerve_refills_daily]}/day, target: #{faction.nerve_refill_target}/day)"
+ else
+ lines << "- Used #{number_with_delimiter(row[:nerve_refills_gained])} nerve refills (#{row[:nerve_refills_daily]}/day)"
+ end
+ lines << "- Completed #{number_with_delimiter(row[:missions_gained])} contracts (#{row[:missions_daily]}/day)"
+ lines << "- Committed #{number_with_delimiter(row[:crimes_gained])} crimes (#{row[:crimes_daily]}/day)"
+ lines << "- Been active for #{number_with_delimiter(row[:activity_time_daily])} min/day"
+ lines.join("\n")
+ end
+
+ def top_performers_clipboard_text(performers, faction, start_date:, end_date:)
+ lines = []
+
+ formatted_start = start_date.strftime("%d %b %Y")
+ formatted_end = end_date.strftime("%d %b %Y")
+
+ lines << "Top Performers for #{faction.name}"
+ lines << "#{formatted_start} - #{formatted_end}"
+ lines << ""
+
+ performers.each_with_index do |performer, index|
+ networth_current = format_networth(performer[:networth_current])
+ networth_gained = performer[:networth_gained] || 0
+ networth_gained_formatted = format_networth_with_sign(networth_gained)
+ activity_mins = performer[:activity_time_gained].to_i / 60
+
+ lines << "#{index + 1}. #{performer[:name]}"
+ lines << " Xanax: #{performer[:xanax_daily]}/day (#{performer[:xanax_gained]} total)"
+ lines << " Energy: #{performer[:energy_refills_daily]}/day (#{performer[:energy_refills_gained]} total)"
+ lines << " Activity: #{activity_mins} mins"
+ lines << " Networth: #{networth_current} (#{networth_gained_formatted})"
+ lines << ""
+ end
+
+ lines.join("\n")
+ end
+
+ def format_networth(amount)
+ return "$0" if amount.nil? || amount == 0
+
+ abs_amount = amount.abs
+ if abs_amount >= 1_000_000_000
+ "$#{(abs_amount / 1_000_000_000.0).round(2)}B"
+ elsif abs_amount >= 1_000_000
+ "$#{(abs_amount / 1_000_000.0).round(2)}M"
+ elsif abs_amount >= 1_000
+ "$#{(abs_amount / 1_000.0).round(1)}K"
+ else
+ "$#{number_with_delimiter(abs_amount)}"
+ end
+ end
+
+ def format_networth_with_sign(amount)
+ return "$0" if amount.nil? || amount == 0
+
+ sign = amount >= 0 ? "+" : "-"
+ "#{sign}#{format_networth(amount)}"
+ end
+
+ def activity_cell_rgb(count, max_val)
+ intensity = max_val > 0 ? (count.to_f / max_val) : 0
+ if intensity <= 0.5
+ t = intensity * 2
+ r = (239 + (234 - 239) * t).round
+ g = (68 + (179 - 68) * t).round
+ b = (68 + (8 - 68) * t).round
+ else
+ t = (intensity - 0.5) * 2
+ r = (234 + (34 - 234) * t).round
+ g = (179 + (197 - 179) * t).round
+ b = (8 + (94 - 8) * t).round
+ end
+ "#{r}, #{g}, #{b}"
+ end
+end
diff --git a/app/helpers/hall_of_famers_helper.rb b/app/helpers/hall_of_famers_helper.rb
new file mode 100644
index 0000000..7cc5583
--- /dev/null
+++ b/app/helpers/hall_of_famers_helper.rb
@@ -0,0 +1,14 @@
+module HallOfFamersHelper
+ def user_stats_clipboard_text(row, start_date:, end_date:, days:)
+ formatted_start = start_date.strftime("%d %b %Y")
+ formatted_end = end_date.strftime("%d %b %Y")
+
+ lines = []
+ lines << "From #{formatted_start} to #{formatted_end} (#{days} days) #{row[:name]} has:"
+ lines << "- Used #{number_with_delimiter(row[:xanax_gained])} xanax (#{row[:xanax_daily]}/day)"
+ lines << "- Used #{number_with_delimiter(row[:energy_drinks_gained])} energy drinks (#{row[:energy_drinks_daily]}/day)"
+ lines << "- Used #{number_with_delimiter(row[:se_gained])} stat enhancers (#{row[:se_daily]}/day, #{number_with_delimiter(row[:total_se])} total)"
+ lines << "- Networth change: #{number_to_currency(row[:networth_gained], precision: 0)} (#{number_to_currency(row[:networth_daily], precision: 0)}/day)"
+ lines.join("\n")
+ end
+end
diff --git a/app/helpers/user_api_calls_helper.rb b/app/helpers/user_api_calls_helper.rb
new file mode 100644
index 0000000..ebc3e7b
--- /dev/null
+++ b/app/helpers/user_api_calls_helper.rb
@@ -0,0 +1,2 @@
+module UserApiCallsHelper
+end
diff --git a/app/javascript/application.js b/app/javascript/application.js
index 0d7b494..a40135e 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -1,3 +1,4 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
+import "channels"
diff --git a/app/javascript/channels/consumer.js b/app/javascript/channels/consumer.js
new file mode 100644
index 0000000..8ec3aad
--- /dev/null
+++ b/app/javascript/channels/consumer.js
@@ -0,0 +1,6 @@
+// Action Cable provides the framework to deal with WebSockets in Rails.
+// You can generate new channels where WebSocket features live using the `bin/rails generate channel` command.
+
+import { createConsumer } from "@rails/actioncable"
+
+export default createConsumer()
diff --git a/app/javascript/channels/index.js b/app/javascript/channels/index.js
new file mode 100644
index 0000000..0df9cc5
--- /dev/null
+++ b/app/javascript/channels/index.js
@@ -0,0 +1,2 @@
+// Import all the channels to be used by Action Cable
+
diff --git a/app/javascript/controllers/api_key_controller.js b/app/javascript/controllers/api_key_controller.js
new file mode 100644
index 0000000..b7569b8
--- /dev/null
+++ b/app/javascript/controllers/api_key_controller.js
@@ -0,0 +1,65 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["input", "submit"]
+
+ validateInput() {
+ const value = this.inputTarget.value.trim()
+ const isValid = value.length === 16 && /^[a-zA-Z0-9]+$/.test(value)
+ this.submitTarget.disabled = !isValid
+ }
+
+ async submit(event) {
+ event.preventDefault()
+
+ const form = event.target
+ const formData = new FormData(form)
+
+ this.submitTarget.disabled = true
+ this.submitTarget.textContent = "Validating..."
+
+ try {
+ const response = await fetch(form.action, {
+ method: "PATCH",
+ body: formData,
+ headers: {
+ "Accept": "application/json",
+ "X-CSRF-Token": document.querySelector("[name='csrf-token']").content
+ }
+ })
+
+ const data = await response.json()
+
+ if (data.success) {
+ this.showFlash("notice", data.message)
+ this.inputTarget.value = ""
+ this.submitTarget.textContent = "Update"
+ this.submitTarget.disabled = true
+ this.refreshApiKeyCard()
+ } else {
+ this.showFlash("alert", data.message)
+ this.submitTarget.textContent = "Update"
+ this.submitTarget.disabled = true
+ }
+ } catch (error) {
+ console.error("error:", error)
+ this.showFlash("alert", "An error occurred. Please try again.")
+ this.submitTarget.textContent = "Update"
+ this.submitTarget.disabled = true
+ }
+ }
+
+ showFlash(type, message) {
+ window.dispatchEvent(new CustomEvent("flash:show", {
+ detail: { type, message }
+ }))
+ }
+
+ refreshApiKeyCard() {
+ const frame = document.getElementById("api_key_card")
+ if (frame) {
+ frame.src = "/settings/api_key_card"
+ frame.reload()
+ }
+ }
+}
diff --git a/app/javascript/controllers/api_live_feed_controller.js b/app/javascript/controllers/api_live_feed_controller.js
new file mode 100644
index 0000000..ecc39e8
--- /dev/null
+++ b/app/javascript/controllers/api_live_feed_controller.js
@@ -0,0 +1,74 @@
+import { Controller } from "@hotwired/stimulus"
+import consumer from "channels/consumer"
+
+export default class extends Controller {
+ static targets = ["list"]
+
+ connect() {
+ this.subscription = consumer.subscriptions.create("ApiRateMonitorChannel", {
+ connected: this.connected.bind(this),
+ disconnected: this.disconnected.bind(this),
+ received: this.received.bind(this)
+ })
+ }
+
+ disconnect() {
+ if (this.subscription) {
+ this.subscription.unsubscribe()
+ }
+ }
+
+ connected() {
+ }
+
+ disconnected() {
+ }
+
+ received(data) {
+ this.addApiCallToFeed(data)
+ }
+
+ addApiCallToFeed(call) {
+ if (!this.hasListTarget) {
+ return
+ }
+
+ const item = document.createElement("div")
+ item.className = "live-api-item"
+ item.dataset.id = call.id
+
+ const statusClass = call.status === "success" ? "success" : "error"
+ const statusIcon = call.status === "success" ? "✓" : "✗"
+
+ item.innerHTML = `
+ ${this.formatEndpoint(call.endpoint)}
+ ${statusIcon}
+ ${call.response_time}ms
+ `
+
+ this.listTarget.prepend(item)
+
+ const maxVisible = 3
+ const items = this.listTarget.querySelectorAll(".live-api-item")
+ if (items.length > maxVisible) {
+ Array.from(items).slice(maxVisible).forEach(el => el.remove())
+ }
+
+ requestAnimationFrame(() => {
+ item.classList.add("live-api-item-visible")
+ })
+
+ setTimeout(() => {
+ item.classList.remove("live-api-item-visible")
+ item.classList.add("live-api-item-fade-out")
+
+ setTimeout(() => {
+ item.remove()
+ }, 300)
+ }, 3000)
+ }
+
+ formatEndpoint(endpoint) {
+ return endpoint.replace(/^v\d+\//, "")
+ }
+}
diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js
index 1213e85..a98409d 100644
--- a/app/javascript/controllers/application.js
+++ b/app/javascript/controllers/application.js
@@ -2,7 +2,6 @@ import { Application } from "@hotwired/stimulus"
const application = Application.start()
-// Configure Stimulus development experience
application.debug = false
window.Stimulus = application
diff --git a/app/javascript/controllers/backfill_countdown_controller.js b/app/javascript/controllers/backfill_countdown_controller.js
new file mode 100644
index 0000000..8f0f9e2
--- /dev/null
+++ b/app/javascript/controllers/backfill_countdown_controller.js
@@ -0,0 +1,62 @@
+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()
+ } else {
+ this.reloadPage()
+ }
+ }
+
+ 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/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/javascript/controllers/clipboard_controller.js b/app/javascript/controllers/clipboard_controller.js
new file mode 100644
index 0000000..28e55ea
--- /dev/null
+++ b/app/javascript/controllers/clipboard_controller.js
@@ -0,0 +1,19 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = { text: String }
+
+ copy() {
+ navigator.clipboard.writeText(this.textValue).then(() => {
+ this.showFlash("notice", "Copied to clipboard!")
+ }).catch(() => {
+ this.showFlash("alert", "Failed to copy to clipboard")
+ })
+ }
+
+ showFlash(type, message) {
+ window.dispatchEvent(new CustomEvent("flash:show", {
+ detail: { type, message }
+ }))
+ }
+}
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/countdown_controller.js b/app/javascript/controllers/countdown_controller.js
new file mode 100644
index 0000000..a1be6ae
--- /dev/null
+++ b/app/javascript/controllers/countdown_controller.js
@@ -0,0 +1,28 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = { target: String }
+ static targets = ["display"]
+
+ connect() {
+ this.update()
+ this.intervalId = setInterval(() => this.update(), 1000)
+ }
+
+ disconnect() {
+ if (this.intervalId) clearInterval(this.intervalId)
+ }
+
+ update() {
+ const now = new Date()
+ const target = new Date(this.targetValue)
+ const diff = Math.max(0, Math.floor((target - now) / 1000))
+
+ const hours = Math.floor(diff / 3600)
+ const minutes = Math.floor((diff % 3600) / 60)
+ const seconds = diff % 60
+
+ const pad = (n) => String(n).padStart(2, "0")
+ this.displayTarget.textContent = `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
+ }
+}
diff --git a/app/javascript/controllers/coverage_row_controller.js b/app/javascript/controllers/coverage_row_controller.js
new file mode 100644
index 0000000..195d239
--- /dev/null
+++ b/app/javascript/controllers/coverage_row_controller.js
@@ -0,0 +1,46 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["details", "action", "result", "button"]
+
+ toggle() {
+ const row = this.detailsTarget
+ row.style.display = row.style.display === "none" ? "table-row" : "none"
+ }
+
+ stopPropagation(event) {
+ event.stopPropagation()
+ }
+
+ async backfill(event) {
+ event.preventDefault()
+ event.stopPropagation()
+
+ const form = this.buttonTarget.closest("form")
+ const url = form.action
+ const token = form.querySelector("[name='authenticity_token']").value
+
+ this.buttonTarget.disabled = true
+ this.buttonTarget.textContent = "Scheduling..."
+
+ try {
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": token,
+ "Accept": "application/json"
+ }
+ })
+
+ const data = await response.json()
+
+ this.actionTarget.style.display = "none"
+ this.resultTarget.style.display = "block"
+ this.resultTarget.textContent = data.message
+ } catch {
+ this.buttonTarget.disabled = false
+ this.buttonTarget.textContent = "Backfill Now"
+ }
+ }
+}
diff --git a/app/javascript/controllers/demo_compliance_controller.js b/app/javascript/controllers/demo_compliance_controller.js
new file mode 100644
index 0000000..134e5c8
--- /dev/null
+++ b/app/javascript/controllers/demo_compliance_controller.js
@@ -0,0 +1,163 @@
+import { Controller } from "@hotwired/stimulus"
+
+const TARGETS = { xanax: 3, energy: 1, nerve: 1 }
+
+const MEMBERS = [
+ { name: "BlazeFist", level: 95, ssl: false, xanax: 4.21, energy: 1.43, nerve: 1.14, contracts: 2.07, crimes: 14.29 },
+ { name: "IronWolf", level: 92, ssl: false, xanax: 3.14, energy: 1.07, nerve: 0.93, contracts: 1.50, crimes: 11.43 },
+ { name: "ShadowStrike", level: 98, ssl: true, xanax: 0, energy: 1.29, nerve: 1.36, contracts: 3.21, crimes: 18.57 },
+ { name: "ViperQueen", level: 90, ssl: false, xanax: 3.00, energy: 1.00, nerve: 1.00, contracts: 1.86, crimes: 9.71 },
+ { name: "PhantomX", level: 88, ssl: false, xanax: 2.86, energy: 0.71, nerve: 1.07, contracts: 0.93, crimes: 7.14 },
+ { name: "NightViper", level: 85, ssl: false, xanax: 1.43, energy: 0.36, nerve: 0.21, contracts: 0.71, crimes: 4.29 },
+ { name: "StormRider", level: 82, ssl: false, xanax: 3.57, energy: 1.21, nerve: 0.86, contracts: 1.14, crimes: 8.57 },
+ { name: "DeathBlade", level: 78, ssl: false, xanax: 2.14, energy: 0.57, nerve: 0.43, contracts: 0.50, crimes: 3.43 },
+ { name: "CrimsonFury", level: 75, ssl: false, xanax: 0.71, energy: 0.14, nerve: 0.07, contracts: 0.29, crimes: 1.86 },
+ { name: "FrostBite", level: 70, ssl: false, xanax: 3.43, energy: 0.93, nerve: 1.21, contracts: 1.64, crimes: 12.14 }
+]
+
+function statColor(daily, target) {
+ if (!target || target === 0) return "green"
+ const ratio = daily / target
+ if (ratio >= 1.0) return "green"
+ if (ratio >= 0.6) return "yellow"
+ return "red"
+}
+
+function complianceLevel(m) {
+ const x = m.ssl ? "green" : statColor(m.xanax, TARGETS.xanax)
+ const e = statColor(m.energy, TARGETS.energy)
+ const n = statColor(m.nerve, TARGETS.nerve)
+ if (x === "green" && e === "green" && n === "green") return "compliant"
+ if (x === "red" || e === "red" || n === "red") return "danger"
+ return "warning"
+}
+
+function complianceScore(m) {
+ let xWeight = 40, eWeight = 30, nWeight = 30
+ if (m.ssl) {
+ xWeight = 0; eWeight = 50; nWeight = 50
+ }
+ const xScore = m.ssl ? 0 : Math.min((m.xanax / TARGETS.xanax) * xWeight, xWeight)
+ const eScore = TARGETS.energy > 0 ? Math.min((m.energy / TARGETS.energy) * eWeight, eWeight) : eWeight
+ const nScore = TARGETS.nerve > 0 ? Math.min((m.nerve / TARGETS.nerve) * nWeight, nWeight) : nWeight
+ return Math.round(xScore + eScore + nScore)
+}
+
+export default class extends Controller {
+ static targets = ["table", "compliantCount", "warningCount", "dangerCount"]
+
+ connect() {
+ this.members = MEMBERS.map(m => ({
+ ...m,
+ level: m.level,
+ compliance: complianceLevel(m),
+ score: complianceScore(m)
+ }))
+ this.sortKey = "score"
+ this.sortDir = "desc"
+ this.render()
+ }
+
+ sort(event) {
+ const key = event.currentTarget.dataset.sortKey
+ if (this.sortKey === key) {
+ this.sortDir = this.sortDir === "asc" ? "desc" : "asc"
+ } else {
+ this.sortKey = key
+ this.sortDir = "asc"
+ }
+ this.render()
+ }
+
+ getSorted() {
+ const dir = this.sortDir === "asc" ? 1 : -1
+ return [...this.members].sort((a, b) => {
+ let cmp = 0
+ switch (this.sortKey) {
+ case "name": cmp = a.name.localeCompare(b.name); break
+ case "xanax": cmp = a.xanax - b.xanax; break
+ case "energy": cmp = a.energy - b.energy; break
+ case "nerve": cmp = a.nerve - b.nerve; break
+ case "contracts": cmp = a.contracts - b.contracts; break
+ case "crimes": cmp = a.crimes - b.crimes; break
+ case "score": cmp = a.score - b.score; break
+ }
+ return cmp * dir
+ })
+ }
+
+ render() {
+ const sorted = this.getSorted()
+ const counts = { compliant: 0, warning: 0, danger: 0 }
+ this.members.forEach(m => counts[m.compliance]++)
+
+ this.compliantCountTarget.textContent = counts.compliant
+ this.warningCountTarget.textContent = counts.warning
+ this.dangerCountTarget.textContent = counts.danger
+
+ const indicator = (key) => {
+ if (this.sortKey !== key) return ""
+ return `${this.sortDir === "asc" ? "▲" : "▼"} `
+ }
+
+ const th = (key, label, sub) => {
+ const subHtml = sub ? `${sub} ` : ""
+ return `${label} ${indicator(key)}${subHtml} `
+ }
+
+ let html = `
+
+
+ ${th("name", "Member", "")}
+ ${th("xanax", "Xanax", `${TARGETS.xanax}/day`)}
+ ${th("energy", "E. Refills", `${TARGETS.energy}/day`)}
+ ${th("nerve", "N. Refills", `${TARGETS.nerve}/day`)}
+ Contracts ${indicator("contracts")}
+ Crimes ${indicator("crimes")}
+ `
+
+ sorted.forEach(m => {
+ const badge = this.renderBadge(m.compliance)
+ const xColor = m.ssl ? "" : statColor(m.xanax, TARGETS.xanax)
+ const eColor = statColor(m.energy, TARGETS.energy)
+ const nColor = statColor(m.nerve, TARGETS.nerve)
+
+ html += `
+ ${badge}
+
+ ${m.name}
+ ${m.ssl ? 'SSL ' : ""}
+
+ ${m.ssl ? this.renderExempt() : this.renderStat(m.xanax, xColor)}
+ ${this.renderStat(m.energy, eColor)}
+ ${this.renderStat(m.nerve, nColor)}
+ ${this.renderPlainStat(m.contracts)}
+ ${this.renderPlainStat(m.crimes)}
+ `
+ })
+
+ html += "
"
+ this.tableTarget.innerHTML = html
+ }
+
+ renderBadge(level) {
+ const icons = { compliant: "✓", warning: "⚠", danger: "✗" }
+ return `${icons[level]} `
+ }
+
+ renderStat(daily, color) {
+ return `
+ ${daily.toFixed(2)}
+
`
+ }
+
+ renderPlainStat(daily) {
+ return `
+ ${daily.toFixed(2)}
+
`
+ }
+
+ renderExempt() {
+ return `Exempt
`
+ }
+}
diff --git a/app/javascript/controllers/demo_war_dashboard_controller.js b/app/javascript/controllers/demo_war_dashboard_controller.js
new file mode 100644
index 0000000..da8280b
--- /dev/null
+++ b/app/javascript/controllers/demo_war_dashboard_controller.js
@@ -0,0 +1,204 @@
+import { Controller } from "@hotwired/stimulus"
+
+const MEMBERS = [
+ { name: "BlazeFist", level: 95, status: "Okay", activity: "Online", timer: null, travel: null, total: 18600000000 },
+ { name: "IronWolf", level: 92, status: "Okay", activity: "Idle", timer: null, travel: null, total: 15100000000 },
+ { name: "ShadowStrike", level: 98, status: "Okay", activity: "Online", timer: null, travel: null, total: 12500000000 },
+ { name: "ViperQueen", level: 90, status: "Traveling", activity: "Idle", timer: null, travel: { direction: "returning", seconds: 130 }, total: 11400000000 },
+ { name: "PhantomX", level: 88, status: "Hospital", activity: "Offline", timer: { seconds: 225 }, travel: null, total: 9800000000 },
+ { name: "NightViper", level: 85, status: "Okay", activity: "Online", timer: null, travel: null, total: 8200000000 },
+ { name: "StormRider", level: 82, status: "Traveling", activity: "Online", timer: null, travel: { direction: "outbound", seconds: 500 }, total: 7300000000 },
+ { name: "DeathBlade", level: 78, status: "Okay", activity: "Idle", timer: null, travel: null, total: 5700000000 },
+ { name: "CrimsonFury", level: 75, status: "Hospital", activity: "Offline", timer: { seconds: 45 }, travel: null, total: 4200000000 },
+ { name: "FrostBite", level: 70, status: "Okay", activity: "Offline", timer: null, travel: null, total: 3100000000 }
+]
+
+const STATUS_ORDER = { Okay: 0, Traveling: 1, Hospital: 2 }
+const ACTIVITY_ORDER = { Online: 0, Idle: 1, Offline: 2 }
+
+export default class extends Controller {
+ static targets = ["table", "filterCount"]
+
+ connect() {
+ this.members = MEMBERS.map(m => ({
+ ...m,
+ timer: m.timer ? { ...m.timer } : null,
+ travel: m.travel ? { ...m.travel } : null
+ }))
+ this.sortKey = "total"
+ this.sortDir = "desc"
+ this.statusFilters = { Okay: true, Hospital: true, Traveling: true }
+ this.activityFilters = { Online: true, Idle: true, Offline: true }
+
+ this.render()
+ this.timerInterval = setInterval(() => this.tickTimers(), 1000)
+ }
+
+ disconnect() {
+ if (this.timerInterval) clearInterval(this.timerInterval)
+ }
+
+ tickTimers() {
+ let changed = false
+ this.members.forEach(m => {
+ if (m.timer && m.timer.seconds > 0) {
+ m.timer.seconds--
+ changed = true
+ if (m.timer.seconds <= 0) {
+ m.status = "Okay"
+ m.timer = null
+ }
+ }
+ if (m.travel && m.travel.seconds > 0) {
+ m.travel.seconds--
+ changed = true
+ if (m.travel.seconds <= 0) {
+ m.status = "Okay"
+ m.travel = null
+ }
+ }
+ })
+ if (changed) this.updateTimerCells()
+ }
+
+ updateTimerCells() {
+ const rows = this.tableTarget.querySelectorAll("tr[data-member]")
+ rows.forEach(row => {
+ const name = row.dataset.member
+ const member = this.members.find(m => m.name === name)
+ if (!member) return
+
+ const timerCell = row.querySelector(".demo-war-timer")
+ if (timerCell) timerCell.innerHTML = this.renderTimer(member)
+
+ const statusCell = row.querySelector(".demo-war-status-cell")
+ if (statusCell) statusCell.innerHTML = this.renderStatus(member.status)
+ })
+ }
+
+ sort(event) {
+ const key = event.currentTarget.dataset.sortKey
+ if (this.sortKey === key) {
+ this.sortDir = this.sortDir === "asc" ? "desc" : "asc"
+ } else {
+ this.sortKey = key
+ this.sortDir = "asc"
+ }
+ this.render()
+ }
+
+ toggleFilter(event) {
+ const type = event.currentTarget.dataset.filterType
+ const value = event.currentTarget.dataset.filterValue
+ const filters = type === "status" ? this.statusFilters : this.activityFilters
+ filters[value] = !filters[value]
+ event.currentTarget.classList.toggle("demo-filter-disabled")
+ this.render()
+ }
+
+ getFiltered() {
+ return this.members.filter(m =>
+ this.statusFilters[m.status] && this.activityFilters[m.activity]
+ )
+ }
+
+ getSorted(members) {
+ const dir = this.sortDir === "asc" ? 1 : -1
+ return [...members].sort((a, b) => {
+ let cmp = 0
+ switch (this.sortKey) {
+ case "name": cmp = a.name.localeCompare(b.name); break
+ case "level": cmp = a.level - b.level; break
+ case "status": cmp = (STATUS_ORDER[a.status] ?? 99) - (STATUS_ORDER[b.status] ?? 99); break
+ case "activity": cmp = (ACTIVITY_ORDER[a.activity] ?? 99) - (ACTIVITY_ORDER[b.activity] ?? 99); break
+ case "timer": cmp = this.timerSeconds(a) - this.timerSeconds(b); break
+ case "total": cmp = a.total - b.total; break
+ }
+ return cmp * dir
+ })
+ }
+
+ timerSeconds(m) {
+ if (m.timer) return m.timer.seconds
+ if (m.travel) return m.travel.seconds
+ return -1
+ }
+
+ render() {
+ const filtered = this.getFiltered()
+ const sorted = this.getSorted(filtered)
+ const hidden = this.members.length - filtered.length
+
+ this.filterCountTarget.textContent = hidden > 0
+ ? `(${filtered.length}/${this.members.length}) ${hidden} hidden`
+ : `(${filtered.length}/${this.members.length})`
+
+ const indicator = (key) => {
+ if (this.sortKey !== key) return ""
+ return `${this.sortDir === "asc" ? "▲" : "▼"} `
+ }
+
+ let html = `
+
+ Member ${indicator("name")}
+ Lvl ${indicator("level")}
+ Status ${indicator("status")}
+ Activity ${indicator("activity")}
+ Timer ${indicator("timer")}
+ Total Stats ${indicator("total")}
+ `
+
+ sorted.forEach(m => {
+ const rowClass = m.status === "Hospital" ? "demo-row-hospital" : ""
+ html += `
+ ${m.name}
+ ${m.level}
+ ${this.renderStatus(m.status)}
+ ${this.renderActivity(m.activity)}
+ ${this.renderTimer(m)}
+ ${this.formatStats(m.total)}
+ `
+ })
+
+ html += "
"
+ this.tableTarget.innerHTML = html
+ }
+
+ renderStatus(status) {
+ const cls = status.toLowerCase()
+ return `${status} `
+ }
+
+ renderActivity(activity) {
+ const cls = activity.toLowerCase()
+ return `${activity} `
+ }
+
+ renderTimer(m) {
+ if (m.timer && m.timer.seconds > 0) {
+ const cls = m.timer.seconds < 60 ? "demo-timer-expiring" : ""
+ return `${this.formatTime(m.timer.seconds)} `
+ }
+ if (m.travel && m.travel.seconds > 0) {
+ const arrow = m.travel.direction === "returning" ? "←" : "→"
+ return `${arrow} ${this.formatTime(m.travel.seconds)} `
+ }
+ if (m.travel && m.travel.seconds <= 0) {
+ return `Landing `
+ }
+ return `- `
+ }
+
+ formatTime(seconds) {
+ const m = Math.floor(seconds / 60)
+ const s = seconds % 60
+ return `${m}:${s.toString().padStart(2, "0")}`
+ }
+
+ formatStats(n) {
+ if (n >= 1000000000) return `${(n / 1000000000).toFixed(1)}B`
+ if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}K`
+ return n.toString()
+ }
+}
diff --git a/app/javascript/controllers/dropdown_controller.js b/app/javascript/controllers/dropdown_controller.js
new file mode 100644
index 0000000..b59fa93
--- /dev/null
+++ b/app/javascript/controllers/dropdown_controller.js
@@ -0,0 +1,35 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["menu", "arrow"]
+
+ connect() {
+ this.boundClose = this.close.bind(this)
+ }
+
+ toggle(event) {
+ event.stopPropagation()
+
+ if (this.menuTarget.classList.contains("navbar-dropdown-open")) {
+ this.close()
+ } else {
+ this.open()
+ }
+ }
+
+ open() {
+ this.menuTarget.classList.add("navbar-dropdown-open")
+ this.arrowTarget.classList.add("navbar-user-arrow-open")
+ document.addEventListener("click", this.boundClose)
+ }
+
+ close() {
+ this.menuTarget.classList.remove("navbar-dropdown-open")
+ this.arrowTarget.classList.remove("navbar-user-arrow-open")
+ document.removeEventListener("click", this.boundClose)
+ }
+
+ disconnect() {
+ document.removeEventListener("click", this.boundClose)
+ }
+}
diff --git a/app/javascript/controllers/expandable_controller.js b/app/javascript/controllers/expandable_controller.js
new file mode 100644
index 0000000..60b1f61
--- /dev/null
+++ b/app/javascript/controllers/expandable_controller.js
@@ -0,0 +1,10 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["truncated", "full"]
+
+ toggle() {
+ this.truncatedTarget.classList.toggle("hidden")
+ this.fullTarget.classList.toggle("hidden")
+ }
+}
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/faction_toggle_controller.js b/app/javascript/controllers/faction_toggle_controller.js
new file mode 100644
index 0000000..88fb91d
--- /dev/null
+++ b/app/javascript/controllers/faction_toggle_controller.js
@@ -0,0 +1,35 @@
+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) {
+ const memberCountCell = row.querySelector(".member-count")
+ if (memberCountCell) {
+ memberCountCell.textContent = data.member_count
+ }
+ } else {
+ checkbox.checked = !checkbox.checked
+ alert(`Error: ${data.error}`)
+ }
+ } catch (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..b71c897
--- /dev/null
+++ b/app/javascript/controllers/flash_item_controller.js
@@ -0,0 +1,34 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = { duration: { type: Number, default: 3000 } }
+
+ connect() {
+ requestAnimationFrame(() => {
+ this.element.classList.add("flash-notification-visible")
+ })
+
+ 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..1c2fd40
--- /dev/null
+++ b/app/javascript/controllers/flash_notifications_controller.js
@@ -0,0 +1,54 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ connect() {
+ 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 = `
+
+
+ ${iconSvg}
+ ${this.escapeHtml(message)}
+
+
+
+
+
+
+
+ `
+
+ this.element.insertAdjacentHTML("beforeend", html)
+ }
+
+ escapeHtml(text) {
+ const div = document.createElement("div")
+ div.textContent = text
+ return div.innerHTML
+ }
+}
diff --git a/app/javascript/controllers/hello_controller.js b/app/javascript/controllers/hello_controller.js
deleted file mode 100644
index 5975c07..0000000
--- a/app/javascript/controllers/hello_controller.js
+++ /dev/null
@@ -1,7 +0,0 @@
-import { Controller } from "@hotwired/stimulus"
-
-export default class extends Controller {
- connect() {
- this.element.textContent = "Hello World!"
- }
-}
diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js
index 1156bf8..6ffb4e9 100644
--- a/app/javascript/controllers/index.js
+++ b/app/javascript/controllers/index.js
@@ -1,4 +1,3 @@
-// Import and register all your controllers from the importmap via controllers/**/*_controller
import { application } from "controllers/application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
diff --git a/app/javascript/controllers/inline_edit_controller.js b/app/javascript/controllers/inline_edit_controller.js
new file mode 100644
index 0000000..c2b678e
--- /dev/null
+++ b/app/javascript/controllers/inline_edit_controller.js
@@ -0,0 +1,93 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["cell"]
+ static values = { url: String }
+
+ connect() {
+ this.cellTargets.forEach(cell => {
+ cell.addEventListener("dblclick", () => this.startEditing(cell))
+ })
+ }
+
+ startEditing(cell) {
+ if (cell.querySelector("input")) return
+
+ const currentValue = cell.textContent.trim().replace(/,/g, "")
+ const field = cell.dataset.field
+
+ const input = document.createElement("input")
+ input.type = "text"
+ input.value = currentValue
+ input.className = "inline-edit-input"
+ input.dataset.field = field
+ input.dataset.originalValue = currentValue
+
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") this.save(input, cell)
+ if (e.key === "Escape") this.cancel(input, cell)
+ })
+ input.addEventListener("blur", () => this.save(input, cell))
+
+ cell.textContent = ""
+ cell.appendChild(input)
+ input.focus()
+ input.select()
+ }
+
+ async save(input, cell) {
+ const newValue = input.value.replace(/,/g, "")
+ const originalValue = input.dataset.originalValue
+ const field = input.dataset.field
+
+ if (newValue === originalValue) {
+ this.cancel(input, cell)
+ return
+ }
+
+ const token = document.querySelector('meta[name="csrf-token"]').content
+
+ try {
+ const response = await fetch(this.urlValue, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": token,
+ "Accept": "application/json"
+ },
+ body: JSON.stringify({ spy_report: { [field]: newValue } })
+ })
+
+ if (response.ok) {
+ cell.textContent = Number(newValue).toLocaleString()
+ // Update total
+ this.updateTotal()
+ } else {
+ cell.textContent = Number(originalValue).toLocaleString()
+ }
+ } catch {
+ cell.textContent = Number(originalValue).toLocaleString()
+ }
+ }
+
+ cancel(input, cell) {
+ cell.textContent = Number(input.dataset.originalValue).toLocaleString()
+ }
+
+ updateTotal() {
+ const fields = ["strength", "defense", "speed", "dexterity"]
+ let total = 0
+
+ fields.forEach(field => {
+ const cell = this.cellTargets.find(c => c.dataset.field === field)
+ if (cell) {
+ total += Number(cell.textContent.replace(/,/g, ""))
+ }
+ })
+
+ const totalCell = this.element.querySelector(".spy-stats-total")
+ if (totalCell) {
+ totalCell.textContent = total.toLocaleString()
+ }
+ }
+}
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/javascript/controllers/legal_toc_controller.js b/app/javascript/controllers/legal_toc_controller.js
new file mode 100644
index 0000000..9e4bc2c
--- /dev/null
+++ b/app/javascript/controllers/legal_toc_controller.js
@@ -0,0 +1,46 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["link"]
+
+ connect() {
+ this.headings = this.linkTargets.map((link) => {
+ const id = link.getAttribute("href").slice(1)
+ return document.getElementById(id)
+ }).filter(Boolean)
+
+ this.onScroll = this.highlight.bind(this)
+ window.addEventListener("scroll", this.onScroll, { passive: true })
+
+ this.highlight()
+ }
+
+ disconnect() {
+ window.removeEventListener("scroll", this.onScroll)
+ }
+
+ highlight() {
+ const scrollY = window.scrollY
+ const offset = 100
+ let activeId = null
+
+ for (const heading of this.headings) {
+ if (heading.getBoundingClientRect().top + window.scrollY - offset <= scrollY) {
+ activeId = heading.id
+ }
+ }
+
+ if (!activeId && this.headings.length > 0) {
+ activeId = this.headings[0].id
+ }
+
+ for (const link of this.linkTargets) {
+ const id = link.getAttribute("href").slice(1)
+ const isActive = id === activeId
+ link.classList.toggle("active", isActive)
+ if (isActive) {
+ link.scrollIntoView({ block: "nearest", behavior: "smooth" })
+ }
+ }
+ }
+}
diff --git a/app/javascript/controllers/lobby_delete_controller.js b/app/javascript/controllers/lobby_delete_controller.js
new file mode 100644
index 0000000..00c85a4
--- /dev/null
+++ b/app/javascript/controllers/lobby_delete_controller.js
@@ -0,0 +1,11 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = { confirmation: String }
+ static targets = ["input", "button"]
+
+ validate() {
+ const matches = this.inputTarget.value.trim().toLowerCase() === this.confirmationValue.toLowerCase()
+ this.buttonTarget.disabled = !matches
+ }
+}
diff --git a/app/javascript/controllers/lobby_unlock_controller.js b/app/javascript/controllers/lobby_unlock_controller.js
new file mode 100644
index 0000000..7de00a4
--- /dev/null
+++ b/app/javascript/controllers/lobby_unlock_controller.js
@@ -0,0 +1,66 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = { csrf: String }
+ static targets = ["backdrop", "modal", "password", "error", "submitButton"]
+
+ #unlockUrl = null
+
+ open(event) {
+ this.#unlockUrl = event.params.url
+ this.errorTarget.hidden = true
+ this.errorTarget.textContent = ""
+ this.passwordTarget.value = ""
+ this.submitButtonTarget.disabled = false
+ this.backdropTarget.hidden = false
+ this.passwordTarget.focus()
+ }
+
+ close() {
+ this.backdropTarget.hidden = true
+ this.#unlockUrl = null
+ }
+
+ backdropClose(event) {
+ if (event.target === this.backdropTarget) {
+ this.close()
+ }
+ }
+
+ async submit(event) {
+ event.preventDefault()
+
+ const password = this.passwordTarget.value
+ if (!password) return
+
+ this.submitButtonTarget.disabled = true
+ this.errorTarget.hidden = true
+
+ try {
+ const response = await fetch(this.#unlockUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ "X-CSRF-Token": this.csrfValue
+ },
+ body: JSON.stringify({ password })
+ })
+
+ const data = await response.json()
+
+ if (response.ok) {
+ window.location.href = data.redirect_to
+ } else {
+ this.errorTarget.textContent = data.error
+ this.errorTarget.hidden = false
+ this.passwordTarget.select()
+ this.submitButtonTarget.disabled = false
+ }
+ } catch {
+ this.errorTarget.textContent = "Something went wrong. Please try again."
+ this.errorTarget.hidden = false
+ this.submitButtonTarget.disabled = false
+ }
+ }
+}
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/javascript/controllers/navbar_controller.js b/app/javascript/controllers/navbar_controller.js
index 0dc7173..e4284ba 100644
--- a/app/javascript/controllers/navbar_controller.js
+++ b/app/javascript/controllers/navbar_controller.js
@@ -1,28 +1,18 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
-
connect() {
- document.addEventListener("keydown", this.handleKeydown)
- }
-
- disconnect(){
- document.removeEventListener("keydown", this.handleKeydown)
- }
-
- handleKeydown = (e) => {
- if (this.#shouldIgnore(e)) return;
-
- if (["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) return;
- if (e.key === "p" && window.location.pathname !== "/progress") window.location.href = "/progress";
- if (e.key === "f" && window.location.pathname !== "/faction") window.location.href = "/faction";
- if (e.key === "r" && window.location.pathname !== "/ranked_war") window.location.href = "/ranked_war";
+ this.preventSameRouteClick()
}
- #shouldIgnore(event) {
- return (
- event.defaultPrevented ||
- event.ctrlKey ||
- event.target.closest("input, textarea, trix-editor")
+ preventSameRouteClick() {
+ const links = this.element.querySelectorAll('.navbar-link-active')
+ links.forEach(link => {
+ if (link.tagName === 'A') {
+ link.addEventListener('click', (e) => {
+ e.preventDefault()
+ })
+ }
+ })
}
-}
+}
\ No newline at end of file
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/payout_settings_controller.js b/app/javascript/controllers/payout_settings_controller.js
new file mode 100644
index 0000000..f4fff8b
--- /dev/null
+++ b/app/javascript/controllers/payout_settings_controller.js
@@ -0,0 +1,55 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = { url: String }
+ static targets = ["factionCut", "assistValue", "saveBtn"]
+
+ connect() {
+ this.savedFactionCut = this.factionCutTarget.value
+ this.savedAssistValue = this.assistValueTarget.value
+ this.saveBtnTarget.disabled = true
+ }
+
+ checkDirty() {
+ const dirty = this.factionCutTarget.value !== this.savedFactionCut ||
+ this.assistValueTarget.value !== this.savedAssistValue
+ this.saveBtnTarget.disabled = !dirty
+ }
+
+ async save() {
+ const btn = this.saveBtnTarget
+ btn.disabled = true
+
+ try {
+ const response = await fetch(this.urlValue, {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
+ },
+ body: JSON.stringify({
+ faction_cut: parseFloat(this.factionCutTarget.value.replace(",", ".")) || 0,
+ assist_value: parseFloat(this.assistValueTarget.value.replace(",", ".")) || 0
+ })
+ })
+
+ if (response.ok) {
+ this.savedFactionCut = this.factionCutTarget.value
+ this.savedAssistValue = this.assistValueTarget.value
+ window.dispatchEvent(new CustomEvent("flash:show", {
+ detail: { type: "notice", message: "Payout settings saved." }
+ }))
+ } else {
+ btn.disabled = false
+ window.dispatchEvent(new CustomEvent("flash:show", {
+ detail: { type: "alert", message: "Failed to save settings." }
+ }))
+ }
+ } catch {
+ btn.disabled = false
+ window.dispatchEvent(new CustomEvent("flash:show", {
+ detail: { type: "alert", message: "Failed to save settings." }
+ }))
+ }
+ }
+}
diff --git a/app/javascript/controllers/recon_sample_controller.js b/app/javascript/controllers/recon_sample_controller.js
new file mode 100644
index 0000000..069fbd5
--- /dev/null
+++ b/app/javascript/controllers/recon_sample_controller.js
@@ -0,0 +1,14 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["details"]
+
+ toggle() {
+ const row = this.detailsTarget
+ row.style.display = row.style.display === "none" ? "table-row" : "none"
+ }
+
+ stopPropagation(event) {
+ event.stopPropagation()
+ }
+}
diff --git a/app/javascript/controllers/refresh_countdown_controller.js b/app/javascript/controllers/refresh_countdown_controller.js
new file mode 100644
index 0000000..445afb0
--- /dev/null
+++ b/app/javascript/controllers/refresh_countdown_controller.js
@@ -0,0 +1,48 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = {
+ secondsRemaining: Number,
+ buttonText: { type: String, default: "Check for New Payments" }
+ }
+
+ 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.enableButton()
+ } else {
+ this.updateDisplay()
+ }
+ }, 1000)
+ }
+
+ stopCountdown() {
+ if (this.intervalId) {
+ clearInterval(this.intervalId)
+ this.intervalId = null
+ }
+ }
+
+ updateDisplay() {
+ this.element.value = `Available in ${this.secondsRemainingValue}s`
+ }
+
+ enableButton() {
+ this.element.disabled = false
+ this.element.value = this.buttonTextValue
+ }
+}
diff --git a/app/javascript/controllers/scroll_hint_controller.js b/app/javascript/controllers/scroll_hint_controller.js
new file mode 100644
index 0000000..b175cd3
--- /dev/null
+++ b/app/javascript/controllers/scroll_hint_controller.js
@@ -0,0 +1,27 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["list"]
+
+ connect() {
+ this.checkOverflow()
+ this.listTarget.addEventListener("scroll", this.onScroll.bind(this))
+ }
+
+ disconnect() {
+ this.listTarget.removeEventListener("scroll", this.onScroll.bind(this))
+ }
+
+ checkOverflow() {
+ const el = this.listTarget
+ if (el.scrollHeight > el.clientHeight) {
+ this.element.classList.add("has-overflow")
+ }
+ }
+
+ onScroll() {
+ const el = this.listTarget
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 10
+ this.element.classList.toggle("scrolled-bottom", atBottom)
+ }
+}
diff --git a/app/javascript/controllers/scroll_to_controller.js b/app/javascript/controllers/scroll_to_controller.js
new file mode 100644
index 0000000..17e7236
--- /dev/null
+++ b/app/javascript/controllers/scroll_to_controller.js
@@ -0,0 +1,12 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = { target: String }
+
+ scroll() {
+ const element = document.getElementById(this.targetValue)
+ if (element) {
+ element.scrollIntoView({ behavior: "smooth", block: "start" })
+ }
+ }
+}
diff --git a/app/javascript/controllers/share_subscription_controller.js b/app/javascript/controllers/share_subscription_controller.js
new file mode 100644
index 0000000..9c98e38
--- /dev/null
+++ b/app/javascript/controllers/share_subscription_controller.js
@@ -0,0 +1,24 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["slider", "hidden", "preview", "submit"]
+ static values = { memberCount: Number, maxTotal: Number }
+
+ slide() {
+ const steps = parseInt(this.sliderTarget.value)
+ const members = this.memberCountValue
+ const totalWeeks = steps * members
+ const perMember = steps
+
+ this.hiddenTarget.value = totalWeeks
+
+ if (totalWeeks === 0) {
+ this.previewTarget.textContent = "Drag the slider to share subscription time."
+ this.submitTarget.disabled = true
+ return
+ }
+
+ this.submitTarget.disabled = false
+ this.previewTarget.textContent = `${totalWeeks} weeks total — ${perMember} week${perMember !== 1 ? "s" : ""} each for ${members} members.`
+ }
+}
diff --git a/app/javascript/controllers/skeleton_loader_controller.js b/app/javascript/controllers/skeleton_loader_controller.js
new file mode 100644
index 0000000..31b4f06
--- /dev/null
+++ b/app/javascript/controllers/skeleton_loader_controller.js
@@ -0,0 +1,10 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["content", "skeleton"]
+
+ submit() {
+ this.contentTargets.forEach(el => el.style.display = "none")
+ this.skeletonTargets.forEach(el => el.style.display = "block")
+ }
+}
diff --git a/app/javascript/controllers/sortable_table_controller.js b/app/javascript/controllers/sortable_table_controller.js
new file mode 100644
index 0000000..47114e9
--- /dev/null
+++ b/app/javascript/controllers/sortable_table_controller.js
@@ -0,0 +1,67 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["header", "body", "arrow"]
+
+ 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"
+ })
+
+ this.arrowTargets.forEach(arrow => {
+ arrow.classList.remove("active", "asc")
+ })
+
+ th.dataset.sortDir = 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) {
+ 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) {
+ 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/javascript/controllers/subscription_days_controller.js b/app/javascript/controllers/subscription_days_controller.js
new file mode 100644
index 0000000..d3186d0
--- /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 = { url: String }
+
+ 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(this.urlValue, {
+ 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/javascript/controllers/table_sort_controller.js b/app/javascript/controllers/table_sort_controller.js
new file mode 100644
index 0000000..0e033dc
--- /dev/null
+++ b/app/javascript/controllers/table_sort_controller.js
@@ -0,0 +1,77 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = { column: { type: String, default: "" }, direction: { type: String, default: "asc" } }
+
+ sort(event) {
+ const column = event.currentTarget.dataset.sortColumn
+ const type = event.currentTarget.dataset.sortType || "string"
+
+ if (this.columnValue === column) {
+ this.directionValue = this.directionValue === "asc" ? "desc" : "asc"
+ } else {
+ this.columnValue = column
+ this.directionValue = "asc"
+ }
+
+ this.sortTable(column, type, this.directionValue)
+ this.updateIndicators()
+ }
+
+ sortTable(column, type, direction) {
+ const tbody = this.element.querySelector("tbody")
+ const rows = Array.from(tbody.querySelectorAll("tr"))
+
+ rows.sort((a, b) => {
+ const aCell = a.querySelector(`[data-sort-key="${column}"]`)
+ const bCell = b.querySelector(`[data-sort-key="${column}"]`)
+ const aVal = this.parseValue(aCell, type)
+ const bVal = this.parseValue(bCell, type)
+
+ if (aVal === Infinity && bVal === Infinity) return 0
+ if (aVal === Infinity) return 1
+ if (bVal === Infinity) return -1
+
+ let result
+ if (typeof aVal === "string") {
+ result = aVal.localeCompare(bVal)
+ } else {
+ result = aVal - bVal
+ }
+
+ return direction === "desc" ? -result : result
+ })
+
+ rows.forEach(row => tbody.appendChild(row))
+ }
+
+ parseValue(cell, type) {
+ if (!cell) return type === "string" ? "" : 0
+
+ const raw = cell.dataset.sortValue
+ if (raw === "Infinity") return Infinity
+
+ switch (type) {
+ case "number":
+ return parseFloat(raw) || 0
+ case "boolean":
+ return raw === "true" ? 1 : 0
+ default:
+ return raw || ""
+ }
+ }
+
+ updateIndicators() {
+ this.element.querySelectorAll("[data-sort-column]").forEach(header => {
+ const arrow = header.querySelector(".sort-arrow")
+ if (!arrow) return
+
+ arrow.classList.remove("active", "asc")
+
+ if (header.dataset.sortColumn === this.columnValue) {
+ arrow.classList.add("active")
+ if (this.directionValue === "asc") arrow.classList.add("asc")
+ }
+ })
+ }
+}
diff --git a/app/javascript/controllers/tabs_controller.js b/app/javascript/controllers/tabs_controller.js
new file mode 100644
index 0000000..08c3504
--- /dev/null
+++ b/app/javascript/controllers/tabs_controller.js
@@ -0,0 +1,33 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = { default: String }
+ static targets = ["button", "panel"]
+
+ connect() {
+ const hash = window.location.hash.slice(1)
+ const activeTab = hash || this.defaultValue || this.buttonTargets[0]?.dataset.tab
+
+ if (activeTab) {
+ this.activateTab(activeTab)
+ }
+ }
+
+ switch(event) {
+ const tab = event.currentTarget.dataset.tab
+ this.activateTab(tab)
+
+ history.replaceState(null, null, `#${tab}`)
+ }
+
+ activateTab(tabName) {
+ this.buttonTargets.forEach(button => {
+ button.classList.toggle("active", button.dataset.tab === tabName)
+ })
+
+ this.panelTargets.forEach(panel => {
+ panel.classList.toggle("active", panel.dataset.tab === tabName)
+ panel.hidden = panel.dataset.tab !== tabName
+ })
+ }
+}
diff --git a/app/javascript/controllers/war_dashboard_controller.js b/app/javascript/controllers/war_dashboard_controller.js
new file mode 100644
index 0000000..cb422cb
--- /dev/null
+++ b/app/javascript/controllers/war_dashboard_controller.js
@@ -0,0 +1,956 @@
+import { Controller } from "@hotwired/stimulus"
+
+const FLIGHT_TIMES = {
+ "Mexico": { standard: 1560, airstrip: 1080, wlt: 780, bct: 480 },
+ "Cayman Islands": { standard: 2100, airstrip: 1500, wlt: 1080, bct: 660 },
+ "Canada": { standard: 2460, airstrip: 1740, wlt: 1200, bct: 720 },
+ "Hawaii": { standard: 8040, airstrip: 5640, wlt: 4020, bct: 2400 },
+ "United Kingdom": { standard: 9540, airstrip: 6660, wlt: 4800, bct: 2880 },
+ "Argentina": { standard: 10020, airstrip: 7020, wlt: 4980, bct: 3000 },
+ "Switzerland": { standard: 10500, airstrip: 7380, wlt: 5280, bct: 3180 },
+ "Japan": { standard: 13500, airstrip: 9480, wlt: 6780, bct: 4080 },
+ "China": { standard: 14520, airstrip: 10140, wlt: 7260, bct: 4320 },
+ "UAE": { standard: 16260, airstrip: 11400, wlt: 8100, bct: 4860 },
+ "South Africa": { standard: 17820, airstrip: 12480, wlt: 8940, bct: 5340 }
+}
+
+const PLANE_TYPE_MAP = {
+ "private_jet": ["wlt"],
+ "light_aircraft": ["airstrip"],
+ "airliner": ["bct", "standard"]
+}
+
+export default class extends Controller {
+ static values = {
+ initialData: Object,
+ ourScore: Number,
+ theirScore: Number,
+ targetScore: Number,
+ factionName: String,
+ enemyName: String,
+ startedAt: String,
+ scheduled: Boolean,
+ pollUrl: String,
+ pollInterval: { type: Number, default: 6000 },
+ hideStats: { type: Boolean, default: false },
+ editableStats: { type: Boolean, default: false },
+ statsUrl: { type: String, default: "" },
+ terminatedUrl: { type: String, default: "" }
+ }
+
+ static targets = [
+ "ourScore", "theirScore", "targetScore", "currentLead", "leadProgress",
+ "warTimer", "connectionStatus", "lastUpdated", "updateCountdown",
+ "membersBody", "visibleCount", "totalCount", "filterCount",
+ "sortIndicatorName", "sortIndicatorLevel", "sortIndicatorStatus",
+ "sortIndicatorLastAction", "sortIndicatorTimer", "sortIndicatorTotal",
+ "sortIndicatorStrength", "sortIndicatorDefense",
+ "sortIndicatorSpeed", "sortIndicatorDexterity",
+ "filterStatusOkay", "filterStatusHospital", "filterStatusJail", "filterStatusTraveling", "filterStatusAbroad",
+ "filterActionOnline", "filterActionIdle", "filterActionOffline",
+ "filterMaxStats", "filterMaxStatsLabel"
+ ]
+
+ connect() {
+ this.members = {}
+ this.sortKey = "status"
+ this.sortDirection = "asc"
+ this.timerInterval = null
+ this.pollInterval = null
+ this.countdownInterval = null
+ this.secondsUntilUpdate = this.pollIntervalValue / 1000
+
+ if (this.initialDataValue && Object.keys(this.initialDataValue).length > 0) {
+ this.handleData(this.initialDataValue)
+ this.updateConnectionStatus("connected", "Live")
+ }
+
+ this.startWarTimer()
+ this.startPolling()
+ this.timerInterval = setInterval(() => this.tickTimers(), 1000)
+ this.startUpdateCountdown()
+ this.updateSortIndicators()
+
+ if (this.editableStatsValue) {
+ this.element.addEventListener("click", this.#handleStatClick)
+ }
+ }
+
+ disconnect() {
+ if (this.pollInterval) {
+ clearInterval(this.pollInterval)
+ this.pollInterval = null
+ }
+ if (this.timerInterval) {
+ clearInterval(this.timerInterval)
+ this.timerInterval = null
+ }
+ if (this.warTimerInterval) {
+ clearInterval(this.warTimerInterval)
+ this.warTimerInterval = null
+ }
+ if (this.countdownInterval) {
+ clearInterval(this.countdownInterval)
+ this.countdownInterval = null
+ }
+ this.element.removeEventListener("click", this.#handleStatClick)
+ }
+
+ startPolling() {
+ if (!this.pollUrlValue) return
+
+ this.fetchWarData()
+ this.pollInterval = setInterval(() => this.fetchWarData(), this.pollIntervalValue)
+ }
+
+ async fetchWarData() {
+ try {
+ const response = await fetch(this.pollUrlValue, {
+ headers: { "Accept": "application/json" }
+ })
+
+ if (response.status === 204) return
+
+ if (response.status === 410 && this.terminatedUrlValue) {
+ window.location.href = this.terminatedUrlValue
+ return
+ }
+
+ if (response.ok) {
+ const data = await response.json()
+ if (data && data.members) {
+ this.handleData(data)
+ this.updateConnectionStatus("connected", "Live")
+ }
+ }
+ } catch {
+ this.updateConnectionStatus("connecting", "Reconnecting...")
+ }
+ }
+
+ handleData(data) {
+ if (data.our_score !== undefined) {
+ this.ourScoreValue = data.our_score
+ if (this.hasOurScoreTarget) this.ourScoreTarget.textContent = data.our_score
+ }
+ if (data.their_score !== undefined) {
+ this.theirScoreValue = data.their_score
+ if (this.hasTheirScoreTarget) this.theirScoreTarget.textContent = data.their_score
+ }
+ if (data.target_score !== undefined) {
+ this.targetScoreValue = data.target_score
+ if (this.hasTargetScoreTarget) this.targetScoreTarget.textContent = data.target_score
+ }
+
+ if (this.hasOurScoreTarget && this.hasTheirScoreTarget) {
+ this.updateScoreClasses()
+ }
+ this.updateLeadProgress()
+
+ if (data.members) {
+ const previousMembers = { ...this.members }
+ this.members = {}
+
+ for (const [id, member] of Object.entries(data.members)) {
+ if (member.status?.state === "Fallen") continue
+
+ this.members[id] = {
+ ...member,
+ torn_id: member.torn_id || id,
+ _changed: this.memberChanged(previousMembers[id], member)
+ }
+ }
+
+ this.renderTable()
+ }
+
+ if (data.cached_at) {
+ this.updateLastUpdated(data.cached_at)
+ }
+ }
+
+ memberChanged(prev, current) {
+ if (!prev) return true
+ if (prev.status?.state !== current.status?.state) return true
+ if (prev.status?.until !== current.status?.until) return true
+ if (prev.level !== current.level) return true
+ if (prev.last_action?.status !== current.last_action?.status) return true
+ return false
+ }
+
+ updateScoreClasses() {
+ if (!this.hasOurScoreTarget || !this.hasTheirScoreTarget) return
+
+ const ours = this.ourScoreValue
+ const theirs = this.theirScoreValue
+ const ourEl = this.ourScoreTarget
+ const theirEl = this.theirScoreTarget
+
+ ourEl.classList.remove("winning", "losing", "tied")
+ theirEl.classList.remove("winning", "losing", "tied")
+
+ if (ours > theirs) {
+ ourEl.classList.add("winning")
+ theirEl.classList.add("losing")
+ } else if (theirs > ours) {
+ ourEl.classList.add("losing")
+ theirEl.classList.add("winning")
+ } else {
+ ourEl.classList.add("tied")
+ theirEl.classList.add("tied")
+ }
+ }
+
+ updateLeadProgress() {
+ const lead = this.ourScoreValue - this.theirScoreValue
+ const target = this.targetScoreValue
+
+ if (this.hasCurrentLeadTarget) {
+ this.currentLeadTarget.textContent = lead.toLocaleString()
+ }
+
+ if (this.hasLeadProgressTarget) {
+ const percentage = Math.min(Math.max((lead / target) * 100, 0), 100)
+ this.leadProgressTarget.style.width = `${percentage}%`
+ this.leadProgressTarget.classList.toggle("losing", lead < 0)
+ }
+ }
+
+ startWarTimer() {
+ if (!this.startedAtValue) return
+
+ const startedAt = new Date(this.startedAtValue)
+ const updateTimer = () => {
+ const now = new Date()
+
+ if (this.scheduledValue) {
+ const remaining = Math.floor((startedAt - now) / 1000)
+ if (remaining <= 0) {
+ window.location.reload()
+ return
+ }
+ if (this.hasWarTimerTarget) {
+ this.warTimerTarget.textContent = `Starts in ${this.formatDuration(remaining)}`
+ }
+ } else {
+ const elapsed = Math.floor((now - startedAt) / 1000)
+ if (this.hasWarTimerTarget) {
+ this.warTimerTarget.textContent = this.formatDuration(elapsed)
+ }
+ }
+ }
+
+ updateTimer()
+ this.warTimerInterval = setInterval(updateTimer, 1000)
+ }
+
+ updateConnectionStatus(state, text) {
+ if (!this.hasConnectionStatusTarget) return
+
+ this.connectionStatusTarget.classList.remove("connected", "connecting", "offline")
+ this.connectionStatusTarget.classList.add(state)
+
+ const textEl = this.connectionStatusTarget.querySelector(".live-polling-text")
+ if (textEl) {
+ textEl.textContent = text
+ }
+ }
+
+ updateLastUpdated(isoString) {
+ if (!this.hasLastUpdatedTarget) return
+
+ const date = new Date(isoString)
+ const hours = date.getHours().toString().padStart(2, "0")
+ const minutes = date.getMinutes().toString().padStart(2, "0")
+ const seconds = date.getSeconds().toString().padStart(2, "0")
+ this.lastUpdatedTarget.textContent = `Updated ${hours}:${minutes}:${seconds}`
+
+ this.secondsUntilUpdate = this.pollIntervalValue / 1000
+ this.restartCountdownInterval()
+ }
+
+ startUpdateCountdown() {
+ this.updateCountdownDisplay()
+ this.restartCountdownInterval()
+ }
+
+ restartCountdownInterval() {
+ if (this.countdownInterval) clearInterval(this.countdownInterval)
+ this.updateCountdownDisplay()
+ this.countdownInterval = setInterval(() => {
+ this.secondsUntilUpdate = Math.max(0, this.secondsUntilUpdate - 1)
+ this.updateCountdownDisplay()
+ }, 1000)
+ }
+
+ updateCountdownDisplay() {
+ if (!this.hasUpdateCountdownTarget) return
+ this.updateCountdownTarget.textContent = `Next update in ${this.secondsUntilUpdate}s`
+ }
+
+ toggleFilter({ currentTarget }) {
+ const isActive = currentTarget.dataset.filterActive === "true"
+ currentTarget.dataset.filterActive = isActive ? "false" : "true"
+ currentTarget.classList.toggle("filter-disabled", isActive)
+ this.applyFilters()
+ }
+
+ isFilterActive(target) {
+ return target.dataset.filterActive === "true"
+ }
+
+ applyFilters() {
+ if (this.hasFilterMaxStatsTarget && this.hasFilterMaxStatsLabelTarget) {
+ const maxVal = parseInt(this.filterMaxStatsTarget.value)
+ const sliderMax = parseInt(this.filterMaxStatsTarget.max)
+ if (maxVal >= sliderMax) {
+ this.filterMaxStatsLabelTarget.textContent = "No limit"
+ } else {
+ this.filterMaxStatsLabelTarget.textContent = this.formatStat(maxVal)
+ }
+ }
+
+ this.renderTable()
+ }
+
+ getFilteredMembers(members) {
+ const statusFilters = {}
+ if (this.hasFilterStatusOkayTarget) statusFilters["Okay"] = this.isFilterActive(this.filterStatusOkayTarget)
+ if (this.hasFilterStatusHospitalTarget) statusFilters["Hospital"] = this.isFilterActive(this.filterStatusHospitalTarget)
+ if (this.hasFilterStatusJailTarget) statusFilters["Jail"] = this.isFilterActive(this.filterStatusJailTarget)
+ if (this.hasFilterStatusTravelingTarget) statusFilters["Traveling"] = this.isFilterActive(this.filterStatusTravelingTarget)
+ if (this.hasFilterStatusAbroadTarget) statusFilters["Abroad"] = this.isFilterActive(this.filterStatusAbroadTarget)
+
+ const actionFilters = {}
+ if (this.hasFilterActionOnlineTarget) actionFilters["Online"] = this.isFilterActive(this.filterActionOnlineTarget)
+ if (this.hasFilterActionIdleTarget) actionFilters["Idle"] = this.isFilterActive(this.filterActionIdleTarget)
+ if (this.hasFilterActionOfflineTarget) actionFilters["Offline"] = this.isFilterActive(this.filterActionOfflineTarget)
+
+ let maxStats = Infinity
+ if (this.hasFilterMaxStatsTarget) {
+ const val = parseInt(this.filterMaxStatsTarget.value)
+ const sliderMax = parseInt(this.filterMaxStatsTarget.max)
+ if (val < sliderMax) maxStats = val
+ }
+
+ return members.filter(member => {
+ const state = member.status?.state || "Unknown"
+ if (statusFilters[state] === false) return false
+
+ const actionStatus = member.last_action?.status || "Offline"
+ if (actionFilters[actionStatus] === false) return false
+
+ const total = member.stats?.total || 0
+ if (total > 0 && total > maxStats) return false
+
+ return true
+ })
+ }
+
+ updateFilterCount(visible, total) {
+ if (this.hasVisibleCountTarget) this.visibleCountTarget.textContent = visible
+ if (this.hasTotalCountTarget) this.totalCountTarget.textContent = total
+
+ if (this.hasFilterCountTarget) {
+ if (visible < total) {
+ this.filterCountTarget.textContent = `${total - visible} hidden`
+ } else {
+ this.filterCountTarget.textContent = ""
+ }
+ }
+ }
+
+ sort({ params: { sortKey } }) {
+ if (this.sortKey === sortKey) {
+ this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc"
+ } else {
+ this.sortKey = sortKey
+ this.sortDirection = "asc"
+ }
+
+ this.updateSortIndicators()
+ this.renderTable()
+ }
+
+ updateSortIndicators() {
+ const keys = ["Name", "Level", "Status", "LastAction", "Timer", "Total", "Strength", "Defense", "Speed", "Dexterity"]
+
+ keys.forEach(key => {
+ const targetName = `sortIndicator${key}`
+ const hasTarget = this[`has${targetName.charAt(0).toUpperCase() + targetName.slice(1)}Target`]
+ const target = hasTarget ? this[`${targetName}Target`] : null
+
+ if (target) {
+ target.classList.remove("active", "asc")
+ const sortKeyMatch = key.charAt(0).toLowerCase() + key.slice(1)
+ if (this.sortKey === sortKeyMatch) {
+ target.classList.add("active")
+ if (this.sortDirection === "asc") target.classList.add("asc")
+ }
+ }
+ })
+ }
+
+ getSortedMembers() {
+ const members = Object.values(this.members)
+ const key = this.sortKey
+ const dir = this.sortDirection === "asc" ? 1 : -1
+
+ return members.sort((a, b) => {
+ let aVal, bVal
+
+ switch (key) {
+ case "name":
+ aVal = (a.name || "").toLowerCase()
+ bVal = (b.name || "").toLowerCase()
+ return aVal < bVal ? -1 * dir : aVal > bVal ? 1 * dir : 0
+
+ case "level":
+ return ((a.level || 0) - (b.level || 0)) * dir
+
+ case "status":
+ aVal = this.statusSortOrder(a.status?.state)
+ bVal = this.statusSortOrder(b.status?.state)
+ return (aVal - bVal) * dir
+
+ case "lastAction":
+ aVal = this.actionSortOrder(a.last_action?.status)
+ bVal = this.actionSortOrder(b.last_action?.status)
+ if (aVal !== bVal) return (aVal - bVal) * dir
+ aVal = a.last_action?.timestamp || 0
+ bVal = b.last_action?.timestamp || 0
+ return (bVal - aVal) * dir
+
+ case "timer":
+ aVal = this.getTimerSeconds(a)
+ bVal = this.getTimerSeconds(b)
+ return (aVal - bVal) * dir
+
+ case "total":
+ aVal = a.stats?.total || 0
+ bVal = b.stats?.total || 0
+ return (aVal - bVal) * dir
+
+ case "strength":
+ aVal = a.stats?.strength || 0
+ bVal = b.stats?.strength || 0
+ return (aVal - bVal) * dir
+
+ case "defense":
+ aVal = a.stats?.defense || 0
+ bVal = b.stats?.defense || 0
+ return (aVal - bVal) * dir
+
+ case "speed":
+ aVal = a.stats?.speed || 0
+ bVal = b.stats?.speed || 0
+ return (aVal - bVal) * dir
+
+ case "dexterity":
+ aVal = a.stats?.dexterity || 0
+ bVal = b.stats?.dexterity || 0
+ return (aVal - bVal) * dir
+
+ default:
+ return 0
+ }
+ })
+ }
+
+ statusSortOrder(state) {
+ const order = { "Okay": 0, "Traveling": 1, "Jail": 2, "Hospital": 3, "Fallen": 4 }
+ return order[state] ?? 5
+ }
+
+ actionSortOrder(status) {
+ const order = { "Online": 0, "Idle": 1, "Offline": 2 }
+ return order[status] ?? 3
+ }
+
+ getTimerSeconds(member) {
+ const status = member.status
+ if (!status) return -1
+
+ if (status.state === "Traveling") {
+ if (!status.travel_started_at || !status.destination) {
+ return 999999
+ }
+ const flightData = FLIGHT_TIMES[status.destination]
+ if (flightData) {
+ const ticketTypes = PLANE_TYPE_MAP[status.plane_type] || ["standard"]
+ const duration = flightData[ticketTypes[0]]
+ const elapsed = Math.floor((new Date() - new Date(status.travel_started_at)) / 1000)
+ const remaining = duration - elapsed
+ return remaining > 0 ? remaining : -1
+ }
+ }
+
+ if (!status.until) return -1
+ const expiresAt = new Date(status.until)
+ const remaining = Math.floor((expiresAt - new Date()) / 1000)
+ return remaining > 0 ? remaining : -1
+ }
+
+ renderTable() {
+ if (!this.hasMembersBodyTarget) return
+
+ const sorted = this.getSortedMembers()
+ const filtered = this.getFilteredMembers(sorted)
+
+ this.updateFilterCount(filtered.length, sorted.length)
+
+ const activeEdit = this.#captureActiveEdit()
+
+ if (filtered.length === 0) {
+ this.membersBodyTarget.innerHTML = `
+
+ ${sorted.length === 0 ? "No member data available." : "No members match the current filters."}
+
+ `
+ return
+ }
+
+ const rows = filtered.map(member => this.renderRow(member)).join("")
+ this.membersBodyTarget.innerHTML = rows
+
+ if (activeEdit) this.#restoreActiveEdit(activeEdit)
+ }
+
+ renderRow(member) {
+ const status = member.status || { state: "Unknown" }
+ const statusClass = this.statusCssClass(status.state)
+ const rowClass = status.state === "Hospital" ? "row-hospital" : "row-okay"
+ const changedClass = member._changed ? "row-updated" : ""
+
+ const timerHtml = this.renderTimer(member)
+ const lastActionHtml = this.renderLastAction(member)
+ const statsHtml = this.hideStatsValue ? "" : this.renderStats(member)
+ const attackUrl = `https://www.torn.com/loader.php?sid=attack&user2ID=${member.torn_id}`
+ const profileUrl = `https://www.torn.com/profiles.php?XID=${member.torn_id}`
+
+ return `
+
+
+ ${this.escapeHtml(member.name || "Unknown")}
+
+ ${member.level || "?"}
+
+ ${this.escapeHtml(status.state || "Unknown")}
+
+ ${lastActionHtml}
+ ${timerHtml}
+ ${statsHtml}
+
+
+
+
+
+
+
+
+
+
+ `
+ }
+
+ renderLastAction(member) {
+ const lastAction = member.last_action
+ if (!lastAction?.status) return '- '
+
+ const actionClass = this.actionCssClass(lastAction.status)
+ const relative = lastAction.relative || ""
+
+ return `${this.escapeHtml(lastAction.status)} `
+ }
+
+ renderTimer(member) {
+ const status = member.status
+ if (!status) return '- '
+
+ if (status.state === "Traveling") {
+ return this.renderTravelTimer(status)
+ }
+
+ if (status.state === "Abroad") {
+ const description = status.description || ""
+ const location = description.replace(/^In\s+/i, "") || "Abroad"
+ return `${this.escapeHtml(location)} `
+ }
+
+ if (!status.until) return '- '
+
+ const expiresAt = new Date(status.until)
+ const now = new Date()
+ const remaining = Math.floor((expiresAt - now) / 1000)
+
+ if (remaining <= 0) return '- '
+
+ const expiringSoon = remaining < 60
+ const cssClass = expiringSoon ? "hospital-timer expiring-soon" : "hospital-timer"
+
+ return `${this.formatCountdown(remaining)} `
+ }
+
+ renderTravelTimer(status) {
+ const destination = status.destination
+ const description = status.description || ""
+ const isReturning = description.toLowerCase().includes("returning")
+ const directionPrefix = isReturning ? "\u2190 " : ""
+ const directionSuffix = isReturning ? "" : " \u2192"
+
+ if (!status.travel_started_at) {
+ const destText = destination || "Unknown"
+ const displayText = isReturning ? `\u2190 Torn` : `${destText} \u2192`
+ return `${this.escapeHtml(displayText)} `
+ }
+
+ const planeType = status.plane_type
+ const startedAt = new Date(status.travel_started_at)
+
+ const flightData = FLIGHT_TIMES[destination]
+ if (!flightData) {
+ const displayText = isReturning ? `\u2190 Torn` : `${destination} \u2192`
+ return `${this.escapeHtml(displayText)} `
+ }
+
+ const ticketTypes = PLANE_TYPE_MAP[planeType] || ["standard"]
+ const now = Date.now()
+
+ if (ticketTypes.length === 2) {
+ const fastEtaMs = startedAt.getTime() + flightData[ticketTypes[0]] * 1000
+ const slowEtaMs = startedAt.getTime() + flightData[ticketTypes[1]] * 1000
+ const fastRemaining = Math.max(0, Math.floor((fastEtaMs - now) / 1000))
+ const slowRemaining = Math.max(0, Math.floor((slowEtaMs - now) / 1000))
+
+ const fastEta = new Date(fastEtaMs).toISOString()
+ const slowEta = new Date(slowEtaMs).toISOString()
+
+ if (slowRemaining <= 0) {
+ return 'About to land '
+ }
+
+ const fastText = fastRemaining <= 0 ? "About to land" : this.formatCountdown(fastRemaining)
+ const slowText = this.formatCountdown(slowRemaining)
+ const expiringSoon = fastRemaining > 0 && fastRemaining < 60
+
+ return ``
+ + `${directionPrefix}${fastText} `
+ + ` / `
+ + `${slowText}${directionSuffix} `
+ + ` `
+ } else {
+ const etaMs = startedAt.getTime() + flightData[ticketTypes[0]] * 1000
+ const remaining = Math.max(0, Math.floor((etaMs - now) / 1000))
+ const eta = new Date(etaMs).toISOString()
+
+ if (remaining <= 0) {
+ return 'About to land '
+ }
+
+ const expiringSoon = remaining < 60
+
+ return `${directionPrefix}${this.formatCountdown(remaining)}${directionSuffix} `
+ }
+ }
+
+ renderStats(member) {
+ const stats = member.stats
+ const editable = this.editableStatsValue
+ const fields = ["total", "strength", "defense", "speed", "dexterity"]
+
+ if (!stats) {
+ return fields.map(field => {
+ const extraClass = field === "total" ? " stat-total" : ""
+ if (editable) {
+ return ``
+ }
+ return ``
+ }).join("")
+ }
+
+ return fields.map(field => {
+ const value = stats[field]
+ const extraClass = field === "total" ? " stat-total" : ""
+
+ if (editable) {
+ return ``
+ }
+ return ``
+ }).join("")
+ }
+
+ tickTimers() {
+ if (!this.hasMembersBodyTarget) return
+
+ const timerElements = this.membersBodyTarget.querySelectorAll("[data-timer-until]")
+ timerElements.forEach(el => {
+ const expiresAt = new Date(el.dataset.timerUntil)
+ const now = new Date()
+ const remaining = Math.floor((expiresAt - now) / 1000)
+
+ if (remaining <= 0) {
+ el.textContent = "-"
+ el.className = "stat-value no-data"
+ el.removeAttribute("data-timer-until")
+ } else {
+ el.textContent = this.formatCountdown(remaining)
+ if (remaining < 60) {
+ el.className = "hospital-timer expiring-soon"
+ } else {
+ el.className = "hospital-timer"
+ }
+ }
+ })
+
+ const travelTimers = this.membersBodyTarget.querySelectorAll("[data-travel-eta]")
+ travelTimers.forEach(el => {
+ const eta = new Date(el.dataset.travelEta)
+ const remaining = Math.max(0, Math.floor((eta - new Date()) / 1000))
+ const isReturning = el.dataset.travelReturning === "true"
+ const prefix = isReturning ? "\u2190 " : ""
+ const suffix = isReturning ? "" : " \u2192"
+
+ if (remaining <= 0) {
+ el.textContent = "About to land"
+ el.className = "travel-timer about-to-land"
+ el.removeAttribute("data-travel-eta")
+ } else {
+ el.textContent = `${prefix}${this.formatCountdown(remaining)}${suffix}`
+ el.className = remaining < 60 ? "travel-timer expiring-soon" : "travel-timer"
+ }
+ })
+
+ const dualTimers = this.membersBodyTarget.querySelectorAll("[data-travel-fast-eta]")
+ dualTimers.forEach(el => {
+ const fastEta = new Date(el.dataset.travelFastEta)
+ const slowEta = new Date(el.dataset.travelSlowEta)
+ const now = new Date()
+ const fastRemaining = Math.max(0, Math.floor((fastEta - now) / 1000))
+ const slowRemaining = Math.max(0, Math.floor((slowEta - now) / 1000))
+ const isReturning = el.dataset.travelReturning === "true"
+ const prefix = isReturning ? "\u2190 " : ""
+ const suffix = isReturning ? "" : " \u2192"
+
+ if (slowRemaining <= 0) {
+ el.textContent = "About to land"
+ el.className = "travel-timer about-to-land"
+ el.removeAttribute("data-travel-fast-eta")
+ el.removeAttribute("data-travel-slow-eta")
+ } else {
+ const fastEl = el.querySelector(".travel-fast")
+ const slowEl = el.querySelector(".travel-slow")
+ if (fastEl) fastEl.textContent = fastRemaining <= 0 ? "About to land" : `${prefix}${this.formatCountdown(fastRemaining)}`
+ if (slowEl) slowEl.textContent = `${this.formatCountdown(slowRemaining)}${suffix}`
+ el.className = fastRemaining > 0 && fastRemaining < 60 ? "travel-timer expiring-soon" : "travel-timer"
+ }
+ })
+ }
+
+ #handleStatClick = (event) => {
+ const cell = event.target.closest(".stat-editable")
+ if (!cell || cell.querySelector("input")) return
+
+ const memberId = cell.dataset.memberId
+ const field = cell.dataset.statField
+ const rawValue = cell.dataset.statRaw || ""
+
+ const input = document.createElement("input")
+ input.type = "text"
+ input.className = "stat-edit-input"
+ input.value = rawValue
+ input.placeholder = "e.g. 1.5B"
+ input.dataset.memberId = memberId
+ input.dataset.statField = field
+
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault()
+ this.#saveStat(input, cell)
+ } else if (e.key === "Escape") {
+ e.preventDefault()
+ this.#cancelEdit(cell, rawValue)
+ }
+ })
+
+ input.addEventListener("blur", () => {
+ setTimeout(() => {
+ if (document.body.contains(input)) {
+ this.#saveStat(input, cell)
+ }
+ }, 100)
+ })
+
+ cell.textContent = ""
+ cell.appendChild(input)
+ input.focus()
+ input.select()
+ }
+
+ #parseStat(value) {
+ if (!value || value.trim() === "") return 0
+
+ const cleaned = value.trim().toUpperCase()
+ const match = cleaned.match(/^([\d.]+)\s*([KMBT]?)$/)
+ if (!match) return parseInt(cleaned.replace(/[^0-9]/g, ""), 10) || 0
+
+ const num = parseFloat(match[1])
+ const suffix = match[2]
+ const multipliers = { "K": 1_000, "M": 1_000_000, "B": 1_000_000_000, "T": 1_000_000_000_000 }
+
+ return Math.round(num * (multipliers[suffix] || 1))
+ }
+
+ async #saveStat(input, cell) {
+ const memberId = input.dataset.memberId
+ const field = input.dataset.statField
+ const parsed = this.#parseStat(input.value)
+
+ if (parsed <= 0) {
+ this.#cancelEdit(cell, cell.dataset.statRaw || "")
+ return
+ }
+
+ cell.dataset.statRaw = parsed
+ cell.textContent = this.formatStat(parsed)
+
+ const member = this.members[memberId]
+ if (member) {
+ if (!member.stats) member.stats = {}
+ member.stats[field] = parsed
+ }
+
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
+ try {
+ const body = { torn_id: memberId, [field]: parsed }
+ await fetch(this.statsUrlValue, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ "X-CSRF-Token": csrfToken
+ },
+ body: JSON.stringify(body)
+ })
+ } catch {
+ }
+
+ this.renderTable()
+ }
+
+ #cancelEdit(cell, rawValue) {
+ cell.textContent = rawValue ? this.formatStat(parseInt(rawValue, 10)) : "-"
+ }
+
+ #captureActiveEdit() {
+ const input = this.membersBodyTarget.querySelector(".stat-edit-input")
+ if (!input) return null
+
+ return {
+ memberId: input.dataset.memberId,
+ field: input.dataset.statField,
+ value: input.value,
+ selectionStart: input.selectionStart,
+ selectionEnd: input.selectionEnd
+ }
+ }
+
+ #restoreActiveEdit(edit) {
+ const cell = this.membersBodyTarget.querySelector(
+ `.stat-editable[data-member-id="${edit.memberId}"][data-stat-field="${edit.field}"]`
+ )
+ if (!cell) return
+
+ const input = document.createElement("input")
+ input.type = "text"
+ input.className = "stat-edit-input"
+ input.value = edit.value
+ input.placeholder = "e.g. 1.5B"
+ input.dataset.memberId = edit.memberId
+ input.dataset.statField = edit.field
+
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") {
+ e.preventDefault()
+ this.#saveStat(input, cell)
+ } else if (e.key === "Escape") {
+ e.preventDefault()
+ this.#cancelEdit(cell, cell.dataset.statRaw || "")
+ }
+ })
+
+ input.addEventListener("blur", () => {
+ setTimeout(() => {
+ if (document.body.contains(input)) {
+ this.#saveStat(input, cell)
+ }
+ }, 100)
+ })
+
+ cell.textContent = ""
+ cell.appendChild(input)
+ input.focus()
+ input.setSelectionRange(edit.selectionStart, edit.selectionEnd)
+ }
+
+ statusCssClass(state) {
+ const map = {
+ "Okay": "status-okay",
+ "Hospital": "status-hospital",
+ "Jail": "status-jail",
+ "Traveling": "status-traveling",
+ "Abroad": "status-abroad",
+ "Fallen": "status-fallen"
+ }
+ return map[state] || "status-unknown"
+ }
+
+ actionCssClass(status) {
+ const map = {
+ "Online": "action-online",
+ "Idle": "action-idle",
+ "Offline": "action-offline"
+ }
+ return map[status] || "action-offline"
+ }
+
+ formatCountdown(totalSeconds) {
+ const hours = Math.floor(totalSeconds / 3600)
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
+ const seconds = totalSeconds % 60
+
+ if (hours > 0) {
+ return `${hours}h ${minutes}m ${seconds}s`
+ }
+ return `${minutes}m ${seconds}s`
+ }
+
+ formatDuration(totalSeconds) {
+ const hours = Math.floor(totalSeconds / 3600)
+ const minutes = Math.floor((totalSeconds % 3600) / 60)
+ const seconds = totalSeconds % 60
+
+ if (hours > 0) {
+ return `${hours}h ${minutes}m ${seconds}s`
+ }
+ return `${minutes}m ${seconds}s`
+ }
+
+ formatStat(value) {
+ if (value === null || value === undefined || value === 0) return "-"
+
+ if (value >= 1_000_000_000) {
+ return `${(value / 1_000_000_000).toFixed(1)}B`
+ } else if (value >= 1_000_000) {
+ return `${(value / 1_000_000).toFixed(1)}M`
+ } else if (value >= 1_000) {
+ return `${(value / 1_000).toFixed(1)}K`
+ }
+ return value.toLocaleString()
+ }
+
+ escapeHtml(text) {
+ const div = document.createElement("div")
+ div.textContent = text
+ return div.innerHTML
+ }
+}
diff --git a/app/javascript/controllers/war_score_controller.js b/app/javascript/controllers/war_score_controller.js
new file mode 100644
index 0000000..78cf6c3
--- /dev/null
+++ b/app/javascript/controllers/war_score_controller.js
@@ -0,0 +1,33 @@
+import { Controller } from "@hotwired/stimulus"
+import consumer from "channels/consumer"
+
+export default class extends Controller {
+ static values = {
+ ourScore: Number,
+ theirScore: Number
+ }
+
+ static targets = ["ourScore", "theirScore"]
+
+ connect() {
+ this.subscription = consumer.subscriptions.create("WarChannel", {
+ received: (data) => this.onReceived(data)
+ })
+ }
+
+ disconnect() {
+ if (this.subscription) {
+ this.subscription.unsubscribe()
+ this.subscription = null
+ }
+ }
+
+ onReceived(data) {
+ if (data.our_score !== undefined && this.hasOurScoreTarget) {
+ this.ourScoreTarget.textContent = data.our_score
+ }
+ if (data.their_score !== undefined && this.hasTheirScoreTarget) {
+ this.theirScoreTarget.textContent = data.their_score
+ }
+ }
+}
diff --git a/app/jobs/admin_api_job.rb b/app/jobs/admin_api_job.rb
new file mode 100644
index 0000000..e1f959c
--- /dev/null
+++ b/app/jobs/admin_api_job.rb
@@ -0,0 +1,3 @@
+# Transitional alias — all API discipline lives in TornApiJob.
+class AdminApiJob < TornApiJob
+end
diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb
index d394c3d..a009ace 100644
--- a/app/jobs/application_job.rb
+++ b/app/jobs/application_job.rb
@@ -1,7 +1,2 @@
class ApplicationJob < ActiveJob::Base
- # Automatically retry jobs that encountered a deadlock
- # retry_on ActiveRecord::Deadlocked
-
- # Most jobs are safe to ignore if the underlying records are no longer available
- # discard_on ActiveJob::DeserializationError
end
diff --git a/app/jobs/backfill_armory_news_job.rb b/app/jobs/backfill_armory_news_job.rb
new file mode 100644
index 0000000..7d52034
--- /dev/null
+++ b/app/jobs/backfill_armory_news_job.rb
@@ -0,0 +1,66 @@
+class BackfillArmoryNewsJob < FactionApiJob
+ BACKFILL_FLOOR = Time.utc(2026, 1, 1).to_i
+ PAGE_DELAY = 1.minute
+
+ 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)
+ 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
+
+ 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)
+
+ 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 ])
+
+ 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)
+ else
+ faction.update!(armory_backfill_pending: false)
+ 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/backfill_hof_stats_job.rb b/app/jobs/backfill_hof_stats_job.rb
new file mode 100644
index 0000000..eec303f
--- /dev/null
+++ b/app/jobs/backfill_hof_stats_job.rb
@@ -0,0 +1,30 @@
+class BackfillHofStatsJob < ApplicationJob
+ queue_as :faction
+
+ 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
+
+ users = User.hof_stats_users.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_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, api_key: api_key)
+ jobs_scheduled += 1
+ end
+ end
+
+ 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
new file mode 100644
index 0000000..94d8b4a
--- /dev/null
+++ b/app/jobs/backfill_personal_stats_job.rb
@@ -0,0 +1,38 @@
+class BackfillPersonalStatsJob < ApplicationJob
+ queue_as :faction
+
+ SECONDS_PER_API_CALL = 1.1
+
+ def perform(faction_id, start_date, end_date)
+ faction = Faction.find(faction_id)
+ 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
+
+ Rails.logger.info("Scheduling backfill for faction #{faction.name}: #{users.count} users, #{dates.size} days")
+
+ jobs_scheduled = 0
+
+ users.each do |user|
+ existing_dates = user.personal_stat_snapshots
+ .where(date: dates.first..dates.last)
+ .pluck(:date)
+ .to_set
+
+ dates.each do |date|
+ next if existing_dates.include?(date)
+
+ BackfillSingleStatJob.perform_later(user.id, date.to_s, faction_id: faction.id, api_key: api_key)
+ jobs_scheduled += 1
+ end
+ end
+
+ 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)
+ end
+
+ Rails.logger.info("Scheduled #{jobs_scheduled} stat fetch jobs for faction #{faction.name}")
+ end
+end
diff --git a/app/jobs/backfill_ranked_wars_job.rb b/app/jobs/backfill_ranked_wars_job.rb
new file mode 100644
index 0000000..5ae25f9
--- /dev/null
+++ b/app/jobs/backfill_ranked_wars_job.rb
@@ -0,0 +1,75 @@
+class BackfillRankedWarsJob < FactionApiJob
+ 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)
+ return unless faction
+
+ 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 = []
+
+ 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"]
+ )
+
+ if ranked_war.completed? && ranked_war.our_members.empty?
+ wars_needing_reports << war_data["id"]
+ end
+
+ ranked_war.save!
+ end
+
+ wars_needing_reports.each do |torn_war_id|
+ fetch_war_report(faction, api_key, torn_war_id)
+ end
+
+ Rails.logger.info("[BackfillRankedWarsJob] Backfilled #{wars.size} wars for faction #{faction.name} (#{wars_needing_reports.size} reports fetched)")
+ end
+
+ private
+
+ def fetch_war_report(faction, 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
+end
diff --git a/app/jobs/backfill_single_stat_job.rb b/app/jobs/backfill_single_stat_job.rb
new file mode 100644
index 0000000..d4d1fdb
--- /dev/null
+++ b/app/jobs/backfill_single_stat_job.rb
@@ -0,0 +1,53 @@
+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
+
+ # 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})")
+ end
+
+ def perform(user_id, date_str, faction_id:, batch: 1, api_key: nil)
+ user = User.find(user_id)
+ date = Date.parse(date_str)
+
+ 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?
+
+ 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
+ # 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")
+ end
+
+ private
+
+ 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::NotFoundError, TornApi::InvalidKeyError => e
+ # 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
+
+ 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/backfill_user_stats_job.rb b/app/jobs/backfill_user_stats_job.rb
new file mode 100644
index 0000000..f1cf002
--- /dev/null
+++ b/app/jobs/backfill_user_stats_job.rb
@@ -0,0 +1,26 @@
+class BackfillUserStatsJob < ApplicationJob
+ queue_as :faction
+
+ def perform(user_id, start_date, end_date, api_key: nil)
+ user = User.find(user_id)
+ api_key ||= user.faction&.torn_api_key&.key
+ return Rails.logger.warn("BackfillUserStatsJob: No API key for #{user.name}, skipping") unless api_key
+
+ 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, api_key: api_key)
+ jobs_scheduled += 1
+ end
+
+ Rails.logger.info("BackfillUserStatsJob: Scheduled #{jobs_scheduled} jobs for #{user.name}")
+ end
+end
diff --git a/app/jobs/clear_backfill_status_job.rb b/app/jobs/clear_backfill_status_job.rb
new file mode 100644
index 0000000..e35b03c
--- /dev/null
+++ b/app/jobs/clear_backfill_status_job.rb
@@ -0,0 +1,10 @@
+class ClearBackfillStatusJob < ApplicationJob
+ queue_as :faction
+
+ 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/concerns/gap_backfill.rb b/app/jobs/concerns/gap_backfill.rb
new file mode 100644
index 0000000..50eb9f7
--- /dev/null
+++ b/app/jobs/concerns/gap_backfill.rb
@@ -0,0 +1,25 @@
+# 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)
+ window_start = PersonalStatSnapshot.tracking_start_date
+ window_end = PersonalStatSnapshot.tracking_end_date
+ existing = user.personal_stat_snapshots.pluck(:date).to_set
+ partial = user.personal_stat_snapshots.partial.where(date: window_start..window_end).pluck(:date)
+ yesterday = Date.current.yesterday
+
+ 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?
+
+ 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
+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
new file mode 100644
index 0000000..a53b512
--- /dev/null
+++ b/app/jobs/daily/data_retention_cleanup_job.rb
@@ -0,0 +1,53 @@
+module Daily
+ class DataRetentionCleanupJob < ApplicationJob
+ queue_as :default
+
+ 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
+ cleanup_stale_factions
+ end
+
+ private
+
+ def cleanup_old_sessions
+ cutoff = SESSION_RETENTION_DAYS.days.ago
+ deleted_count = Session.where("created_at < ?", cutoff).delete_all
+
+ Rails.logger.info "DataRetentionCleanupJob: Deleted #{deleted_count} sessions older than #{SESSION_RETENTION_DAYS} days"
+ end
+
+ def cleanup_old_api_calls
+ cutoff = API_CALL_RETENTION_DAYS.days.ago
+ 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"
+ 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
+
+ # 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/jobs/daily/faction_member_sync_job.rb b/app/jobs/daily/faction_member_sync_job.rb
new file mode 100644
index 0000000..8ba41ca
--- /dev/null
+++ b/app/jobs/daily/faction_member_sync_job.rb
@@ -0,0 +1,70 @@
+module Daily
+ class FactionMemberSyncJob < ApplicationJob
+ include GapBackfill
+
+ 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 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/factionhof_members_job.rb b/app/jobs/daily/factionhof_members_job.rb
deleted file mode 100644
index e5db7c9..0000000
--- a/app/jobs/daily/factionhof_members_job.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-class Daily::FactionhofMembersJob < ApplicationJob
- queue_as :default
-
- FACTION_BATCH_SIZE = 50
- TOP_FACTIONS_COUNT = 4000
-
- def perform
- api_key = Rails.application.credentials.dig(:bram, :api_key)
- # 20 calls
- top_factions = (0...TOP_FACTIONS_COUNT).step(100).flat_map do |offset|
- TornApi::Torn::Factionhof.new(api_key, offset:).fetch
- 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
- 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..d9ce191
--- /dev/null
+++ b/app/jobs/daily/hof_member_sync_job.rb
@@ -0,0 +1,34 @@
+module Daily
+ class HofMemberSyncJob < ApplicationJob
+ include GapBackfill
+
+ 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 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 c81294b..0000000
--- a/app/jobs/daily/personal_stats_job.rb
+++ /dev/null
@@ -1,17 +0,0 @@
-module Daily
- class PersonalStatsJob < ApplicationJob
- queue_as :default
-
- def perform(*args)
- batch_size = 60
- delay_time = 30.minutes
-
- TornUser.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
- end
- end
- end
- end
-end
diff --git a/app/jobs/daily/ranked_war_refresh_job.rb b/app/jobs/daily/ranked_war_refresh_job.rb
new file mode 100644
index 0000000..e8cb2db
--- /dev/null
+++ b/app/jobs/daily/ranked_war_refresh_job.rb
@@ -0,0 +1,11 @@
+module Daily
+ class RankedWarRefreshJob < ApplicationJob
+ queue_as :default
+
+ def perform
+ Faction.where(setup_completed: true).joins(:api_keys).where(api_keys: { type: "ApiKey::Torn" }).distinct.find_each do |faction|
+ BackfillRankedWarsJob.perform_later(faction.id)
+ end
+ end
+ end
+end
diff --git a/app/jobs/daily/stock_dividend_job.rb b/app/jobs/daily/stock_dividend_job.rb
index 88e3926..63f6fe0 100644
--- a/app/jobs/daily/stock_dividend_job.rb
+++ b/app/jobs/daily/stock_dividend_job.rb
@@ -1,9 +1,7 @@
module Daily
- class StockDividendJob < ApplicationJob
- queue_as :default
-
+ class StockDividendJob < AdminApiJob
def perform(*args)
- stocks = TornApi::Torn::Stocks.new(api_key).fetch
+ stocks = TornApi::Torn::Stocks.new(AdminCredentials.api_key).fetch
items = Torn::Item.money_makers.index_by(&:torn_id)
stocks.each do |fetched_stock|
@@ -31,7 +29,7 @@ def calculate_dividend_value(description, item_market_price)
end
def average_market_price
- market_data = TornApi::Market.new(api_key).fetch
+ market_data = TornApi::Market.new(AdminCredentials.api_key).fetch
return 0 if market_data.blank?
costs = market_data.values.map { |point_data| point_data["cost"] }
@@ -39,9 +37,5 @@ def average_market_price
costs.sum / costs.size
end
-
- def api_key
- api_key ||=Rails.application.credentials.dig(:bram, :api_key)
- end
end
end
diff --git a/app/jobs/daily/validate_faction_keys_job.rb b/app/jobs/daily/validate_faction_keys_job.rb
new file mode 100644
index 0000000..499c5c4
--- /dev/null
+++ b/app/jobs/daily/validate_faction_keys_job.rb
@@ -0,0 +1,46 @@
+module Daily
+ class ValidateFactionKeysJob < ApplicationJob
+ queue_as :default
+
+ def perform
+ validate_faction_keys
+ validate_user_keys
+ end
+
+ private
+
+ def validate_faction_keys
+ ApiKey::Torn.where.not(faction_id: nil).includes(:faction).find_each do |api_key|
+ 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, 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
+ api_key.faction.handle_invalid_api_key!
+ 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
+ sleep 1
+ end
+ end
+
+ def validate_user_keys
+ ApiKey::Torn.where.not(user_id: nil).includes(:user).find_each do |api_key|
+ TornApi::Key::Info.new(api_key.key).fetch
+ rescue TornApi::InvalidKeyError
+ Rails.logger.info("[ValidateKeys] User #{api_key.user.name} [#{api_key.user.torn_id}]: invalid key, clearing")
+ api_key.destroy!
+ rescue TornApi::ApiError => e
+ Rails.logger.warn("[ValidateKeys] User #{api_key.user.name} [#{api_key.user.torn_id}]: #{e.message}")
+ ensure
+ sleep 1
+ end
+ end
+ end
+end
diff --git a/app/jobs/daily/xanax_payments_job.rb b/app/jobs/daily/xanax_payments_job.rb
new file mode 100644
index 0000000..e757fef
--- /dev/null
+++ b/app/jobs/daily/xanax_payments_job.rb
@@ -0,0 +1,64 @@
+module Daily
+ class XanaxPaymentsJob < AdminApiJob
+ PAYMENT_RECIPIENT_TORN_ID = 2728237
+
+ def perform
+ fetch_and_process_payments
+ end
+
+ private
+
+ def fetch_and_process_payments
+ log_entries = TornApi::User::Log.new(AdminCredentials.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
+
+ WEEKS_PER_XANAX = 2
+
+ def process_payment(entry)
+ sender = find_or_create_sender(entry.sender_torn_id)
+ recipient = User.find_by!(torn_id: PAYMENT_RECIPIENT_TORN_ID)
+ weeks = entry.xanax_quantity * WEEKS_PER_XANAX
+
+ XanaxPayment.create!(
+ recipient: recipient,
+ sender: sender,
+ log_id: entry.id,
+ xanax_amount: entry.xanax_quantity,
+ weeks_granted: weeks,
+ processed_at: Time.at(entry.timestamp)
+ )
+
+ sender.extend_subscription!(weeks)
+
+ Rails.logger.info "Xanax payment processed: #{entry.xanax_quantity} xanax (#{weeks} weeks) 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(AdminCredentials.api_key, torn_id).fetch
+
+ User.create!(
+ torn_id: profile.id,
+ name: profile.name,
+ level: profile.level
+ )
+ 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
+ )
+ end
+ end
+end
diff --git a/app/jobs/faction_api_job.rb b/app/jobs/faction_api_job.rb
new file mode 100644
index 0000000..f23ea36
--- /dev/null
+++ b/app/jobs/faction_api_job.rb
@@ -0,0 +1,3 @@
+# Transitional alias — all API discipline lives in TornApiJob.
+class FactionApiJob < TornApiJob
+end
diff --git a/app/jobs/faction_members_job.rb b/app/jobs/faction_members_job.rb
deleted file mode 100644
index e62b7d6..0000000
--- a/app/jobs/faction_members_job.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-class FactionMembersJob < ApplicationJob
- queue_as :default
-
- def perform(batch_ids)
- api_key = Rails.application.credentials.dig(:bram, :api_key)
-
- # 50 api calls and generating max 5000 members
- all_members = batch_ids.flat_map do |faction_id|
- TornApi::Faction::Members.new(api_key, faction_id).fetch
- end
-
- all_members.each do |member|
- TornUser.find_or_create_by(torn_id: member.id) do |user|
- user.name = member.name
- user.level = member.level
- end
- end
- end
-end
diff --git a/app/jobs/factionhof_members_job.rb b/app/jobs/factionhof_members_job.rb
new file mode 100644
index 0000000..8596b2f
--- /dev/null
+++ b/app/jobs/factionhof_members_job.rb
@@ -0,0 +1,13 @@
+class FactionhofMembersJob < ApplicationJob
+ queue_as :default
+
+ TOP_FACTIONS_COUNT = 4000
+
+ def perform
+ (0...TOP_FACTIONS_COUNT).step(100).each do |offset|
+ FetchFactionHofPageJob.perform_later(offset)
+ end
+
+ Rails.logger.info "FactionhofMembersJob: Scheduled #{TOP_FACTIONS_COUNT / 100} HOF page fetch jobs"
+ 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..52323a2
--- /dev/null
+++ b/app/jobs/fetch_armory_news_job.rb
@@ -0,0 +1,45 @@
+class FetchArmoryNewsJob < FactionApiJob
+ 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)
+ 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/jobs/fetch_faction_hof_page_job.rb b/app/jobs/fetch_faction_hof_page_job.rb
new file mode 100644
index 0000000..aa00aef
--- /dev/null
+++ b/app/jobs/fetch_faction_hof_page_job.rb
@@ -0,0 +1,11 @@
+class FetchFactionHofPageJob < AdminApiJob
+ def perform(offset)
+ factions = TornApi::Torn::Factionhof.new(AdminCredentials.api_key, offset:).fetch
+
+ 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..d1581b7
--- /dev/null
+++ b/app/jobs/fetch_faction_members_job.rb
@@ -0,0 +1,16 @@
+class FetchFactionMembersJob < AdminApiJob
+ def perform(faction_torn_id)
+ members = TornApi::Faction::Members.new(AdminCredentials.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
+ 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_member_activity_job.rb b/app/jobs/fetch_member_activity_job.rb
new file mode 100644
index 0000000..f9e470f
--- /dev/null
+++ b/app/jobs/fetch_member_activity_job.rb
@@ -0,0 +1,43 @@
+class FetchMemberActivityJob < FactionApiJob
+ 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)
+ return unless faction
+
+ api_key = faction.torn_api_key&.key
+ return unless api_key
+
+ members = TornApi::Faction::Members.new(api_key, faction.torn_id).fetch
+ now = Time.current
+
+ snapshots = members.map do |member|
+ status = if member.last_action_status.in?(%w[Online Idle])
+ member.last_action_status
+ elsif member.last_action_timestamp && (now.to_i - member.last_action_timestamp) < 900
+ "Online"
+ else
+ "Offline"
+ end
+
+ {
+ faction_id: faction.id,
+ torn_member_id: member.id,
+ member_name: member.name,
+ recorded_at: now,
+ hour_utc: now.hour,
+ day_of_week: now.wday,
+ status: status,
+ created_at: now,
+ updated_at: now
+ }
+ 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
+end
diff --git a/app/jobs/fetch_personal_stats_job.rb b/app/jobs/fetch_personal_stats_job.rb
index 8f4e0d0..554ae91 100644
--- a/app/jobs/fetch_personal_stats_job.rb
+++ b/app/jobs/fetch_personal_stats_job.rb
@@ -1,17 +1,63 @@
-class FetchPersonalStatsJob < ApplicationJob
- queue_as :default
+class FetchPersonalStatsJob < FactionApiJob
+ queue_with_priority 50
+ limits_concurrency to: 1, key: ->(user, api_key:, **) { api_key }, group: CONCURRENCY_GROUP
- MIN_STAT_ENHANCER = 200
+ MAX_RETRIES = 3
- 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, 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, 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")
+ 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(
+ 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
- def api_key
- Rails.application.credentials.dig(:bram, :api_key)
+ 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(
+ api_key,
+ user.torn_id,
+ timestamp: stats_date.end_of_day.to_i,
+ stat_batch: stat_batch
+ ).fetch
+ 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/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/jobs/member_activity_poll_job.rb b/app/jobs/member_activity_poll_job.rb
new file mode 100644
index 0000000..6975e98
--- /dev/null
+++ b/app/jobs/member_activity_poll_job.rb
@@ -0,0 +1,11 @@
+class MemberActivityPollJob < ApplicationJob
+ queue_as :default
+
+ def perform
+ Faction.where(setup_completed: true).find_each do |faction|
+ next unless faction.torn_api_key.present?
+
+ FetchMemberActivityJob.perform_later(faction.id)
+ end
+ end
+end
diff --git a/app/jobs/public_war_polling_job.rb b/app/jobs/public_war_polling_job.rb
new file mode 100644
index 0000000..e48c483
--- /dev/null
+++ b/app/jobs/public_war_polling_job.rb
@@ -0,0 +1,132 @@
+class PublicWarPollingJob < ApplicationJob
+ POLL_INTERVAL = 3.seconds
+ CACHE_TTL = 30.seconds
+
+ DESTINATION_PATTERN = /(?:Traveling to |Returning to Torn from |In )(.+)/i
+
+ queue_as :war
+ limits_concurrency to: 1, key: ->(lobby_id) { "public_war_polling_#{lobby_id}" }
+
+ def perform(lobby_id)
+ lobby = PublicWarLobby.find_by(id: lobby_id)
+ return unless lobby
+
+ api_key = Rails.cache.read(lobby.api_key_cache_key)
+ unless api_key
+ lobby.terminate!
+ return
+ end
+
+ @previous_data = Rails.cache.read(lobby.war_cache_key) || {}
+
+ war_data = build_war_data(api_key, lobby)
+ Rails.cache.write(lobby.war_cache_key, war_data, expires_in: CACHE_TTL)
+
+ PublicWarPollingJob.set(wait: POLL_INTERVAL).perform_later(lobby_id)
+ rescue TornApi::InvalidKeyError
+ Rails.logger.warn("PublicWarPollingJob: Invalid API key for lobby #{lobby_id}, terminating")
+ lobby&.terminate!
+ rescue StandardError => e
+ Rails.logger.error("PublicWarPollingJob: Error for lobby #{lobby_id}: #{e.class} - #{e.message}")
+ PublicWarPollingJob.set(wait: POLL_INTERVAL).perform_later(lobby_id) if lobby&.persisted?
+ end
+
+ private
+
+ def build_war_data(api_key, lobby)
+ enemy_members = fetch_enemy_members(api_key, lobby.faction_torn_id)
+ spy_stats = Rails.cache.read(lobby.spy_stats_cache_key) || {}
+
+ members = enemy_members.transform_values do |member|
+ data = build_member_data(member)
+ merge_spy_stats(data, spy_stats)
+ end
+
+ {
+ faction_name: lobby.faction_name,
+ opponent_faction_name: lobby.opponent_faction_name,
+ members: members,
+ cached_at: Time.current.iso8601
+ }
+ end
+
+ def fetch_enemy_members(api_key, faction_torn_id)
+ members = TornApi::Faction::Members.new(api_key, faction_torn_id).fetch
+ members.index_by(&:id)
+ end
+
+ def build_member_data(member)
+ {
+ torn_id: member.id,
+ name: member.name,
+ level: member.level,
+ status: build_status(member),
+ last_action: build_last_action(member)
+ }
+ end
+
+ def build_last_action(member)
+ {
+ status: member.last_action_status,
+ timestamp: member.last_action_timestamp,
+ relative: member.last_action_relative
+ }
+ end
+
+ def build_status(member)
+ state = member.status_state
+ if state && state != "Okay"
+ status = { state: state }
+ status[:description] = member.status_description if member.status_description.present?
+ status[:until] = Time.at(member.status_until.to_i).iso8601 if member.status_until.to_i > 0
+
+ if state == "Traveling"
+ status[:plane_type] = member.plane_image_type
+ status[:destination] = extract_destination(member.status_description)
+ status[:travel_started_at] = resolve_travel_started_at(member)
+ end
+
+ status
+ else
+ { state: "Okay" }
+ end
+ end
+
+ def extract_destination(description)
+ return nil unless description.present?
+
+ match = description.match(DESTINATION_PATTERN)
+ match&.[](1)
+ end
+
+ def merge_spy_stats(member_data, spy_stats)
+ torn_id = member_data[:torn_id].to_s
+ stats = spy_stats[torn_id] || spy_stats[member_data[:torn_id]]
+
+ if stats
+ member_data[:stats] = stats
+ end
+
+ member_data
+ end
+
+ def resolve_travel_started_at(member)
+ previous_members = @previous_data[:members] || @previous_data["members"] || {}
+ member_key = member.id.to_s
+ previous_member = previous_members[member_key] || previous_members[member.id]
+
+ unless previous_member
+ return Time.current.iso8601
+ end
+
+ previous_status = previous_member[:status] || previous_member["status"] || {}
+ previous_state = previous_status[:state] || previous_status["state"]
+ previous_started = previous_status[:travel_started_at] || previous_status["travel_started_at"]
+
+ if previous_state == "Traveling" && previous_started.present?
+ previous_started
+ else
+ Time.current.iso8601
+ end
+ end
+end
diff --git a/app/jobs/recon/collect_training_sample_job.rb b/app/jobs/recon/collect_training_sample_job.rb
new file mode 100644
index 0000000..2cc54ab
--- /dev/null
+++ b/app/jobs/recon/collect_training_sample_job.rb
@@ -0,0 +1,40 @@
+class Recon::CollectTrainingSampleJob < ApplicationJob
+ queue_as :default
+ limits_concurrency to: 1, key: "recon_collect", group: "ReconApiCalls"
+
+ BATCH_SIZE = 10
+
+ def perform(player_id:, spied_at:)
+ spied_at_date = Date.parse(spied_at.to_s)
+
+ sample = Recon::TrainingSample.find_by(player_id: player_id, spied_at: spied_at_date)
+ return unless sample
+
+ api_key = AdminCredentials.api_key
+ return if api_key.blank?
+
+ timestamp = spied_at_date.end_of_day.to_i
+
+ personalstats = fetch_personalstats(api_key, player_id, timestamp)
+ profile = Recon::TornApi::Profile.new(api_key, player_id).fetch
+
+ features = Recon::FeatureSet.build(personalstats: personalstats, profile: profile)
+
+ 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
+
+ private
+
+ def fetch_personalstats(api_key, player_id, timestamp)
+ batches = Recon::FeatureSet::API_STAT_NAMES.each_slice(BATCH_SIZE).to_a
+
+ batches.reduce({}) do |result, batch|
+ stats = Recon::TornApi::PersonalStats.new(
+ api_key, player_id, stats: batch, timestamp: timestamp
+ ).fetch
+ result.merge(stats)
+ end
+ end
+end
diff --git a/app/jobs/sync_faction_members_job.rb b/app/jobs/sync_faction_members_job.rb
new file mode 100644
index 0000000..7f0f287
--- /dev/null
+++ b/app/jobs/sync_faction_members_job.rb
@@ -0,0 +1,45 @@
+class SyncFactionMembersJob < AdminApiJob
+ def perform(faction_id)
+ faction = Faction.find(faction_id)
+ api_key = AdminCredentials.api_key
+ members = TornApi::Faction::Members.new(api_key, faction.torn_id).fetch
+
+ 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) if new_member
+ end
+
+ Rails.logger.info "SyncFactionMembersJob: Synced #{members.size} members for faction #{faction.name} [#{faction.torn_id}]"
+ rescue TornApi::ApiError => e
+ Rails.logger.error "SyncFactionMembersJob: Failed to sync faction #{faction.torn_id}: #{e.message}"
+ end
+
+ private
+
+ def schedule_backfill(user)
+ 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)
+ 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/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/jobs/torn_api_job.rb b/app/jobs/torn_api_job.rb
new file mode 100644
index 0000000..5d1fa1a
--- /dev/null
+++ b/app/jobs/torn_api_job.rb
@@ -0,0 +1,22 @@
+# 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"
+
+ FACTION_KEY_LOOKUP = ->(faction_id, *, **) { Faction.find_by(id: faction_id)&.torn_api_key&.key }
+
+ # 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
+
+ around_perform do |_job, block|
+ TornApi::RateLimiter.reserving_headroom_for_live_traffic { block.call }
+ sleep(RATE_LIMIT_SLEEP)
+ end
+end
diff --git a/app/jobs/war_polling_job.rb b/app/jobs/war_polling_job.rb
new file mode 100644
index 0000000..be86d71
--- /dev/null
+++ b/app/jobs/war_polling_job.rb
@@ -0,0 +1,135 @@
+class WarPollingJob < ApplicationJob
+ POLL_INTERVAL = 6.seconds
+ CACHE_TTL = 30.seconds
+
+ DESTINATION_PATTERN = /(?:Traveling to |Returning to Torn from |In )(.+)/i
+
+ queue_as :war
+ limits_concurrency to: 1, key: ->(faction_id) { "war_polling_faction_#{faction_id}" }
+
+ def perform(faction_id)
+ faction = Faction.find_by(id: faction_id)
+ return unless faction&.war_polling_active?
+
+ war = faction.current_war
+ unless war
+ faction.update!(war_polling_active: false)
+ return
+ end
+
+ unless faction.torn_api_key.present?
+ Rails.logger.warn("WarPollingJob: No API key for faction #{faction_id}, stopping polling")
+ faction.update!(war_polling_active: false)
+ return
+ end
+
+ @previous_data = Rails.cache.read(faction.war_cache_key) || {}
+
+ war_data = build_war_data(faction, war)
+ Rails.cache.write(faction.war_cache_key, war_data, expires_in: CACHE_TTL)
+
+ WarPollingJob.set(wait: POLL_INTERVAL).perform_later(faction_id)
+ rescue StandardError => e
+ Rails.logger.error("WarPollingJob: Error for faction #{faction_id}: #{e.class} - #{e.message}")
+ WarPollingJob.set(wait: POLL_INTERVAL).perform_later(faction_id) if faction&.war_polling_active?
+ end
+
+ private
+
+ def build_war_data(faction, war)
+ enemy_members = fetch_enemy_members(faction.torn_api_key.key, war.opponent_faction_id)
+ spy_reports = faction.spy_reports.where(torn_id: enemy_members.keys).index_by(&:torn_id)
+
+ members = enemy_members.transform_values do |member|
+ spy = spy_reports[member.id]
+ build_member_data(member, spy)
+ end
+
+ {
+ enemy_faction_id: war.opponent_faction_id,
+ enemy_faction_name: war.opponent_faction_name,
+ our_score: war.our_score,
+ their_score: war.their_score,
+ target_score: war.target_score,
+ started_at: war.started_at.iso8601,
+ members: members,
+ cached_at: Time.current.iso8601
+ }
+ end
+
+ def fetch_enemy_members(api_key, enemy_faction_id)
+ members = TornApi::Faction::Members.new(api_key, enemy_faction_id).fetch
+ members.index_by(&:id)
+ end
+
+ def build_member_data(member, spy)
+ data = {
+ torn_id: member.id,
+ name: member.name,
+ level: member.level,
+ status: build_status(member),
+ last_action: build_last_action(member)
+ }
+
+ if spy
+ data[:stats] = spy.stats_hash
+ data[:stats_timestamp] = spy.spied_at&.iso8601
+ end
+
+ data
+ end
+
+ def build_last_action(member)
+ {
+ status: member.last_action_status,
+ timestamp: member.last_action_timestamp,
+ relative: member.last_action_relative
+ }
+ end
+
+ def build_status(member)
+ state = member.status_state
+ if state && state != "Okay"
+ status = { state: state }
+ status[:description] = member.status_description if member.status_description.present?
+ status[:until] = Time.at(member.status_until.to_i).iso8601 if member.status_until.to_i > 0
+
+ if state == "Traveling"
+ status[:plane_type] = member.plane_image_type
+ status[:destination] = extract_destination(member.status_description)
+ status[:travel_started_at] = resolve_travel_started_at(member)
+ end
+
+ status
+ else
+ { state: "Okay" }
+ end
+ end
+
+ def extract_destination(description)
+ return nil unless description.present?
+
+ match = description.match(DESTINATION_PATTERN)
+ match&.[](1)
+ end
+
+ def resolve_travel_started_at(member)
+ previous_members = @previous_data[:members] || @previous_data["members"] || {}
+ member_key = member.id.to_s
+ previous_member = previous_members[member_key] || previous_members[member.id]
+
+ unless previous_member
+ return Time.current.iso8601
+ end
+
+ previous_status = previous_member[:status] || previous_member["status"] || {}
+ previous_state = previous_status[:state] || previous_status["state"]
+ previous_started = previous_status[:travel_started_at] || previous_status["travel_started_at"]
+
+ if previous_state == "Traveling" && previous_started.present?
+ previous_started
+ else
+ Time.current.iso8601
+ end
+ end
+end
diff --git a/app/models/admin_credentials.rb b/app/models/admin_credentials.rb
new file mode 100644
index 0000000..f6fc9e0
--- /dev/null
+++ b/app/models/admin_credentials.rb
@@ -0,0 +1,5 @@
+module AdminCredentials
+ def self.api_key
+ Rails.application.credentials.dig(:admin, :api_key)
+ end
+end
diff --git a/app/models/api_call.rb b/app/models/api_call.rb
new file mode 100644
index 0000000..446cbe1
--- /dev/null
+++ b/app/models/api_call.rb
@@ -0,0 +1,41 @@
+class ApiCall < ApplicationRecord
+ MINUTE_BUCKET = Arel.sql("strftime('%Y-%m-%d %H:%M', created_at)")
+
+ belongs_to :user
+ belongs_to :faction, optional: true
+
+ validates :endpoint, presence: true
+ validates :status, presence: true
+
+ scope :recent, -> { order(created_at: :desc) }
+ scope :today, -> { where("created_at >= ?", Time.current.beginning_of_day) }
+ scope :last_24_hours, -> { where("created_at >= ?", 24.hours.ago) }
+ scope :successful, -> { where(status: "success") }
+ scope :failed, -> { where(status: "error") }
+
+ after_create :broadcast_api_call
+
+ def self.peak_rate_for(user, scope: :all)
+ calls = scope == :today ? user.api_calls.today : user.api_calls
+
+ minute, count = calls
+ .group(MINUTE_BUCKET)
+ .order(Arel.sql("COUNT(*) DESC"))
+ .limit(1)
+ .pick(MINUTE_BUCKET, Arel.sql("COUNT(*)"))
+
+ { rate: count || 0, minute_start: minute ? Time.zone.parse(minute) : nil }
+ end
+
+ private
+
+ def broadcast_api_call
+ ApiRateMonitorChannel.broadcast_to(user, {
+ id: id,
+ endpoint: endpoint,
+ status: status,
+ response_time: response_time,
+ created_at: created_at.iso8601
+ })
+ end
+end
diff --git a/app/models/api_key.rb b/app/models/api_key.rb
new file mode 100644
index 0000000..90419e4
--- /dev/null
+++ b/app/models/api_key.rb
@@ -0,0 +1,18 @@
+class ApiKey < ApplicationRecord
+ belongs_to :faction, optional: true
+ belongs_to :user, optional: true
+
+ validates :key, presence: true
+ validates :type, presence: true
+ validates :type, uniqueness: { scope: :faction_id }, if: -> { faction_id.present? }
+ validates :type, uniqueness: { scope: :user_id }, if: -> { user_id.present? }
+ validate :faction_or_user_present
+
+ private
+
+ def faction_or_user_present
+ if faction_id.blank? && user_id.blank?
+ errors.add(:base, "must belong to either a faction or a user")
+ end
+ end
+end
diff --git a/app/models/api_key/torn.rb b/app/models/api_key/torn.rb
new file mode 100644
index 0000000..be0789c
--- /dev/null
+++ b/app/models/api_key/torn.rb
@@ -0,0 +1,2 @@
+class ApiKey::Torn < ApiKey
+end
diff --git a/app/models/api_key/tornstats.rb b/app/models/api_key/tornstats.rb
new file mode 100644
index 0000000..ecf50f7
--- /dev/null
+++ b/app/models/api_key/tornstats.rb
@@ -0,0 +1,2 @@
+class ApiKey::Tornstats < ApiKey
+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/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..078e67d
--- /dev/null
+++ b/app/models/discord/error_notifier.rb
@@ -0,0 +1,60 @@
+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 },
+ { name: "Severity", value: severity.to_s, inline: true },
+ { name: "Environment", value: Rails.env, inline: true }
+ ]
+
+ trace = app_backtrace(error)
+ if trace.present?
+ fields << { name: "Stacktrace", value: "```\n#{trace}\n```", inline: false }
+ end
+
+ Discord::Notifier.notify(
+ webhook_key: :error_webhook_url,
+ embed: {
+ title: error.class.to_s,
+ description: "```#{error.message.truncate(1000)}```",
+ color: 15_158_332,
+ fields: fields,
+ footer: { text: "TornManager Error Reporter" },
+ timestamp: Time.current.iso8601
+ }
+ )
+ end
+
+ 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
+
+ root = Rails.root.to_s
+ app_lines = error.backtrace
+ .select { |line| line.start_with?(root) }
+ .map { |line| line.delete_prefix("#{root}/") }
+ .first(APP_TRACE_LIMIT)
+
+ return nil if app_lines.empty?
+
+ app_lines.join("\n").truncate(900)
+ end
+ end
+end
diff --git a/app/models/discord/notifier.rb b/app/models/discord/notifier.rb
new file mode 100644
index 0000000..6ff4a53
--- /dev/null
+++ b/app/models/discord/notifier.rb
@@ -0,0 +1,59 @@
+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
+
+ 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/app/models/discord/verifier.rb b/app/models/discord/verifier.rb
new file mode 100644
index 0000000..6d2aa0a
--- /dev/null
+++ b/app/models/discord/verifier.rb
@@ -0,0 +1,128 @@
+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
+ notify_warning("Cannot set nickname for #{@event.user.name}: #{e.message}")
+ end
+
+ begin
+ member.add_role(@verified_role_id.to_i)
+ rescue => e
+ 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" \
+ "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/faction.rb b/app/models/faction.rb
new file mode 100644
index 0000000..444680b
--- /dev/null
+++ b/app/models/faction.rb
@@ -0,0 +1,176 @@
+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 :api_keys, dependent: :destroy
+ has_one :torn_api_key, class_name: "ApiKey::Torn"
+ 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
+
+ # 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 }
+ validates :energy_refill_target, presence: true, numericality: { greater_than_or_equal_to: 0 }
+ validates :nerve_refill_target, presence: true, numericality: { greater_than_or_equal_to: 0 }
+
+ def to_param
+ torn_id.to_s
+ end
+
+ 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
+
+ def current_war
+ ranked_wars.ongoing.order(started_at: :desc).first
+ end
+
+ def start_war_polling!
+ update!(war_polling_active: true)
+ WarPollingJob.perform_later(id)
+ end
+
+ def stop_war_polling!
+ update!(war_polling_active: false)
+ Rails.cache.delete(war_cache_key)
+ end
+
+ def war_cache_key
+ "faction:#{id}:war_data"
+ end
+
+ def leadership?(user)
+ return false unless user
+ user.faction_id == id && user.leadership_access?
+ end
+
+ def import_spy_report(spy)
+ report = 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!
+ end
+
+ def handle_invalid_api_key!
+ transaction do
+ 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")
+ notify_key_invalidated
+ end
+
+ def delete_all_data!
+ transaction do
+ stop_war_polling! if war_polling_active?
+ clear_backfill_status! if backfill_in_progress?
+
+ user_ids = users.pluck(:id)
+ 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
+ faction_setting&.destroy!
+ update!(setup_completed: false)
+
+ cancel_background_jobs(user_ids)
+ end
+ end
+
+ private
+
+ def cancel_background_jobs(user_ids)
+ SolidQueue::Job
+ .where(finished_at: nil)
+ .where(class_name: %w[
+ BackfillArmoryNewsJob
+ BackfillPersonalStatsJob
+ BackfillRankedWarsJob
+ ClearBackfillStatusJob
+ FetchArmoryNewsJob
+ FetchMemberActivityJob
+ WarPollingJob
+ ])
+ .where("arguments LIKE ?", "%\"arguments\":[#{id},%")
+ .destroy_all
+
+ if user_ids.any?
+ SolidQueue::Job
+ .where(finished_at: nil)
+ .where(class_name: %w[BackfillSingleStatJob BackfillUserStatsJob])
+ .where(user_ids.map { |uid| "arguments LIKE '%\"arguments\":[#{uid},%'" }.join(" OR "))
+ .destroy_all
+ end
+
+ SolidQueue::Semaphore.where(key: "war_polling_faction_#{id}").delete_all
+ 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/faction_setting.rb b/app/models/faction_setting.rb
new file mode 100644
index 0000000..0480b4e
--- /dev/null
+++ b/app/models/faction_setting.rb
@@ -0,0 +1,7 @@
+class FactionSetting < ApplicationRecord
+ belongs_to :faction
+
+ validates :faction_id, uniqueness: true
+ validates :payout_faction_cut, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
+ validates :payout_assist_value, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }
+end
diff --git a/app/models/faction_subscription_grant.rb b/app/models/faction_subscription_grant.rb
new file mode 100644
index 0000000..fd571b3
--- /dev/null
+++ b/app/models/faction_subscription_grant.rb
@@ -0,0 +1,13 @@
+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 :torn_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/member_activity_snapshot.rb b/app/models/member_activity_snapshot.rb
new file mode 100644
index 0000000..2f9035e
--- /dev/null
+++ b/app/models/member_activity_snapshot.rb
@@ -0,0 +1,58 @@
+class MemberActivitySnapshot < ApplicationRecord
+ belongs_to :faction
+
+ validates :torn_member_id, :member_name, :recorded_at, :status, :hour_utc, :day_of_week, presence: true
+
+ scope :recent, ->(days = 7) { where(recorded_at: days.days.ago..) }
+ scope :active, -> { where(status: %w[Online Idle]) }
+
+ def self.calendar_heatmap(faction_id, start_date, end_date)
+ where(faction_id: faction_id)
+ .where(recorded_at: start_date.beginning_of_day..end_date.end_of_day)
+ .active
+ .group(Arel.sql("date(recorded_at)"), :hour_utc)
+ .count
+ end
+
+ def self.member_summary(faction_id, days: 7)
+ where(faction_id: faction_id)
+ .recent(days)
+ .group(:torn_member_id, :member_name)
+ .select(
+ "torn_member_id",
+ "member_name",
+ "COUNT(*) as total_snapshots",
+ "SUM(CASE WHEN status = 'Online' THEN 1 ELSE 0 END) as online_count",
+ "SUM(CASE WHEN status IN ('Online', 'Idle') THEN 1 ELSE 0 END) as active_count"
+ )
+ .order(Arel.sql("SUM(CASE WHEN status = 'Online' THEN 1 ELSE 0 END) DESC"))
+ end
+
+ def self.member_hourly_summary(faction_id, days: 7)
+ where(faction_id: faction_id)
+ .recent(days)
+ .group(:torn_member_id, :hour_utc)
+ .select(
+ "torn_member_id",
+ "hour_utc",
+ "SUM(CASE WHEN status IN ('Online', 'Idle') THEN 1 ELSE 0 END) as active_count",
+ "COUNT(*) as total_count"
+ )
+ .each_with_object({}) do |row, hash|
+ hash[row.torn_member_id] ||= {}
+ hash[row.torn_member_id][row.hour_utc] = row.total_count > 0 ? (row.active_count.to_f / row.total_count).round(2) : 0
+ end
+ end
+
+ def self.peak_hour(faction_id, days: 7)
+ where(faction_id: faction_id)
+ .recent(days)
+ .active
+ .group(:hour_utc)
+ .order("count_all DESC")
+ .limit(1)
+ .count
+ .keys
+ .first
+ end
+end
diff --git a/app/models/personal_stat_snapshot.rb b/app/models/personal_stat_snapshot.rb
index 3db6ae5..00e39b0 100644
--- a/app/models/personal_stat_snapshot.rb
+++ b/app/models/personal_stat_snapshot.rb
@@ -1,3 +1,51 @@
class PersonalStatSnapshot < ApplicationRecord
- belongs_to :torn_user
+ belongs_to :user
+
+ validates :date, presence: true, uniqueness: { scope: :user_id }
+
+ # 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)
+ }
+
+ def self.tracking_start_date
+ Date.new(2026, 1, 1)
+ end
+
+ def self.tracking_end_date
+ Date.current.yesterday
+ end
+
+ TRACKED_STATS_BATCH_1 = {
+ "xantaken" => :drugs_xanax,
+ "energydrinkused" => :items_used_energy_drinks,
+ "refills" => :other_refills_energy,
+ "nerverefills" => :other_refills_nerve,
+ "boostersused" => :items_used_boosters,
+ "statenhancersused" => :items_used_stat_enhancers,
+ "contractscompleted" => :missions_contracts_total,
+ "criminaloffenses" => :crimes_offenses_total,
+ "timeplayed" => :other_activity_time,
+ "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.db_columns
+ TRACKED_STATS.values
+ end
+
+ def self.stat_batches
+ [ TRACKED_STATS_BATCH_1, TRACKED_STATS_BATCH_2 ]
+ end
end
diff --git a/app/models/public_war_lobby.rb b/app/models/public_war_lobby.rb
new file mode 100644
index 0000000..477b67a
--- /dev/null
+++ b/app/models/public_war_lobby.rb
@@ -0,0 +1,60 @@
+class PublicWarLobby < ApplicationRecord
+ MAX_LOBBIES = 10
+
+ has_secure_password validations: false
+
+ validates :slug, presence: true, uniqueness: true
+ validates :faction_torn_id, presence: true
+ validates :faction_name, presence: true
+ validates :opponent_faction_name, presence: true
+ validates :created_by_name, presence: true
+ validates :created_by_torn_id, presence: true
+ validate :lobby_limit, on: :create
+
+ before_validation :generate_slug, on: :create
+
+ def to_param
+ slug
+ end
+
+ def war_cache_key
+ "public_war_lobby:#{id}:war_data"
+ end
+
+ def api_key_cache_key
+ "public_war_lobby:#{id}:api_key"
+ end
+
+ def spy_stats_cache_key
+ "public_war_lobby:#{id}:spy_stats"
+ end
+
+ def active?
+ Rails.cache.exist?(api_key_cache_key)
+ end
+
+ def password_protected?
+ password_digest.present?
+ end
+
+ def war_name
+ "#{faction_name} vs #{opponent_faction_name}"
+ end
+
+ def terminate!
+ Rails.cache.delete(war_cache_key)
+ Rails.cache.delete(api_key_cache_key)
+ Rails.cache.delete(spy_stats_cache_key)
+ destroy!
+ end
+
+ private
+
+ def generate_slug
+ self.slug ||= SecureRandom.alphanumeric(8).downcase
+ end
+
+ def lobby_limit
+ errors.add(:base, "Maximum number of public lobbies (#{MAX_LOBBIES}) reached") if PublicWarLobby.count >= MAX_LOBBIES
+ end
+end
diff --git a/app/models/ranked_war.rb b/app/models/ranked_war.rb
new file mode 100644
index 0000000..67404e2
--- /dev/null
+++ b/app/models/ranked_war.rb
@@ -0,0 +1,122 @@
+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) }
+ 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) }
+
+ def to_param
+ torn_war_id.to_s
+ end
+
+ def ongoing?
+ ended_at.nil?
+ end
+
+ def completed?
+ ended_at.present?
+ end
+
+ def scheduled?
+ ongoing? && started_at > Time.current
+ end
+
+ 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
+
+ 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
+
+ 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 calculate_reward_value!(api_key)
+ items = our_rewards&.dig("items") || []
+ return unless items.any?
+
+ item_ids = items.map { |i| i["id"] }.uniq
+ prices = fetch_market_prices(api_key, item_ids)
+
+ items.each { |item| item["market_price"] = prices[item["id"]] || 0 }
+ total = items.sum { |item| item["market_price"] * item["quantity"] }
+
+ update!(our_rewards: our_rewards.merge("items" => items), reward_estimated_value: total)
+ 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
+
+ private
+
+ def fetch_market_prices(api_key, item_ids)
+ item_ids.each_with_object({}) do |id, prices|
+ response = TornApi::Base.new(api_key).send(:get, "v2/market/#{id}/itemmarket", { limit: 1 })
+ avg_price = response.dig("itemmarket", "item", "average_price")
+ prices[id] = avg_price || 0
+ rescue TornApi::ApiError
+ prices[id] = 0
+ end
+ end
+end
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/recon/feature_set.rb b/app/models/recon/feature_set.rb
new file mode 100644
index 0000000..f8f062b
--- /dev/null
+++ b/app/models/recon/feature_set.rb
@@ -0,0 +1,101 @@
+class Recon::FeatureSet
+ # Keys are our DB column names, values are Torn API stat names (when different)
+ PERSONALSTAT_MAP = {
+ "xantaken" => "xantaken",
+ "energydrinkused" => "energydrinkused",
+ "refills" => "refills",
+ "daysbeendonator" => "daysbeendonator",
+ "statenhancersused" => "statenhancersused",
+ "boostersused" => "boostersused",
+ "lsdtaken" => "lsdtaken",
+ "revives" => "revives",
+ "exttaken" => "exttaken",
+ "victaken" => "victaken",
+ "rehabs" => "rehabs",
+ "highestbeaten" => "highestbeaten",
+ "hospital" => "hospital",
+ "jobpointsused" => "jobpointsused",
+ "trainsreceived" => "trainsreceived",
+ "attackswon" => "attackswon",
+ "awards" => "awards",
+ "useractivity" => "timeplayed",
+ "networth" => "networth"
+ }.freeze
+
+ PERSONALSTAT_KEYS = PERSONALSTAT_MAP.keys.freeze
+ API_STAT_NAMES = PERSONALSTAT_MAP.values.freeze
+
+ # Fully upgraded property happy caps (with staff)
+ # Ordered most expensive first for matching
+ PROPERTY_HAPPY = {
+ "Private Island" => 5025,
+ "Castle" => 3475,
+ "Palace" => 2550,
+ "Ranch" => 1925,
+ "Mansion" => 1725,
+ "Penthouse" => 1150,
+ "Villa" => 800,
+ "Chalet" => 725,
+ "Beach House" => 650,
+ "Detached House" => 500,
+ "Semi-Detached House" => 275,
+ "Apartment" => 188,
+ "Trailer" => 165,
+ "Shack" => 100
+ }.freeze
+
+ DEFAULT_HAPPY = 100
+
+ def self.build(personalstats:, profile:)
+ features = {}
+
+ PERSONALSTAT_MAP.each do |column, api_name|
+ features[column] = personalstats[api_name] || 0
+ end
+
+ features["level"] = profile.level || 0
+ 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
+
+ def self.resolve_property_happy(property)
+ return DEFAULT_HAPPY if property.nil?
+
+ PROPERTY_HAPPY.find { |name, _| property.include?(name) }&.last || DEFAULT_HAPPY
+ end
+
+ def self.calculate_real_age(age, last_action_timestamp)
+ age ||= 0
+ return age if last_action_timestamp.nil?
+
+ days_inactive = (Time.now - Time.at(last_action_timestamp)).to_i / 1.day
+ [ age - days_inactive, 0 ].max
+ end
+
+ private_class_method :resolve_property_happy, :calculate_real_age
+end
diff --git a/app/models/recon/predictor.rb b/app/models/recon/predictor.rb
new file mode 100644
index 0000000..b1af71e
--- /dev/null
+++ b/app/models/recon/predictor.rb
@@ -0,0 +1,77 @@
+require "onnxruntime"
+
+class Recon::Predictor
+ MODEL_DIR = Rails.root.join("lib/recon")
+
+ # 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
+
+ # Must match TIERS in train_model.py
+ TIERS = [
+ [ :low, 0, 1e9 ],
+ [ :mid, 1e9, 5e9 ],
+ [ :high, 5e9, Float::INFINITY ]
+ ].freeze
+
+ def initialize
+ 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)
+
+ @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 = 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
+ end
+
+ 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/app/models/recon/spy_data_parser.rb b/app/models/recon/spy_data_parser.rb
new file mode 100644
index 0000000..8003a22
--- /dev/null
+++ b/app/models/recon/spy_data_parser.rb
@@ -0,0 +1,145 @@
+class Recon::SpyDataParser
+ SpyRow = Data.define(:player_id, :name, :level, :strength, :defense, :speed, :dexterity, :spied_at)
+
+ HEADER_PATTERN = /\bName\b.*\bLevel\b/i
+ AVERAGE_PATTERN = /^Average:/i
+ NAME_ID_PATTERN = /^(?:\d+\s+)?(.+?)\s*\[(\d+)\]$/
+
+ def self.parse(input)
+ return [] if input.blank?
+
+ input.each_line.filter_map do |line|
+ line = line.strip
+ next if line.blank?
+ next if line.match?(HEADER_PATTERN)
+ next if line.match?(AVERAGE_PATTERN)
+
+ parse_row(line)
+ end
+ end
+
+ def self.parse_row(line)
+ cols = line.split("\t").map(&:strip)
+ return nil if cols.size < 8
+
+ cols.shift if cols[0].match?(/\A\d+\z/)
+
+ name, player_id = extract_name_and_id(cols[0])
+ return nil unless player_id
+
+ if format_with_faction?(cols)
+ parse_format_1(cols, name, player_id)
+ else
+ parse_format_2(cols, name, player_id)
+ end
+ end
+
+ # Format 1: Name, Level, Faction, STR, DEF, SPD, DEX, Total, FF, Date
+ def self.parse_format_1(cols, name, player_id)
+ return nil if cols.size < 10
+
+ date = parse_date(cols[9])
+ return nil unless date
+
+ SpyRow.new(
+ player_id: player_id,
+ name: name,
+ level: parse_level(cols[1]),
+ strength: parse_number(cols[3]),
+ defense: parse_number(cols[4]),
+ speed: parse_number(cols[5]),
+ dexterity: parse_number(cols[6]),
+ spied_at: date
+ )
+ end
+
+ # Format 2: Name, Level, STR, DEF, SPD, DEX, Total, Date, LastAction, Score
+ def self.parse_format_2(cols, name, player_id)
+ return nil if cols.size < 8
+ return nil if cols[2] == "N/A"
+
+ date = parse_date(cols[7])
+ return nil unless date
+
+ SpyRow.new(
+ player_id: player_id,
+ name: name,
+ level: parse_level(cols[1]),
+ strength: parse_number(cols[2]),
+ defense: parse_number(cols[3]),
+ speed: parse_number(cols[4]),
+ dexterity: parse_number(cols[5]),
+ spied_at: date
+ )
+ end
+
+ def self.format_with_faction?(cols)
+ # Format 1 has date at index 9 (DD/MM/YY), format 2 has date at index 7
+ # Check which position contains a valid date
+ cols[9]&.match?(%r{\A\d{2}/\d{2}/\d{2}\z})
+ end
+
+ def self.extract_name_and_id(col)
+ match = col.match(NAME_ID_PATTERN)
+ return [ nil, nil ] unless match
+
+ [ match[1].strip, match[2].to_i ]
+ end
+
+ def self.parse_number(str)
+ str&.gsub(",", "")&.to_i || 0
+ end
+
+ def self.parse_level(str)
+ return nil if str == "Unknown"
+ str&.to_i
+ end
+
+ def self.parse_date(str)
+ return nil if str.blank? || str == "N/A"
+ parts = str.split("/")
+ return nil unless parts.size == 3
+
+ day, month, year = parts.map(&:to_i)
+ year += 2000 if year < 100
+ Date.new(year, month, day)
+ rescue Date::Error
+ nil
+ end
+
+ 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/models/recon/torn_api/personal_stats.rb b/app/models/recon/torn_api/personal_stats.rb
new file mode 100644
index 0000000..84d83ed
--- /dev/null
+++ b/app/models/recon/torn_api/personal_stats.rb
@@ -0,0 +1,31 @@
+module Recon
+ module TornApi
+ class PersonalStats < ::TornApi::Base
+ attr_reader :player_id, :stats, :timestamp
+
+ def initialize(api_key, player_id, stats:, timestamp:)
+ super(api_key)
+ @player_id = player_id
+ @stats = stats
+ @timestamp = timestamp
+ end
+
+ def fetch
+ response = get("v2/user/#{player_id}/personalstats", {
+ stat: stats.join(","),
+ timestamp: timestamp
+ })
+
+ parse(response["personalstats"] || [])
+ end
+
+ private
+
+ def parse(stats_array)
+ stats_array.each_with_object({}) do |stat, hash|
+ hash[stat["name"]] = stat["value"]
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/recon/torn_api/profile.rb b/app/models/recon/torn_api/profile.rb
new file mode 100644
index 0000000..cedde2c
--- /dev/null
+++ b/app/models/recon/torn_api/profile.rb
@@ -0,0 +1,26 @@
+module Recon
+ module TornApi
+ class Profile < ::TornApi::Base
+ ProfileData = Data.define(:age, :level, :property, :last_action_timestamp)
+
+ attr_reader :player_id
+
+ def initialize(api_key, player_id)
+ super(api_key)
+ @player_id = player_id
+ end
+
+ def fetch
+ response = get("v2/user/#{player_id}/profile", {})
+ profile = response["profile"] || response
+
+ ProfileData.new(
+ age: profile["age"],
+ level: profile["level"],
+ property: profile.dig("property", "name"),
+ last_action_timestamp: profile.dig("last_action", "timestamp")
+ )
+ end
+ end
+ end
+end
diff --git a/app/models/recon/training_sample.rb b/app/models/recon/training_sample.rb
new file mode 100644
index 0000000..b46ae5f
--- /dev/null
+++ b/app/models/recon/training_sample.rb
@@ -0,0 +1,27 @@
+class Recon::TrainingSample < ApplicationRecord
+ self.table_name = "recon_training_samples"
+
+ 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
+
+ LABEL_COLUMNS = %w[strength defense speed dexterity].freeze
+
+ validates :player_id, presence: true
+ validates :strength, presence: true
+ validates :defense, presence: true
+ validates :speed, presence: true
+ validates :dexterity, presence: true
+ validates :spied_at, presence: true
+
+ def total_stats
+ strength + defense + speed + dexterity
+ end
+
+ def features
+ FEATURE_COLUMNS.index_with { |col| self[col] }
+ end
+end
diff --git a/app/models/script_version.rb b/app/models/script_version.rb
new file mode 100644
index 0000000..3f1c628
--- /dev/null
+++ b/app/models/script_version.rb
@@ -0,0 +1,11 @@
+class ScriptVersion < ApplicationRecord
+ validates :version, presence: true, uniqueness: true
+ validates :released_at, presence: true
+ validates :script_content, presence: true
+
+ scope :ordered, -> { order(released_at: :desc, created_at: :desc) }
+
+ def self.latest
+ ordered.first
+ end
+end
diff --git a/app/models/spy_report.rb b/app/models/spy_report.rb
new file mode 100644
index 0000000..7de55c0
--- /dev/null
+++ b/app/models/spy_report.rb
@@ -0,0 +1,25 @@
+class SpyReport < ApplicationRecord
+ belongs_to :faction
+
+ validates :torn_id, presence: true, uniqueness: { scope: :faction_id }
+
+ before_save :recalculate_total
+
+ scope :for_targets, ->(torn_ids) { where(torn_id: torn_ids) }
+
+ def stats_hash
+ {
+ strength: strength,
+ defense: defense,
+ speed: speed,
+ dexterity: dexterity,
+ total: total
+ }
+ end
+
+ private
+
+ def recalculate_total
+ self.total = (strength || 0) + (defense || 0) + (speed || 0) + (dexterity || 0)
+ end
+end
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
new file mode 100644
index 0000000..b586cc6
--- /dev/null
+++ b/app/models/subscription.rb
@@ -0,0 +1,20 @@
+class Subscription < ApplicationRecord
+ belongs_to :subscribable, polymorphic: true
+
+ validates :expires_at, presence: true
+ validates :subscribable_type, uniqueness: { scope: :subscribable_id }
+
+ def active?
+ expires_at.present? && expires_at > Time.current
+ end
+
+ def days_remaining
+ return 0 unless active?
+ ((expires_at - Time.current) / 1.day).ceil
+ end
+
+ def extend!(weeks)
+ new_expiry = active? ? expires_at + weeks.weeks : Time.current + weeks.weeks
+ update!(expires_at: new_expiry)
+ end
+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/stock.rb b/app/models/torn/stock.rb
index 6518227..9a9f707 100644
--- a/app/models/torn/stock.rb
+++ b/app/models/torn/stock.rb
@@ -25,6 +25,7 @@ def self.money_rows(owned_stocks)
dividend_value: stock.dividend_value,
block_cost: stock.block_cost(increment),
days_to_break_even: stock.days_to_break_even_with_item(stock.dividend_value, increment),
+ annual_roi: stock.annual_roi(increment),
owned: owned_stock ? stock.owns_increment?(increment, owned_stock.total_shares) : false
}
end
@@ -43,6 +44,14 @@ def days_to_break_even_with_item(item_market_price, increment)
(cost / payout).ceil * payout_days
end
+ def annual_roi(increment)
+ return 0.0 if dividend_value.to_i == 0 || dividend_frequency.to_i == 0
+
+ cost = block_cost(increment)
+ annual_dividends = dividend_value.to_f * (365.0 / dividend_frequency)
+ (annual_dividends / cost * 100).round(2)
+ end
+
def owns_increment?(increment, owned_shares)
required_shares = dividend_requirement * (2**(increment - 1))
owned_shares >= required_shares
diff --git a/app/models/torn_api.rb b/app/models/torn_api.rb
index 179412f..036ba2c 100644
--- a/app/models/torn_api.rb
+++ b/app/models/torn_api.rb
@@ -4,9 +4,24 @@
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
+ # 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
class Base
- DEFAULT_PARAMS = { comment: "tornmanager" }.freeze
+ 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,20 +30,291 @@ def initialize(api_key)
@api_key = api_key
end
- def get(path, params = {})
- merged_params = DEFAULT_PARAMS.merge(params)
- uri = URI("https://api.torn.com/#{path}")
- uri.query = URI.encode_www_form(merged_params) if params.any?
+ def get(path, params = {}, retries: 0)
+ start_time = Time.current
+ api_params = params.is_a?(Hash) ? params : {}
+ merged_params = DEFAULT_PARAMS.merge(api_params)
+ 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)
+
+ RateLimiter.acquire!(api_key)
+ response = perform_request(uri)
+ body = parse_response(response, path)
+
+ check_for_errors(body)
+
+ response_time = ((Time.current - start_time) * 1000).to_i
+ log_api_call(path, merged_params, "success", response_time, body["_metadata"])
+ 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)
+ 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)
+ rescue JSON::ParserError => e
+ 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}")
+ raise ApiError, "Invalid JSON response from Torn API"
+ rescue Net::HTTPError, SocketError => e
+ handle_network_error(uri, api_params, e, retries, start_time)
+ 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})"
+ http.request(req)
+ end
+ end
+
+ 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]}")
+ if status >= 500
+ notify_torn_degraded(path, status)
+ raise TransientError, "Torn API request failed (HTTP #{status})"
end
- JSON.parse(resp.body)
+ raise ApiError, "Torn API request failed (HTTP #{status})"
+ 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
+ # 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 — 15/17 heal on retry, 9/24 can last hours
+ when 9 then raise ApiError, "Torn API is currently disabled"
+ 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 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}"
+ 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
+ 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}")
+ 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}")
+ raise TimeoutError, "Torn API request timed out after #{MAX_RETRIES} retries"
+ end
+ end
+
+ 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}")
+ 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}")
+ 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
+
+ def log_api_call(endpoint, params, status, response_time, metadata, error_message = nil)
+ target_user = resolve_api_key_owner
+
+ 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,
+ faction_id: target_user.faction_id,
+ api_key: api_key,
+ endpoint: endpoint,
+ selections: params.except(:comment, :striptags).to_json,
+ response_time: response_time,
+ status: status,
+ error_message: error_message,
+ torn_api_timestamp: metadata&.dig("timestamp")
+ )
+ rescue => e
+ Rails.logger.error("Failed to log API call: #{e.message}")
+ Rails.logger.error(e.backtrace.first(5).join("\n"))
+ 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
+ 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: 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" },
+ timestamp: Time.current.iso8601
+ }
+ )
+ rescue => e
+ 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 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)
+ return found if found
+
+ ::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/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..de9cb19
--- /dev/null
+++ b/app/models/torn_api/faction/armory_news.rb
@@ -0,0 +1,103 @@
+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 /\Agave (\d+)x (.+?) to/
+ [ :loaned, $2 ]
+ when /\Areturned (\d+)x (.+)/
+ [ :returned, $2 ]
+ 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
+ end
+ end
+ 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/models/torn_api/faction/basic.rb b/app/models/torn_api/faction/basic.rb
new file mode 100644
index 0000000..20e6686
--- /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 ApiError, "No faction data returned: #{response}"
+ end
+ end
+
+ def name
+ fetch["name"]
+ end
+ end
+ end
+end
diff --git a/app/models/torn_api/faction/members.rb b/app/models/torn_api/faction/members.rb
index a2f43f5..b8a93eb 100644
--- a/app/models/torn_api/faction/members.rb
+++ b/app/models/torn_api/faction/members.rb
@@ -15,6 +15,7 @@ class Members < Base
:status_state,
:status_color,
:status_until,
+ :plane_image_type,
:revive_setting,
:position,
:is_revivable,
@@ -33,11 +34,12 @@ def endpoint
end
def fetch
- response = get(endpoint, striptags: false)
+ params = { striptags: false, timestamp: Time.current.to_i }
+ response = get(endpoint, params)
if response["members"].present?
parse(response["members"])
else
- raise InvalidKeyError, "Torn API authentication failed: #{response}"
+ raise ApiError, "No members data returned: #{response}"
end
end
@@ -58,6 +60,7 @@ def parse(members_array)
member.dig("status", "state"),
member.dig("status", "color"),
member.dig("status", "until"),
+ member.dig("status", "plane_image_type"),
member["revive_setting"],
member["position"],
member["is_revivable"],
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..ca5b076
--- /dev/null
+++ b/app/models/torn_api/faction/ranked_wars.rb
@@ -0,0 +1,42 @@
+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
+
+ 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
+ break if offset > 1000
+ end
+
+ all_wars
+ end
+ end
+ end
+end
diff --git a/app/models/torn_api/key/info.rb b/app/models/torn_api/key/info.rb
new file mode 100644
index 0000000..39a2b31
--- /dev/null
+++ b/app/models/torn_api/key/info.rb
@@ -0,0 +1,49 @@
+module TornApi
+ module Key
+ class Info < TornApi::Base
+ AccessData = Data.define(
+ :level,
+ :type,
+ :faction,
+ :company
+ )
+
+ UserData = Data.define(
+ :id,
+ :faction_id,
+ :company_id
+ )
+
+ InfoData = Data.define(
+ :access,
+ :user
+ )
+
+ def fetch
+ data = get("v2/key/info")
+
+ 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/models/torn_api/key/log.rb b/app/models/torn_api/key/log.rb
new file mode 100644
index 0000000..9b75fbe
--- /dev/null
+++ b/app/models/torn_api/key/log.rb
@@ -0,0 +1,47 @@
+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 = []
+
+ [ 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/models/torn_api/market.rb b/app/models/torn_api/market.rb
index 12c5448..e1a5ebb 100644
--- a/app/models/torn_api/market.rb
+++ b/app/models/torn_api/market.rb
@@ -3,11 +3,11 @@ class Market < Base
ENDPOINT = "v2/market".freeze
def fetch
- response = get(ENDPOINT, striptags: false, selections: "pointsmarket")
+ response = get(ENDPOINT, { striptags: false, selections: "pointsmarket" })
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/rate_limiter.rb b/app/models/torn_api/rate_limiter.rb
new file mode 100644
index 0000000..afef2d1
--- /dev/null
+++ b/app/models/torn_api/rate_limiter.rb
@@ -0,0 +1,72 @@
+module TornApi
+ # 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 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
+ 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 > limit
+ raise RateLimitError, "Too many requests (client-side key budget of #{limit}/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
+
+ def reserving_headroom?
+ Thread.current[:torn_api_reserve_headroom] == true
+ end
+
+ # 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
+
+ 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/torn/factionhof.rb b/app/models/torn_api/torn/factionhof.rb
index def2643..70151e2 100644
--- a/app/models/torn_api/torn/factionhof.rb
+++ b/app/models/torn_api/torn/factionhof.rb
@@ -21,11 +21,11 @@ def endpoint
end
def fetch
- response = get(endpoint, limit: @limit, offset: @offset, cat: "respect", striptags: false)
+ response = get(endpoint, { limit: @limit, offset: @offset, cat: "respect", striptags: false })
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/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/models/torn_api/torn/items.rb b/app/models/torn_api/torn/items.rb
index e561a65..f772312 100644
--- a/app/models/torn_api/torn/items.rb
+++ b/app/models/torn_api/torn/items.rb
@@ -4,11 +4,11 @@ class Items < Base
ENDPOINT = "v2/torn/items".freeze
def fetch
- response = get(ENDPOINT, striptags: false)
+ response = get(ENDPOINT, { striptags: false })
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 dd124bc..fc17c36 100644
--- a/app/models/torn_api/torn/stocks.rb
+++ b/app/models/torn_api/torn/stocks.rb
@@ -4,26 +4,26 @@ class Stocks < Base
ENDPOINT = "v2/torn/stocks".freeze
def fetch
- response = get(ENDPOINT, striptags: false)
+ response = get(ENDPOINT, { striptags: false })
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
private
def build_stocks(data)
- data.map do |_, details|
+ data.map do |details|
::Torn::Stock.new(
- torn_id: details["stock_id"],
+ torn_id: details["id"],
name: details["name"],
acronym: details["acronym"],
- current_price: details["current_price"],
- dividend_frequency: details["benefit"]["frequency"],
- dividend_requirement: details["benefit"]["requirement"],
- dividend_description: details["benefit"]["description"],
+ current_price: details.dig("market", "price"),
+ dividend_frequency: details.dig("bonus", "frequency"),
+ dividend_requirement: details.dig("bonus", "requirement"),
+ dividend_description: details.dig("bonus", "description"),
)
end
end
diff --git a/app/models/torn_api/user/basic.rb b/app/models/torn_api/user/basic.rb
new file mode 100644
index 0000000..3c6c69e
--- /dev/null
+++ b/app/models/torn_api/user/basic.rb
@@ -0,0 +1,49 @@
+module TornApi
+ module User
+ class Basic < Base
+ attr_reader :torn_id
+
+ BasicData = Data.define(
+ :id,
+ :name,
+ :level,
+ :gender,
+ :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: true })
+ if response["profile"].present?
+ parse_profile(response["profile"])
+ else
+ raise ApiError, "No user data returned: #{response}"
+ end
+ end
+
+ private
+
+ def parse_profile(data)
+ BasicData.new(
+ id: data["id"],
+ name: data["name"],
+ level: data["level"],
+ gender: data["gender"],
+ status: data["status"]
+ )
+ end
+ 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/app/models/torn_api/user/log.rb b/app/models/torn_api/user/log.rb
new file mode 100644
index 0000000..389e05f
--- /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/torn_api/user/personal_stats.rb b/app/models/torn_api/user/personal_stats.rb
index 73661ff..959a19b 100644
--- a/app/models/torn_api/user/personal_stats.rb
+++ b/app/models/torn_api/user/personal_stats.rb
@@ -1,222 +1,13 @@
module TornApi
module User
class PersonalStats < Base
- attr_reader :torn_id
- PersonalStatSnapshot = Data.define(
- :attacking_attacks_won,
- :attacking_attacks_lost,
- :attacking_attacks_stalemate,
- :attacking_attacks_assist,
- :attacking_attacks_stealth,
- :attacking_defends_won,
- :attacking_defends_lost,
- :attacking_defends_stalemate,
- :attacking_defends_total,
- :attacking_elo,
- :attacking_unarmored_wins,
- :attacking_highest_level_beaten,
- :attacking_escapes_player,
- :attacking_escapes_foes,
- :attacking_killstreak_best,
- :attacking_hits_success,
- :attacking_hits_miss,
- :attacking_hits_critical,
- :attacking_hits_one_hit_kills,
- :attacking_damage_total,
- :attacking_damage_best,
- :attacking_networth_money_mugged,
- :attacking_networth_largest_mug,
- :attacking_networth_items_looted,
- :attacking_ammunition_total,
- :attacking_ammunition_special,
- :attacking_ammunition_hollow_point,
- :attacking_ammunition_tracer,
- :attacking_ammunition_piercing,
- :attacking_ammunition_incendiary,
- :attacking_faction_respect,
- :attacking_faction_retaliations,
- :attacking_faction_ranked_war_hits,
- :attacking_faction_raid_hits,
- :attacking_faction_territory_wall_joins,
- :attacking_faction_territory_wall_clears,
- :attacking_faction_territory_wall_time,
+ attr_reader :torn_id, :timestamp, :stat_batch
- # Jobs
- :jobs_job_points_used,
- :jobs_trains_received,
-
- # Trading
- :trading_items_bought_market,
- :trading_items_bought_shops,
- :trading_items_auctions_won,
- :trading_items_auctions_sold,
- :trading_items_sent,
- :trading_trades,
- :trading_points_bought,
- :trading_points_sold,
- :trading_bazaar_customers,
- :trading_bazaar_sales,
- :trading_bazaar_profit,
- :trading_item_market_customers,
- :trading_item_market_sales,
- :trading_item_market_revenue,
- :trading_item_market_fees,
-
- # Jail
- :jail_times_jailed,
- :jail_busts_success,
- :jail_busts_fails,
- :jail_bails_amount,
- :jail_bails_fees,
-
- # Hospital
- :hospital_times_hospitalized,
- :hospital_medical_items_used,
- :hospital_blood_withdrawn,
- :hospital_reviving_skill,
- :hospital_reviving_revives,
- :hospital_reviving_revives_received,
-
- # Finishing hits
- :finishing_hits_heavy_artillery,
- :finishing_hits_machine_guns,
- :finishing_hits_rifles,
- :finishing_hits_sub_machine_guns,
- :finishing_hits_shotguns,
- :finishing_hits_pistols,
- :finishing_hits_temporary,
- :finishing_hits_piercing,
- :finishing_hits_slashing,
- :finishing_hits_clubbing,
- :finishing_hits_mechanical,
- :finishing_hits_hand_to_hand,
-
- # Communication
- :communication_mails_sent_total,
- :communication_mails_sent_friends,
- :communication_mails_sent_faction,
- :communication_mails_sent_colleagues,
- :communication_mails_sent_spouse,
- :communication_classified_ads,
- :communication_personals,
-
- # Crimes
- :crimes_offenses_vandalism,
- :crimes_offenses_fraud,
- :crimes_offenses_theft,
- :crimes_offenses_counterfeiting,
- :crimes_offenses_illicit_services,
- :crimes_offenses_cybercrime,
- :crimes_offenses_extortion,
- :crimes_offenses_illegal_production,
- :crimes_offenses_organized_crimes,
- :crimes_offenses_total,
- :crimes_skills_search_for_cash,
- :crimes_skills_bootlegging,
- :crimes_skills_graffiti,
- :crimes_skills_shoplifting,
- :crimes_skills_pickpocketing,
- :crimes_skills_card_skimming,
- :crimes_skills_burglary,
- :crimes_skills_hustling,
- :crimes_skills_disposal,
- :crimes_skills_cracking,
- :crimes_skills_forgery,
- :crimes_skills_scamming,
- :crimes_skills_arson,
- :crimes_total,
- :crimes_version,
-
- # Bounties
- :bounties_placed_amount,
- :bounties_placed_value,
- :bounties_collected_amount,
- :bounties_collected_value,
- :bounties_received_amount,
- :bounties_received_value,
-
- # Items
- :items_found_city,
- :items_found_dump,
- :items_found_easter_eggs,
- :items_trashed,
- :items_used_books,
- :items_used_boosters,
- :items_used_consumables,
- :items_used_candy,
- :items_used_alcohol,
- :items_used_energy,
- :items_used_energy_drinks,
- :items_used_stat_enhancers,
- :items_used_easter_eggs,
- :items_viruses_coded,
-
- # Travel
- :travel_total,
- :travel_time_spent,
- :travel_items_bought,
- :travel_hunting_skill,
- :travel_attacks_won,
- :travel_defends_lost,
- :travel_argentina,
- :travel_canada,
- :travel_cayman_islands,
- :travel_china,
- :travel_hawaii,
- :travel_japan,
- :travel_mexico,
- :travel_united_arab_emirates,
- :travel_united_kingdom,
- :travel_south_africa,
- :travel_switzerland,
-
- # Drugs
- :drugs_cannabis,
- :drugs_ecstasy,
- :drugs_ketamine,
- :drugs_lsd,
- :drugs_opium,
- :drugs_pcp,
- :drugs_shrooms,
- :drugs_speed,
- :drugs_vicodin,
- :drugs_xanax,
- :drugs_total,
- :drugs_overdoses,
- :drugs_rehabilitations_amount,
- :drugs_rehabilitations_fees,
-
- # Missions
- :missions_missions,
- :missions_contracts_total,
- :missions_contracts_duke,
- :missions_credits,
-
- # Racing
- :racing_skill,
- :racing_points,
- :racing_races_entered,
- :racing_races_won,
-
- # Networth
- :networth_total,
-
- # Other
- :other_activity_time,
- :other_activity_streak_current,
- :other_activity_streak_best,
- :other_awards,
- :other_merits_bought,
- :other_refills_energy,
- :other_refills_nerve,
- :other_refills_token,
- :other_donator_days,
- :other_ranked_war_wins
- )
-
- def initialize(api_key, torn_id)
+ 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
@@ -224,229 +15,36 @@ def endpoint
end
def fetch
- response = get(endpoint, cat: "all", striptags: false)
- if response["personalstats"].present?
- parse_personalstats(response["personalstats"])
+ stat_names = stat_batch.keys.join(",")
+ params = { stat: stat_names }
+ params[:timestamp] = timestamp if timestamp
+
+ response = get(endpoint, params)
+
+ if response["personalstats"].present? || response["personalstats"].is_a?(Array)
+ parse_personalstats(response["personalstats"], stat_batch)
else
- raise InvalidKeyError, "Torn API authentication failed: #{response}"
+ raise NoDataError, "No personal stats data returned: #{response}"
end
end
private
- def parse_personalstats(stats)
- PersonalStatSnapshot.new(
- # Attacking
- attacking_attacks_won: stats.dig("attacking", "attacks", "won"),
- attacking_attacks_lost: stats.dig("attacking", "attacks", "lost"),
- attacking_attacks_stalemate: stats.dig("attacking", "attacks", "stalemate"),
- attacking_attacks_assist: stats.dig("attacking", "attacks", "assist"),
- attacking_attacks_stealth: stats.dig("attacking", "attacks", "stealth"),
- attacking_defends_won: stats.dig("attacking", "defends", "won"),
- attacking_defends_lost: stats.dig("attacking", "defends", "lost"),
- attacking_defends_stalemate: stats.dig("attacking", "defends", "stalemate"),
- attacking_defends_total: stats.dig("attacking", "defends", "total"),
- attacking_elo: stats.dig("attacking", "elo"),
- attacking_unarmored_wins: stats.dig("attacking", "unarmored_wins"),
- attacking_highest_level_beaten: stats.dig("attacking", "highest_level_beaten"),
- attacking_escapes_player: stats.dig("attacking", "escapes", "player"),
- attacking_escapes_foes: stats.dig("attacking", "escapes", "foes"),
- attacking_killstreak_best: stats.dig("attacking", "killstreak", "best"),
- attacking_hits_success: stats.dig("attacking", "hits", "success"),
- attacking_hits_miss: stats.dig("attacking", "hits", "miss"),
- attacking_hits_critical: stats.dig("attacking", "hits", "critical"),
- attacking_hits_one_hit_kills: stats.dig("attacking", "hits", "one_hit_kills"),
- attacking_damage_total: stats.dig("attacking", "damage", "total"),
- attacking_damage_best: stats.dig("attacking", "damage", "best"),
- attacking_networth_money_mugged: stats.dig("attacking", "networth", "money_mugged"),
- attacking_networth_largest_mug: stats.dig("attacking", "networth", "largest_mug"),
- attacking_networth_items_looted: stats.dig("attacking", "networth", "items_looted"),
- attacking_ammunition_total: stats.dig("attacking", "ammunition", "total"),
- attacking_ammunition_special: stats.dig("attacking", "ammunition", "special"),
- attacking_ammunition_hollow_point: stats.dig("attacking", "ammunition", "hollow_point"),
- attacking_ammunition_tracer: stats.dig("attacking", "ammunition", "tracer"),
- attacking_ammunition_piercing: stats.dig("attacking", "ammunition", "piercing"),
- attacking_ammunition_incendiary: stats.dig("attacking", "ammunition", "incendiary"),
- attacking_faction_respect: stats.dig("attacking", "faction", "respect"),
- attacking_faction_retaliations: stats.dig("attacking", "faction", "retaliations"),
- attacking_faction_ranked_war_hits: stats.dig("attacking", "faction", "ranked_war_hits"),
- attacking_faction_raid_hits: stats.dig("attacking", "faction", "raid_hits"),
- attacking_faction_territory_wall_joins: stats.dig("attacking", "faction", "territory", "wall_joins"),
- attacking_faction_territory_wall_clears: stats.dig("attacking", "faction", "territory", "wall_clears"),
- attacking_faction_territory_wall_time: stats.dig("attacking", "faction", "territory", "wall_time"),
-
- # Jobs
- jobs_job_points_used: stats.dig("jobs", "job_points_used"),
- jobs_trains_received: stats.dig("jobs", "trains_received"),
-
- # Trading
- trading_items_bought_market: stats.dig("trading", "items", "bought", "market"),
- trading_items_bought_shops: stats.dig("trading", "items", "bought", "shops"),
- trading_items_auctions_won: stats.dig("trading", "items", "auctions", "won"),
- trading_items_auctions_sold: stats.dig("trading", "items", "auctions", "sold"),
- trading_items_sent: stats.dig("trading", "items", "sent"),
- trading_trades: stats.dig("trading", "trades"),
- trading_points_bought: stats.dig("trading", "points", "bought"),
- trading_points_sold: stats.dig("trading", "points", "sold"),
- trading_bazaar_customers: stats.dig("trading", "bazaar", "customers"),
- trading_bazaar_sales: stats.dig("trading", "bazaar", "sales"),
- trading_bazaar_profit: stats.dig("trading", "bazaar", "profit"),
- trading_item_market_customers: stats.dig("trading", "item_market", "customers"),
- trading_item_market_sales: stats.dig("trading", "item_market", "sales"),
- trading_item_market_revenue: stats.dig("trading", "item_market", "revenue"),
- trading_item_market_fees: stats.dig("trading", "item_market", "fees"),
-
- # Jail
- jail_times_jailed: stats.dig("jail", "times_jailed"),
- jail_busts_success: stats.dig("jail", "busts", "success"),
- jail_busts_fails: stats.dig("jail", "busts", "fails"),
- jail_bails_amount: stats.dig("jail", "bails", "amount"),
- jail_bails_fees: stats.dig("jail", "bails", "fees"),
-
- # Hospital
- hospital_times_hospitalized: stats.dig("hospital", "times_hospitalized"),
- hospital_medical_items_used: stats.dig("hospital", "medical_items_used"),
- hospital_blood_withdrawn: stats.dig("hospital", "blood_withdrawn"),
- hospital_reviving_skill: stats.dig("hospital", "reviving", "skill"),
- hospital_reviving_revives: stats.dig("hospital", "reviving", "revives"),
- hospital_reviving_revives_received: stats.dig("hospital", "reviving", "revives_received"),
-
- # Finishing hits
- finishing_hits_heavy_artillery: stats.dig("finishing_hits", "heavy_artillery"),
- finishing_hits_machine_guns: stats.dig("finishing_hits", "machine_guns"),
- finishing_hits_rifles: stats.dig("finishing_hits", "rifles"),
- finishing_hits_sub_machine_guns: stats.dig("finishing_hits", "sub_machine_guns"),
- finishing_hits_shotguns: stats.dig("finishing_hits", "shotguns"),
- finishing_hits_pistols: stats.dig("finishing_hits", "pistols"),
- finishing_hits_temporary: stats.dig("finishing_hits", "temporary"),
- finishing_hits_piercing: stats.dig("finishing_hits", "piercing"),
- finishing_hits_slashing: stats.dig("finishing_hits", "slashing"),
- finishing_hits_clubbing: stats.dig("finishing_hits", "clubbing"),
- finishing_hits_mechanical: stats.dig("finishing_hits", "mechanical"),
- finishing_hits_hand_to_hand: stats.dig("finishing_hits", "hand_to_hand"),
-
- # Communication
- communication_mails_sent_total: stats.dig("communication", "mails_sent", "total"),
- communication_mails_sent_friends: stats.dig("communication", "mails_sent", "friends"),
- communication_mails_sent_faction: stats.dig("communication", "mails_sent", "faction"),
- communication_mails_sent_colleagues: stats.dig("communication", "mails_sent", "colleagues"),
- communication_mails_sent_spouse: stats.dig("communication", "mails_sent", "spouse"),
- communication_classified_ads: stats.dig("communication", "classified_ads"),
- communication_personals: stats.dig("communication", "personals"),
-
- # Crimes
- crimes_offenses_vandalism: stats.dig("crimes", "offenses", "vandalism"),
- crimes_offenses_fraud: stats.dig("crimes", "offenses", "fraud"),
- crimes_offenses_theft: stats.dig("crimes", "offenses", "theft"),
- crimes_offenses_counterfeiting: stats.dig("crimes", "offenses", "counterfeiting"),
- crimes_offenses_illicit_services: stats.dig("crimes", "offenses", "illicit_services"),
- crimes_offenses_cybercrime: stats.dig("crimes", "offenses", "cybercrime"),
- crimes_offenses_extortion: stats.dig("crimes", "offenses", "extortion"),
- crimes_offenses_illegal_production: stats.dig("crimes", "offenses", "illegal_production"),
- crimes_offenses_organized_crimes: stats.dig("crimes", "offenses", "organized_crimes"),
- crimes_offenses_total: stats.dig("crimes", "offenses", "total"),
- crimes_skills_search_for_cash: stats.dig("crimes", "skills", "search_for_cash"),
- crimes_skills_bootlegging: stats.dig("crimes", "skills", "bootlegging"),
- crimes_skills_graffiti: stats.dig("crimes", "skills", "graffiti"),
- crimes_skills_shoplifting: stats.dig("crimes", "skills", "shoplifting"),
- crimes_skills_pickpocketing: stats.dig("crimes", "skills", "pickpocketing"),
- crimes_skills_card_skimming: stats.dig("crimes", "skills", "card_skimming"),
- crimes_skills_burglary: stats.dig("crimes", "skills", "burglary"),
- crimes_skills_hustling: stats.dig("crimes", "skills", "hustling"),
- crimes_skills_disposal: stats.dig("crimes", "skills", "disposal"),
- crimes_skills_cracking: stats.dig("crimes", "skills", "cracking"),
- crimes_skills_forgery: stats.dig("crimes", "skills", "forgery"),
- crimes_skills_scamming: stats.dig("crimes", "skills", "scamming"),
- crimes_skills_arson: stats.dig("crimes", "skills", "arson"),
- crimes_total: stats.dig("crimes", "total"),
- crimes_version: stats.dig("crimes", "version"),
-
- # Bounties
- bounties_placed_amount: stats.dig("bounties", "placed", "amount"),
- bounties_placed_value: stats.dig("bounties", "placed", "value"),
- bounties_collected_amount: stats.dig("bounties", "collected", "amount"),
- bounties_collected_value: stats.dig("bounties", "collected", "value"),
- bounties_received_amount: stats.dig("bounties", "received", "amount"),
- bounties_received_value: stats.dig("bounties", "received", "value"),
-
- # Items
- items_found_city: stats.dig("items", "found", "city"),
- items_found_dump: stats.dig("items", "found", "dump"),
- items_found_easter_eggs: stats.dig("items", "found", "easter_eggs"),
- items_trashed: stats.dig("items", "trashed"),
- items_used_books: stats.dig("items", "used", "books"),
- items_used_boosters: stats.dig("items", "used", "boosters"),
- items_used_consumables: stats.dig("items", "used", "consumables"),
- items_used_candy: stats.dig("items", "used", "candy"),
- items_used_alcohol: stats.dig("items", "used", "alcohol"),
- items_used_energy: stats.dig("items", "used", "energy"),
- items_used_energy_drinks: stats.dig("items", "used", "energy_drinks"),
- items_used_stat_enhancers: stats.dig("items", "used", "stat_enhancers"),
- items_used_easter_eggs: stats.dig("items", "used", "easter_eggs"),
- items_viruses_coded: stats.dig("items", "viruses_coded"),
-
- # Travel
- travel_total: stats.dig("travel", "total"),
- travel_time_spent: stats.dig("travel", "time_spent"),
- travel_items_bought: stats.dig("travel", "items_bought"),
- travel_hunting_skill: stats.dig("travel", "hunting", "skill"),
- travel_attacks_won: stats.dig("travel", "attacks_won"),
- travel_defends_lost: stats.dig("travel", "defends_lost"),
- travel_argentina: stats.dig("travel", "argentina"),
- travel_canada: stats.dig("travel", "canada"),
- travel_cayman_islands: stats.dig("travel", "cayman_islands"),
- travel_china: stats.dig("travel", "china"),
- travel_hawaii: stats.dig("travel", "hawaii"),
- travel_japan: stats.dig("travel", "japan"),
- travel_mexico: stats.dig("travel", "mexico"),
- travel_united_arab_emirates: stats.dig("travel", "united_arab_emirates"),
- travel_united_kingdom: stats.dig("travel", "united_kingdom"),
- travel_south_africa: stats.dig("travel", "south_africa"),
- travel_switzerland: stats.dig("travel", "switzerland"),
-
- # Drugs
- drugs_cannabis: stats.dig("drugs", "cannabis"),
- drugs_ecstasy: stats.dig("drugs", "ecstasy"),
- drugs_ketamine: stats.dig("drugs", "ketamine"),
- drugs_lsd: stats.dig("drugs", "lsd"),
- drugs_opium: stats.dig("drugs", "opium"),
- drugs_pcp: stats.dig("drugs", "pcp"),
- drugs_shrooms: stats.dig("drugs", "shrooms"),
- drugs_speed: stats.dig("drugs", "speed"),
- drugs_vicodin: stats.dig("drugs", "vicodin"),
- drugs_xanax: stats.dig("drugs", "xanax"),
- drugs_total: stats.dig("drugs", "total"),
- drugs_overdoses: stats.dig("drugs", "overdoses"),
- drugs_rehabilitations_amount: stats.dig("drugs", "rehabilitations", "amount"),
- drugs_rehabilitations_fees: stats.dig("drugs", "rehabilitations", "fees"),
+ def parse_personalstats(stats, requested_stats)
+ stats_hash = stats.each_with_object({}) do |stat, hash|
+ hash[stat["name"]] = stat["value"]
+ end
- # Missions
- missions_missions: stats.dig("missions", "missions"),
- missions_contracts_total: stats.dig("missions", "contracts", "total"),
- missions_contracts_duke: stats.dig("missions", "contracts", "duke"),
- missions_credits: stats.dig("missions", "credits"),
+ response_timestamp = stats.first&.dig("timestamp")
+ response_date = response_timestamp ? Time.at(response_timestamp).utc.to_date : nil
- # Racing
- racing_skill: stats.dig("racing", "skill"),
- racing_points: stats.dig("racing", "points"),
- racing_races_entered: stats.dig("racing", "races", "entered"),
- racing_races_won: stats.dig("racing", "races", "won"),
+ result = { date: response_date, timestamp: response_timestamp }
- # Networth
- networth_total: stats.dig("networth", "total"),
+ requested_stats.each do |api_name, db_column|
+ result[db_column] = stats_hash[api_name] || 0
+ end
- # Other
- other_activity_time: stats.dig("other", "activity", "time"),
- other_activity_streak_current: stats.dig("other", "activity", "streak", "current"),
- other_activity_streak_best: stats.dig("other", "activity", "streak", "best"),
- other_awards: stats.dig("other", "awards"),
- other_merits_bought: stats.dig("other", "merits_bought"),
- other_refills_energy: stats.dig("other", "refills", "energy"),
- other_refills_nerve: stats.dig("other", "refills", "nerve"),
- other_refills_token: stats.dig("other", "refills", "token"),
- other_donator_days: stats.dig("other", "donator_days"),
- other_ranked_war_wins: stats.dig("other", "ranked_war_wins")
- )
+ result
end
end
end
diff --git a/app/models/torn_api/user/profile.rb b/app/models/torn_api/user/profile.rb
index 1f75b49..a439dda 100644
--- a/app/models/torn_api/user/profile.rb
+++ b/app/models/torn_api/user/profile.rb
@@ -1,16 +1,36 @@
module TornApi
module User
class Profile < Base
- ENDPOINT = "v2/user/basic".freeze
+ ProfileData = Data.define(
+ :id,
+ :name,
+ :level,
+ :image
+ )
+
+ def endpoint
+ "v2/user/profile"
+ end
def fetch
- response = get(ENDPOINT, striptags: false)
+ response = get(endpoint, { striptags: true })
if response["profile"].present?
- response["profile"]
+ parse_profile(response["profile"])
else
- raise InvalidKeyError, "Torn API authentication failed: #{response}"
+ raise ApiError, "No profile data returned: #{response}"
end
end
+
+ private
+
+ def parse_profile(data)
+ ProfileData.new(
+ id: data["id"],
+ name: data["name"],
+ level: data["level"],
+ image: data["image"]
+ )
+ end
end
end
end
diff --git a/app/models/torn_api/user/stocks.rb b/app/models/torn_api/user/stocks.rb
index 44e675a..3b0efe3 100644
--- a/app/models/torn_api/user/stocks.rb
+++ b/app/models/torn_api/user/stocks.rb
@@ -7,22 +7,22 @@ class Stocks < Base
ENDPOINT = "v2/user/stocks".freeze
def fetch
- response = get(ENDPOINT, striptags: false)
+ response = get(ENDPOINT, { striptags: false })
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
private
def parse_user_stocks(stock_data)
- stock_data.map do |stock_id, stock_details|
+ stock_data.map do |stock_details|
UserStock.new(
- stock_id: stock_id.to_i,
- total_shares: stock_details["total_shares"],
- dividend: parse_dividend_info(stock_details["dividend"]),
+ stock_id: stock_details["id"],
+ total_shares: stock_details["shares"],
+ dividend: parse_dividend_info(stock_details["bonus"]),
transactions: parse_transactions(stock_details["transactions"])
)
end
@@ -32,7 +32,7 @@ def parse_dividend_info(dividend_data)
return nil unless dividend_data
Dividend.new(
- ready: dividend_data["ready"],
+ ready: dividend_data["available"],
increment: dividend_data["increment"],
progress: dividend_data["progress"],
frequency: dividend_data["frequency"]
@@ -42,11 +42,11 @@ def parse_dividend_info(dividend_data)
def parse_transactions(transactions_data)
return [] unless transactions_data
- transactions_data.map do |_, transaction_details|
+ transactions_data.map do |transaction_details|
Transaction.new(
shares: transaction_details["shares"],
- bought_price: transaction_details["bought_price"],
- time_bought: Time.at(transaction_details["time_bought"])
+ bought_price: transaction_details["price"],
+ time_bought: Time.at(transaction_details["timestamp"])
)
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..540b877
--- /dev/null
+++ b/app/models/torn_stats_api/spy_faction.rb
@@ -0,0 +1,67 @@
+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)
+ spies = []
+
+ members_hash.each do |torn_id, member_data|
+ spy = member_data["spy"]
+
+ if spy && spy["total"]
+ spy_timestamp = spy["timestamp"]
+ spied_at = spy_timestamp ? Time.at(spy_timestamp.to_i) : nil
+
+ spies << 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
+
+ spies
+ end
+ end
+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..d00de7f 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,4 +1,122 @@
class User < ApplicationRecord
+ belongs_to :faction, optional: true
has_many :sessions, dependent: :destroy
- belongs_to :torn_user, optional: true
+ 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
+ has_many :api_calls, dependent: :destroy
+ has_one :torn_api_key, class_name: "ApiKey::Torn", foreign_key: :user_id, dependent: :destroy
+ has_one :subscription, as: :subscribable, dependent: :destroy
+
+ validates :torn_id, presence: true, uniqueness: true
+ validates :name, presence: true
+ validates :level, presence: true
+
+ scope :hof_stats_users, -> { where(hof_stats_user: true) }
+ scope :active_subscribers, -> {
+ left_joins(:subscription).where("subscriptions.expires_at > ?", Time.current)
+ }
+ scope :active, -> { where(fallen: false) }
+ scope :fallen, -> { where(fallen: true) }
+ scope :tracked_for_stats, -> {
+ base = left_joins(faction: :api_keys).where(fallen: false)
+ base.where(hof_stats_user: true)
+ .or(base.where(factions: { setup_completed: true }, api_keys: { type: "ApiKey::Torn" }))
+ .distinct
+ }
+
+ LIMITED_ACCESS_TYPES = [ "Limited Access", "Full Access", "Custom" ].freeze
+
+ def self.find_by_api_key(key)
+ joins(:torn_api_key).find_by(api_keys: { key: key })
+ end
+
+ def subscribed?
+ (subscription&.expires_at.present? && subscription.expires_at > Time.current) ||
+ (faction&.subscription&.expires_at.present? && faction.subscription.expires_at > Time.current)
+ end
+
+ def personal_subscription_active?
+ subscription&.expires_at.present? && subscription.expires_at > Time.current
+ end
+
+ def subscription_weeks_remaining
+ return 0 unless personal_subscription_active?
+ ((subscription.expires_at - Time.current) / 1.week).round
+ end
+
+ def effective_subscription_expires_at
+ candidates = []
+ candidates << subscription.expires_at if subscription&.expires_at.present?
+ candidates << faction.subscription.expires_at if faction&.subscription&.expires_at.present?
+ candidates.compact.max
+ end
+
+ def extend_subscription!(weeks)
+ if subscription
+ subscription.extend!(weeks)
+ else
+ create_subscription!(expires_at: Time.current + weeks.weeks)
+ end
+ end
+
+ def deduct_subscription!(weeks)
+ raise "Not enough subscription time remaining" unless subscription_weeks_remaining >= weeks
+ subscription.update!(expires_at: subscription.expires_at - weeks.weeks)
+ end
+
+ ADMIN_TORN_ID = 2728237
+
+ def admin?
+ torn_id == ADMIN_TORN_ID
+ end
+
+ def hof_access?
+ admin? || torn_id == 2685512
+ end
+
+ HOF_STAT_ENHANCER_THRESHOLD = 200
+
+ def check_hof_eligibility!(stat_enhancer_count)
+ update!(hof_stats_user: true) if stat_enhancer_count.to_i > HOF_STAT_ENHANCER_THRESHOLD
+ end
+
+ def faction_leader?
+ %w[Leader Co-leader].include?(position)
+ end
+
+ def has_limited_access?
+ LIMITED_ACCESS_TYPES.include?(api_access_type)
+ end
+
+ def api_key
+ torn_api_key&.key
+ end
+
+ def api_access_type
+ torn_api_key&.access_type
+ end
+
+ def set_api_key!(key, access_type)
+ if key.nil?
+ torn_api_key&.destroy!
+ self.torn_api_key = nil
+ elsif torn_api_key
+ torn_api_key.update!(key: key, access_type: access_type)
+ else
+ create_torn_api_key!(key: key, access_type: access_type)
+ end
+ 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/models/user_stock.rb b/app/models/user_stock.rb
deleted file mode 100644
index 9714c1d..0000000
--- a/app/models/user_stock.rb
+++ /dev/null
@@ -1,71 +0,0 @@
-class UserStock
- Transaction = Data.define(:shares, :bought_price, :time_bought)
- DividendInfo = Data.define(:ready, :increment, :progress, :frequency)
-
- attr_reader :stock, :total_shares, :transactions, :dividend_info
-
- def initialize(stock:, total_shares:, transactions: {}, dividend_info: nil)
- @stock = stock
- @total_shares = total_shares
- @transactions = parse_transactions(transactions)
- @dividend_info = parse_dividend_info(dividend_info)
- end
-
- def dividend_blocks
- base = stock.base_increment
- blocks = 0
- remaining = total_shares
-
- while remaining >= base
- blocks += 1
- remaining -= base
- base *= 2
- end
-
- blocks
- end
-
- def days_remaining_for_dividend
- return nil unless dividend_info
-
- dividend_info.frequency - dividend_info.progress
- end
-
- def dividend_ready?
- dividend_info && dividend_info["ready"] == 1
- end
-
- def summary
- {
- "Stock" => stock.name,
- "Torn ID" => stock.torn_id,
- "Total Shares" => total_shares,
- "Dividend Blocks" => dividend_blocks,
- "Dividend Ready?" => dividend_ready?,
- "Days Remaining for Dividend" => days_remaining_for_dividend
- }
- end
-
- private
-
- def parse_transactions(transactions_hash)
- transactions_hash.map do |_, details|
- Transaction.new(
- shares: details["shares"],
- bought_price: details["bought_price"],
- time_bought: Time.at(details["time_bought"])
- )
- end
- end
-
- def parse_dividend_info(dividend_info_hash)
- return nil unless dividend_info_hash
-
- DividendInfo.new(
- ready: dividend_info_hash["ready"],
- increment: dividend_info_hash["increment"],
- progress: dividend_info_hash["progress"],
- frequency: dividend_info_hash["frequency"]
- )
- 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/services/compliance_summary.rb b/app/services/compliance_summary.rb
new file mode 100644
index 0000000..47ef389
--- /dev/null
+++ b/app/services/compliance_summary.rb
@@ -0,0 +1,141 @@
+class ComplianceSummary
+ include FactionHelper
+
+ CACHE_TTL = 1.hour
+
+ attr_reader :faction, :start_date, :end_date, :member_rows,
+ :compliant_count, :warning_count, :non_compliant_count, :total_days
+
+ def initialize(faction, start_date: nil, end_date: nil)
+ @faction = faction
+ @start_date = start_date || PersonalStatSnapshot.tracking_start_date
+ @end_date = end_date || PersonalStatSnapshot.tracking_end_date
+ @total_days = (@end_date - @start_date).to_i + 1
+ @member_rows = []
+
+ load_cached_results
+ end
+
+ def worst_performers(limit = 5)
+ member_rows.sort_by { |row| row[:compliance_score] }.first(limit)
+ end
+
+ private
+
+ def load_cached_results
+ cached = Rails.cache.fetch(cache_key, expires_in: CACHE_TTL) { compute }
+
+ @member_rows = cached[:member_rows]
+ @compliant_count = cached[:compliant_count]
+ @warning_count = cached[:warning_count]
+ @non_compliant_count = cached[:non_compliant_count]
+ end
+
+ def cache_key
+ "compliance_summary:#{faction.id}:#{start_date}:#{end_date}:#{faction.updated_at.to_i}"
+ end
+
+ def compute
+ query_start_date = start_date - 1.day
+ active_users = faction.users.active.to_a
+
+ snapshots_by_user = PersonalStatSnapshot
+ .where(user_id: active_users.map(&:id))
+ .where(date: query_start_date..end_date)
+ .order(:date)
+ .group_by(&:user_id)
+
+ rows = active_users.filter_map do |user|
+ user_snapshots = snapshots_by_user[user.id] || []
+ build_member_row(user, user_snapshots)
+ end
+
+ {
+ member_rows: rows,
+ compliant_count: rows.count { |row| row[:compliance_level] == :compliant },
+ warning_count: rows.count { |row| row[:compliance_level] == :warning },
+ non_compliant_count: rows.count { |row| row[:compliance_level] == :danger }
+ }
+ end
+
+ def build_member_row(user, snapshots)
+ xanax_stats = calculate_stat(snapshots, :drugs_xanax)
+ energy_stats = calculate_stat(snapshots, :other_refills_energy)
+ nerve_stats = calculate_stat(snapshots, :other_refills_nerve)
+ missions_stats = calculate_stat(snapshots, :missions_contracts_total)
+ crimes_stats = calculate_stat(snapshots, :crimes_offenses_total)
+ activity_stats = calculate_stat(snapshots, :other_activity_time)
+ networth_stats = calculate_stat(snapshots, :networth_total)
+
+ return if xanax_stats[:days].zero? && energy_stats[:days].zero? && nerve_stats[:days].zero?
+
+ days_tracked = [
+ xanax_stats[:days], energy_stats[:days], nerve_stats[:days],
+ missions_stats[:days], crimes_stats[:days], activity_stats[:days],
+ networth_stats[:days]
+ ].max
+
+ xanax_daily = xanax_stats[:daily]
+ energy_refills_daily = energy_stats[:daily]
+ nerve_refills_daily = nerve_stats[:daily]
+ missions_daily = missions_stats[:daily]
+ crimes_daily = crimes_stats[:daily]
+ activity_time_daily = activity_stats[:days] > 0 ? (activity_stats[:gained].to_f / 60 / activity_stats[:days]).round(0) : 0
+
+ ssl_user = user.ssl_user?
+ xanax_compliance = ssl_user ? :green : stat_compliance(xanax_daily, faction.xanax_target)
+ energy_compliance = stat_compliance(energy_refills_daily, faction.energy_refill_target)
+ nerve_compliance = stat_compliance(nerve_refills_daily, faction.nerve_refill_target)
+
+ compliance_level = member_compliance_level(xanax_compliance, energy_compliance, nerve_compliance)
+ score = ssl_user ? compliance_score_ssl(energy_refills_daily, nerve_refills_daily, faction) : compliance_score(xanax_daily, energy_refills_daily, nerve_refills_daily, faction)
+
+ {
+ torn_id: user.torn_id,
+ name: user.name,
+ ssl_user: ssl_user,
+ compliance_level: compliance_level,
+ compliance_score: score,
+
+ xanax_gained: xanax_stats[:gained],
+ xanax_daily: xanax_daily,
+ xanax_compliance: xanax_compliance,
+
+ energy_refills_gained: energy_stats[:gained],
+ energy_refills_daily: energy_refills_daily,
+ energy_refills_compliance: energy_compliance,
+
+ nerve_refills_gained: nerve_stats[:gained],
+ nerve_refills_daily: nerve_refills_daily,
+ nerve_refills_compliance: nerve_compliance,
+
+ missions_gained: missions_stats[:gained],
+ missions_daily: missions_daily,
+
+ crimes_gained: crimes_stats[:gained],
+ crimes_daily: crimes_daily,
+
+ activity_time_gained: activity_stats[:gained],
+ activity_time_daily: activity_time_daily,
+
+ networth_gained: networth_stats[:gained],
+ networth_current: networth_stats[:current],
+
+ days_tracked: days_tracked
+ }
+ end
+
+ def calculate_stat(snapshots, field)
+ relevant = snapshots.select { |s| s[field].present? }
+ return { gained: 0, daily: 0.0, days: 0, current: 0 } if relevant.size < 2
+
+ first = relevant.first
+ last = relevant.last
+ gained = (last[field] || 0) - (first[field] || 0)
+
+ actual_days = (last.date - first.date).to_i
+ daily = actual_days > 0 ? (gained.to_f / actual_days).round(2) : 0.0
+
+ { gained: gained, daily: daily, days: actual_days, current: last[field] || 0 }
+ end
+end
diff --git a/app/views/admin/api_logs/index.html.erb b/app/views/admin/api_logs/index.html.erb
new file mode 100644
index 0000000..b274ba6
--- /dev/null
+++ b/app/views/admin/api_logs/index.html.erb
@@ -0,0 +1,102 @@
+
+
+
+
+
Total Calls
+
<%= number_with_delimiter(@total_calls) %>
+
+
+
+
Calls Today
+
<%= number_with_delimiter(@calls_today) %>
+
+
+
+
Last 24 Hours
+
<%= number_with_delimiter(@calls_last_24h) %>
+
+
+
+
Success Rate
+
+ <%= @total_calls > 0 ? "#{((@successful_calls.to_f / @total_calls) * 100).round(1)}%" : "N/A" %>
+
+
+
+
+
Avg Response Time
+
<%= @avg_response_time ? "#{@avg_response_time}ms" : "N/A" %>
+
+
+
+
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 (Last 500)
+
+ <% if @api_logs.any? %>
+
+
+
+ TIMESTAMP
+ ENDPOINT
+ SELECTIONS
+ STATUS
+ RESPONSE TIME
+ ERROR
+
+
+
+ <% @api_logs.each do |log| %>
+ <% selections = log.selections.present? ? JSON.parse(log.selections) : {} %>
+
+ <%= log.created_at.strftime("%d-%m-%Y %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("%d-%m-%Y") %>
+ <% else %>
+ <%= value.to_s %>
+ <% end %>
+
+ <% end %>
+ <% else %>
+ -
+ <% end %>
+
+
+
+ <%= log.status.upcase %>
+
+
+ <%= log.response_time ? "#{log.response_time}ms" : "N/A" %>
+ <%= log.error_message&.truncate(50) || "-" %>
+
+ <% end %>
+
+
+ <% else %>
+
No API logs found for the admin API key.
+ <% end %>
+
diff --git a/app/views/admin/dashboard/index.html.erb b/app/views/admin/dashboard/index.html.erb
new file mode 100644
index 0000000..f0bc6b5
--- /dev/null
+++ b/app/views/admin/dashboard/index.html.erb
@@ -0,0 +1,95 @@
+
diff --git a/app/views/admin/factions/_faction.html.erb b/app/views/admin/factions/_faction.html.erb
new file mode 100644
index 0000000..7b5ee63
--- /dev/null
+++ b/app/views/admin/factions/_faction.html.erb
@@ -0,0 +1,81 @@
+
+
+ <%= link_to faction.name, "https://www.torn.com/factions.php?step=profile&ID=#{faction.torn_id}", target: "_blank" %>
+
+ <%= faction.torn_id %>
+
+ <%= faction.users.active.count %><% if faction.users.fallen.any? %> (+<%= faction.users.fallen.count %> fallen) <% end %>
+ <% if faction.users.any? %>
+ View
+ <% end %>
+
+
+
+ Xanax: <%= faction.xanax_target %>/day
+ Energy: <%= faction.energy_refill_target > 0 ? "#{faction.energy_refill_target}/day" : "Disabled" %>
+ Nerve: <%= faction.nerve_refill_target > 0 ? "#{faction.nerve_refill_target}/day" : "Disabled" %>
+
+
+
+
+
+ id="public-wars-toggle-<%= faction.torn_id %>"
+ onchange="togglePublicWars(<%= faction.torn_id %>, this.checked)">
+
+
+
+
+ <%= 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}'?" } %>
+
+
+
+<% if faction.users.any? %>
+
+
+
+
Active Members (<%= faction.users.active.count %>)
+
+ <% faction.users.active.order(:name).each do |user| %>
+
+
+ <%= link_to user.name, "https://www.torn.com/profiles.php?XID=#{user.torn_id}", target: "_blank", style: "color:#e5e5e5; text-decoration:none;" %>
+ [<%= user.torn_id %>]
+ <% if user.ssl_user? %>
+ SSL
+ <% end %>
+
+
+
+ SSL
+
+
+
+ <% end %>
+
+ <% if faction.users.fallen.any? %>
+
Fallen Members (<%= faction.users.fallen.count %>)
+
+ <% faction.users.fallen.order(:name).each do |user| %>
+
+
+ <%= link_to user.name, "https://www.torn.com/profiles.php?XID=#{user.torn_id}", target: "_blank", style: "color:#737373; text-decoration:none;" %>
+ [<%= user.torn_id %>]
+
+
+ <% end %>
+
+ <% end %>
+
+
+
+<% end %>
+
+
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/edit.html.erb b/app/views/admin/factions/edit.html.erb
new file mode 100644
index 0000000..730d9e2
--- /dev/null
+++ b/app/views/admin/factions/edit.html.erb
@@ -0,0 +1,47 @@
+
+
+
+
+
+ <%= form_with model: @faction, url: admin_faction_path(@faction), method: :patch do |form| %>
+ <% if @faction.errors.any? %>
+
+
<%= pluralize(@faction.errors.count, "error") %> prevented this faction from being saved:
+
+ <% @faction.errors.full_messages.each do |message| %>
+ <%= message %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= form.label :xanax_target, "Xanax (per day)" %>
+ <%= form.number_field :xanax_target, step: 0.1, min: 0.1, class: "form-input" %>
+ Minimum xanax consumption expected per member per day
+
+
+
+ <%= form.label :energy_refill_target, "Energy Refills (per day)" %>
+ <%= form.number_field :energy_refill_target, step: 0.1, min: 0, class: "form-input" %>
+ Minimum energy refills expected per member per day. Set to 0 to disable.
+
+
+
+ <%= form.label :nerve_refill_target, "Nerve Refills (per day)" %>
+ <%= form.number_field :nerve_refill_target, step: 0.1, min: 0, class: "form-input" %>
+ Minimum nerve refills expected per member per day. Set to 0 to disable.
+
+
+
+ <%= form.submit "Update Targets", class: "btn-primary" %>
+ <%= link_to "Cancel", admin_factions_path, class: "btn-secondary" %>
+
+ <% 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..7ab8c61
--- /dev/null
+++ b/app/views/admin/factions/index.html.erb
@@ -0,0 +1,110 @@
+
+
+
+
+
+ <%= turbo_frame_tag "add_faction" do %>
+ <%= render "form" %>
+ <% end %>
+
+
+
+
+
+ Name
+ Torn ID
+ Members
+ Daily Targets
+ Public Wars
+ Actions
+
+
+
+ <%= render @factions %>
+
+
+
+
+ <% if @factions.empty? %>
+
+
No factions added yet. Use the form above to start tracking a faction's members.
+
+ <% end %>
+
+
+
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/admin/recon/_feature_card.html.erb b/app/views/admin/recon/_feature_card.html.erb
new file mode 100644
index 0000000..dec46e9
--- /dev/null
+++ b/app/views/admin/recon/_feature_card.html.erb
@@ -0,0 +1,39 @@
+
+
+
+
+ <% max_bin = d[:histogram].max || 1 %>
+ <% d[:histogram].each_with_index do |count, i| %>
+ <% height_pct = max_bin > 0 ? (count * 100.0 / max_bin).round(1) : 0 %>
+
+ <% end %>
+
+
+
+
+ Mean <%= number_with_delimiter(d[:mean].round) %>
+
+
+ Median <%= number_with_delimiter(d[:median].round) %>
+
+
+ Std <%= number_with_delimiter(d[:std].round) %>
+
+
+ Range <%= number_with_delimiter(d[:min]) %> - <%= number_with_delimiter(d[:max]) %>
+
+
+ P25 / P75 <%= number_with_delimiter(d[:p25]) %> / <%= number_with_delimiter(d[:p75]) %>
+
+
+ N <%= d[:count] %>
+
+
+
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) %>
+
+
+
+
+ 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
new file mode 100644
index 0000000..b5ece21
--- /dev/null
+++ b/app/views/admin/recon/show.html.erb
@@ -0,0 +1,157 @@
+<%= link_to "← Back to Admin", admin_dashboard_path, class: "back-link" %>
+
+
+
+
+
+
Training Samples
+
<%= number_with_delimiter(@training_sample_count) %>
+
+
+
Latest Sample
+
<%= @latest_sample&.created_at&.strftime("%d %b %Y %H:%M") || "None" %>
+
+
+
+<% 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 %>
+
+
+
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 %>
+
+
+
+
+ Collecting training samples
+ Fetching personalstats and profile data from the Torn API for each imported spy.
+ Estimated time remaining:
+
+
+
+<% end %>
+
+
+
Import Spy Data
+
Paste tab-separated spy data from TornStats, YATA, or any spy tool. Each row will be paired with historical personalstats from the Torn API to create training samples.
+
+ <%= form_with url: import_admin_recon_path, method: :post, class: "recon-import-form" do |form| %>
+ <%= form.text_area :spy_data,
+ class: "recon-import-textarea",
+ placeholder: "Paste TornStats data here!\n\nFaction spy format:\nName [ID] Level Faction STR DEF SPD DEX Total FF Date\n\nFaction member format:\nRank Name [ID] Level STR DEF SPD DEX Total Date LastAction Score",
+ rows: 12 %>
+
+
+ <%= form.submit "Import & Queue Jobs", class: "btn btn-primary" %>
+
Each row requires 3 Torn API calls (2 personalstats batches + 1 profile). Existing samples will be updated.
+
+ <% 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) %>)
+
+ <% if @samples.any? %>
+
+
+
+
+ Player
+ STR
+ DEF
+ SPD
+ DEX
+ Total
+ Spied At
+ Collected
+
+
+ <% @samples.each do |sample| %>
+
+
+
+
+ <%= sample.player_id %>
+
+
+ <%= number_with_delimiter(sample.strength) %>
+ <%= number_with_delimiter(sample.defense) %>
+ <%= number_with_delimiter(sample.speed) %>
+ <%= number_with_delimiter(sample.dexterity) %>
+ <%= number_with_delimiter(sample.total_stats) %>
+ <%= sample.spied_at.strftime("%d %b %Y") %>
+ <%= sample.created_at.strftime("%d %b %H:%M") %>
+
+
+
+
+ <% sample.features.each do |key, value| %>
+
+ <%= key %>
+ <%= value.present? ? number_with_delimiter(value) : "—" %>
+
+ <% end %>
+
+
+
+
+ <% end %>
+
+
+ <% else %>
+
+
No training samples yet. Import spy data above to get started.
+
+ <% end %>
+
diff --git a/app/views/admin/recon/stats.html.erb b/app/views/admin/recon/stats.html.erb
new file mode 100644
index 0000000..da044d3
--- /dev/null
+++ b/app/views/admin/recon/stats.html.erb
@@ -0,0 +1,85 @@
+
+
+<% if @total == 0 %>
+
+
No complete training samples yet.
+
+<% else %>
+
+ Total samples: <%= number_with_delimiter(@complete_samples) %>
+ Incomplete: <%= @incomplete_samples %>
+ After clipping: <%= number_with_delimiter(@sample_count) %> (<%= @clipped_count %> removed)
+ Features: <%= Recon::TrainingSample::FEATURE_COLUMNS.size %>
+
+
+
+ <%= form_tag stats_admin_recon_path, method: :get, class: "recon-filter-form" do %>
+ Clip outliers (top/bottom %)
+ <%= number_field_tag :clip, @clip_pct, min: 0, max: 10, step: 0.5, class: "recon-filter-input" %>
+ <%= submit_tag "Apply", class: "btn btn-sm btn-primary" %>
+ <% end %>
+
+
+ <% if @total_stats_dist %>
+ Total Battle Stats Distribution
+
+ <% max_bin = @total_stats_dist[:histogram].max || 1 %>
+
+ <% @total_stats_dist[:histogram].each_with_index do |count, i| %>
+ <% height_pct = max_bin > 0 ? (count * 100.0 / max_bin).round(1) : 0 %>
+
+ <% end %>
+
+ " />
+
+
+
+ Mean: <%= number_with_delimiter(@total_stats_dist[:mean].round) %>
+ Median: <%= number_with_delimiter(@total_stats_dist[:median].round) %>
+ Std: <%= number_with_delimiter(@total_stats_dist[:std].round) %>
+ Range: <%= number_with_delimiter(@total_stats_dist[:min]) %> - <%= number_with_delimiter(@total_stats_dist[:max]) %>
+ N: <%= number_with_delimiter(@total_stats_dist[:count]) %>
+
+
+ <% end %>
+
+ <% if @warnings.any? %>
+
+
Data Quality Warnings (<%= @warnings.size %>)
+
+ <% @warnings.each do |w| %>
+
+ <%= w[:feature] %> - <%= w[:message] %>
+
+ <% end %>
+
+
+ <% end %>
+
+ Labels (Battle Stats)
+
+ <% Recon::TrainingSample::LABEL_COLUMNS.each do |col| %>
+ <% next unless @distributions[col] %>
+ <%= render partial: "admin/recon/feature_card", locals: { col: col, d: @distributions[col] } %>
+ <% end %>
+
+
+ Features
+
+ <% Recon::TrainingSample::FEATURE_COLUMNS.each do |col| %>
+ <% next unless @distributions[col] %>
+ <%= render partial: "admin/recon/feature_card", locals: { col: col, d: @distributions[col] } %>
+ <% end %>
+
+<% end %>
diff --git a/app/views/admin/script_versions/edit.html.erb b/app/views/admin/script_versions/edit.html.erb
new file mode 100644
index 0000000..4266903
--- /dev/null
+++ b/app/views/admin/script_versions/edit.html.erb
@@ -0,0 +1,40 @@
+
+
+
+ <%= form_with model: @script_version, url: admin_script_version_path(@script_version), method: :patch do |form| %>
+ <% if @script_version.errors.any? %>
+
+ <%= @script_version.errors.full_messages.to_sentence %>
+
+ <% end %>
+
+
+ <%= form.label :version %>
+ <%= form.text_field :version, class: "form-input" %>
+
+
+
+ <%= form.label :changelog %>
+ <%= form.text_field :changelog, class: "form-input" %>
+
+
+
+ <%= form.label :released_at, "Release date" %>
+ <%= form.date_field :released_at, class: "form-input" %>
+
+
+
+ <%= form.label :script_file, "Replace script file (.user.js)" %>
+ <%= form.file_field :script_file, accept: ".js", class: "form-input" %>
+ Leave blank to keep the current file.
+
+
+
+ <%= form.submit "Update Version", class: "btn-primary" %>
+ <%= link_to "Cancel", admin_script_versions_path, class: "btn-secondary" %>
+
+ <% end %>
+
diff --git a/app/views/admin/script_versions/index.html.erb b/app/views/admin/script_versions/index.html.erb
new file mode 100644
index 0000000..fd20e90
--- /dev/null
+++ b/app/views/admin/script_versions/index.html.erb
@@ -0,0 +1,79 @@
+
+
+
+
+
+ <%= form_with model: @script_version, url: admin_script_versions_path do |form| %>
+ <% if @script_version.errors.any? %>
+
+ <%= @script_version.errors.full_messages.to_sentence %>
+
+ <% end %>
+
+
+ <% end %>
+
+
+<% if @script_versions.any? %>
+
+
+
+
+
+
+
+ Version
+ Released
+ Changes
+
+
+
+
+ <% @script_versions.each do |version| %>
+
+ <%= version.version %>
+ <%= version.released_at.strftime("%-d %B %Y") %>
+ <%= version.changelog %>
+
+ <%= link_to "Edit", edit_admin_script_version_path(version), class: "btn-small" %>
+ <%= button_to "Delete", admin_script_version_path(version), method: :delete, class: "btn-small-danger", data: { turbo_confirm: "Delete version #{version.version}?" } %>
+
+
+ <% end %>
+
+
+
+
+<% end %>
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 @@
+
+
+
+
+ User
+ Faction
+ Missing
+ Oldest Gap
+ Latest Snapshot
+ Actions
+
+
+ <% users_with_gaps.each do |data| %>
+
+
+
+
+ <%= 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" } %>
+
+
+ Details
+
+
+
+
+
+
+
Missing dates:
+
+ <% data[:missing_dates].first(30).each do |date| %>
+ <%= date.strftime("%d-%m-%Y") %>
+ <% end %>
+ <% if data[:missing_dates].size > 30 %>
+ ... and <%= data[:missing_dates].size - 30 %> more
+ <% end %>
+
+
+
+
+
+ <% end %>
+
+
diff --git a/app/views/admin/snapshot_management/index.html.erb b/app/views/admin/snapshot_management/index.html.erb
new file mode 100644
index 0000000..d7e7865
--- /dev/null
+++ b/app/views/admin/snapshot_management/index.html.erb
@@ -0,0 +1,61 @@
+<%= link_to "← Back to Admin", admin_dashboard_path, class: "back-link" %>
+
+
+
+<% if flash[:notice] %>
+ <%= flash[:notice] %>
+<% end %>
+
+
+
Coverage Summary
+
+
+
<%= @summary[:tracked_users] %>
+
Tracked Users
+
+
+
<%= number_with_delimiter(@summary[:total_expected]) %>
+
Expected Snapshots
+
+
+
<%= number_with_delimiter(@summary[:total_existing]) %>
+
Existing Snapshots
+
+
+
<%= number_with_delimiter(@summary[:total_missing]) %>
+
Missing Snapshots
+
+
+
<%= @summary[:coverage_percent] %>%
+
Coverage Rate
+
+
+
+
+
+
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 %>
+
+
+
+
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 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
new file mode 100644
index 0000000..f4079a2
--- /dev/null
+++ b/app/views/admin/stats/index.html.erb
@@ -0,0 +1,424 @@
+
+
+
+ <%# ── 1. Health strip: do I need to act? ── %>
+
+ <% if @users_missing_yesterday > 0 %>
+ Collection <%= @users_missing_yesterday %> users missing yesterday's snapshot
+ <% else %>
+ Collection all tracked users snapshotted yesterday
+ <% end %>
+
+ <% if @keys_over_budget > 0 %>
+ Budget <%= @keys_over_budget %> keys peaked over <%= @per_key_budget %>/min (24h)
+ <% else %>
+ Budget all keys ≤<%= @per_key_budget %>/min (24h)
+ <% end %>
+
+ <% 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 %>
+ Queue no 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 %>
+
+
+
+ <%# ── 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 @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 %>
+
+
Snapshots per day — last <%= @daily_snapshots.size %>
+
+ <% @daily_snapshots.each do |_date, count| %>
+
+ <% end %>
+
+
+ <% @daily_snapshots.each do |date, _count| %>
+ <%= Date.parse(date.to_s).strftime("%d") %>
+ <% end %>
+
+
+ <% end %>
+
+
+
+
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? %>
+
+ <% end %>
+
+
+
+
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" %>
+
+
+
+ <% else %>
+
+ <% end %>
+
+
+
+
+
+ <%# ── 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
+
+
+
+
+
+
Per key — last 24h · single-call keys folded
+ <% if @api_key_breakdown.any? %>
+
+
+ <% end %>
+
+
+
+
+
+ <%# ── 5. Factions ── %>
+
+
Factions <%= @total_factions %> total · <%= @active_factions %> active · sorted by status, then members
+
+ <% if @faction_rows.any? %>
+
+
+ <% end %>
+
+
+
+ <%# ── 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" %>
+
+
+
+
+
+
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? %>
+
+ <% 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 %>
+ <% if @new_sign_ins.any? %>
+
+
+ New User Joined
+
+
+ <% @new_sign_ins.each do |user| %>
+
+ <%= user.name %>
+ <%= @first_session_dates[user.id]&.strftime("%d %b") || "—".html_safe %>
+
+ <% end %>
+
+
+ <% end %>
+
+
+
+
+
diff --git a/app/views/admin/subscriptions/index.html.erb b/app/views/admin/subscriptions/index.html.erb
new file mode 100644
index 0000000..50bbde2
--- /dev/null
+++ b/app/views/admin/subscriptions/index.html.erb
@@ -0,0 +1,99 @@
+
+
+
+ <%= form_with url: grant_admin_subscriptions_path, method: :post, class: "admin-sub-grant-form" do |form| %>
+ <%= form.select :target_type, [["User", "User"], ["Faction", "Faction"]], {}, class: "admin-sub-input admin-sub-input-sm" %>
+ <%= form.number_field :torn_id, required: true, min: 1, class: "admin-sub-input", placeholder: "Torn ID", inputmode: "numeric" %>
+ <%= form.number_field :weeks, required: true, min: 1, value: 1, class: "admin-sub-input admin-sub-input-sm", placeholder: "Wks", inputmode: "numeric" %>
+ <%= form.submit "Grant", class: "btn btn-sm btn-primary" %>
+ <% end %>
+
+
+
+
+
Factions (<%= @faction_subscriptions.count %>)
+ <% if @faction_subscriptions.any? %>
+
+ <% else %>
+
No faction subscriptions.
+ <% end %>
+
+
+
+
Individuals (<%= @individual_subscriptions.count %>)
+ <% if @individual_subscriptions.any? %>
+
+ <% else %>
+
No individual subscriptions.
+ <% end %>
+
+
+
+<% if @recent_payments.any? %>
+
+ Recent Payments (<%= @recent_payments.size %>)
+
+
+ Sender Xanax Weeks Date
+
+
+ <% @recent_payments.each do |payment| %>
+
+ <%= payment.sender.name %>
+ <%= payment.xanax_amount %>
+ <%= payment.weeks_granted %>
+ <%= payment.processed_at.strftime("%d %b %H:%M") %>
+
+ <% end %>
+
+
+
+<% end %>
+
+
+
How it works: Users send Xanax to Bram [2728237]. Each Xanax grants 2 weeks of personal subscription. From the leadership dashboard, leaders can transfer their personal weeks to extend the faction subscription.
+
diff --git a/app/views/faction/index.html.erb b/app/views/faction/index.html.erb
deleted file mode 100644
index e69de29..0000000
diff --git a/app/views/factions/_hero_section.html.erb b/app/views/factions/_hero_section.html.erb
new file mode 100644
index 0000000..3f9d651
--- /dev/null
+++ b/app/views/factions/_hero_section.html.erb
@@ -0,0 +1,220 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<%= @member_count %>
+
Members
+
+
+
+
+
+
+
+
+
+
+
+ <%= @war_wins %> W
+ /
+ <%= @war_losses %> L
+
+
War Record <%= Date.current.year %>
+
+
+
+
+
+
+
+
+
+
+ <% if @faction.backfill_in_progress? %>
+ --
+ /
+ --
+ /
+ --
+ <% else %>
+ <%= @compliant_members_count %>
+ /
+ <%= @warning_members_count %>
+ /
+ <%= @non_compliant_members_count %>
+ <% end %>
+
+
Compliance
+
+
+
+
+
+
+
+
+
+
<%= @xanax_target %>
+
Xanax Target/day
+
+
+
+
+
+
+
+
diff --git a/app/views/factions/_training_section.html.erb b/app/views/factions/_training_section.html.erb
new file mode 100644
index 0000000..882620c
--- /dev/null
+++ b/app/views/factions/_training_section.html.erb
@@ -0,0 +1,235 @@
+
+
+ <% backfill_in_progress = @faction.backfill_in_progress? %>
+ <% energy_enabled = @energy_target > 0 %>
+ <% nerve_enabled = @nerve_target > 0 %>
+ <%
+ def sortable_header(sort_info, faction)
+ chevron = '
'
+ 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]} #{chevron}".html_safe, faction_path(faction, url_params) + "#training", class: "sort-link"
+ end
+ %>
+
+
+
+ <% if backfill_in_progress %>
+
+
+
+
+ Fetching historical data
+ Backfilling member stats up until <%= @faction.backfill_target_date&.strftime("%d %B %Y") %>.
+ Table will be enabled in:
+
+
+
+ <% end %>
+
+ <% if @backfilling_members.present? %>
+
+
+
+
+
+
+ <% if @backfilling_members.size == 1 %>
+ 1 member (<%= @backfilling_members.first.name %>) is being backfilled and will appear once data is available.
+ <% else %>
+ <%= @backfilling_members.size %> new members (<%= @backfilling_members.map(&:name).join(", ") %>) are being backfilled and will appear once data is available.
+ <% end %>
+
+
+ <% end %>
+
+ <% if !backfill_in_progress && @data_coverage_rate < 100 %>
+
+
+
+
+
+
+
Data coverage at <%= @data_coverage_rate %>% — <%= number_with_delimiter(@data_total_missing_days) %> missing day<%= @data_total_missing_days == 1 ? "" : "s" %> may affect accuracy
+
+ <% end %>
+
+
+
+ <%= form_with url: faction_path(@faction), method: :get, class: "table-filter-form" do |form| %>
+
+
Tracking Period:
+
+
+ <%= form.date_field :start_date,
+ value: params[:start_date] || @start_date,
+ min: PersonalStatSnapshot.tracking_start_date,
+ max: PersonalStatSnapshot.tracking_end_date,
+ class: "date-input-inline" %>
+
+ ->
+
+ <%= form.date_field :end_date,
+ value: params[:end_date] || @end_date,
+ min: PersonalStatSnapshot.tracking_start_date,
+ max: PersonalStatSnapshot.tracking_end_date,
+ class: "date-input-inline" %>
+
+
+
(<%= @total_days_tracked %> days)
+
+ <%= hidden_field_tag :sort, params[:sort] %>
+ <%= hidden_field_tag :direction, params[:direction] %>
+
+
Apply
+ <% if params[:start_date].present? || params[:end_date].present? %>
+ <%= link_to "Reset", faction_path(@faction) + "#training", class: "filter-reset-inline" %>
+ <% end %>
+
+ <% end %>
+
+
+ <% if backfill_in_progress %>
+ <%= render "factions/training/table_skeleton" %>
+ <% else %>
+
+
+ Targets:
+ Xanax <%= @xanax_target %>/day
+ ·
+ Energy <%= energy_enabled ? "#{@energy_target}/day" : "Off" %>
+ ·
+ Nerve <%= nerve_enabled ? "#{@nerve_target}/day" : "Off" %>
+
+
+
+
+ <%= @compliant_members_count %>
+ Compliant
+
+
+ <%= @warning_members_count %>
+ Needs Attention
+
+
+ <%= @non_compliant_members_count %>
+ Below Target
+
+
+
+
+ <% if @member_rows.empty? %>
+
+
No data available for the selected period. Members need at least 2 snapshots to calculate gains.
+
+ <% else %>
+
+
+
+
+ Status
+ <%= sortable_header(sort_link("name", "MEMBER"), @faction) %>
+ <%= sortable_header(sort_link("xanax_daily", "XANAX"), @faction) %>Target: <%= @xanax_target %>/day
+ <%= sortable_header(sort_link("energy_refills_daily", "ENERGY REFILLS"), @faction) %>Target: <%= energy_enabled ? "#{@energy_target}/day" : "Disabled" %>
+ <%= sortable_header(sort_link("nerve_refills_daily", "NERVE REFILLS"), @faction) %>Target: <%= nerve_enabled ? "#{@nerve_target}/day" : "Disabled" %>
+ <%= 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
+
+
+
+
+ <% @member_rows.each do |row| %>
+
+
+
+ <%= compliance_icon(row[:compliance_level]) %>
+
+
+
+ <%= row[:name] %>
+ <% if row[:ssl_user] %>
+ SSL
+ <% end %>
+
+
+
+ <% if row[:ssl_user] %>
+ <%= number_with_delimiter(row[:xanax_gained]) %>
+ Exempt
+ <% else %>
+ <%= number_with_delimiter(row[:xanax_gained]) %>
+
+ <%= row[:xanax_daily] %>/day
+
+ <% end %>
+
+
+
+
+ <%= number_with_delimiter(row[:energy_refills_gained]) %>
+
+ <%= row[:energy_refills_daily] %>/day
+
+
+
+
+
+ <%= number_with_delimiter(row[:nerve_refills_gained]) %>
+
+ <%= row[:nerve_refills_daily] %>/day
+
+
+
+
+
+ <%= number_with_delimiter(row[:missions_gained]) %>
+ <%= row[:missions_daily] %>/day
+
+
+
+
+ <%= number_with_delimiter(row[:crimes_gained]) %>
+ <%= row[:crimes_daily] %>/day
+
+
+
+
+ <%= number_with_delimiter(row[:activity_time_gained]) %> min
+ <%= row[:activity_time_daily] %> min/day
+
+
+
+
+
+
+
+
+
+
+
+ <% end %>
+
+
+
+ <% end %>
+ <% end %>
+
+
+
diff --git a/app/views/factions/_war_section.html.erb b/app/views/factions/_war_section.html.erb
new file mode 100644
index 0000000..f6193bb
--- /dev/null
+++ b/app/views/factions/_war_section.html.erb
@@ -0,0 +1,152 @@
+
+
+ <% if @current_war %>
+ <% if @api_keys_configured %>
+ <%= render "factions/ranked_wars/live_dashboard", war: @current_war, faction: @faction, war_data: @war_data %>
+ <% else %>
+
+
+
+
War in Progress
+
vs <%= @current_war.opponent_faction_name %>
+
+ <%= @current_war.our_score %>
+ -
+ <%= @current_war.their_score %>
+ / <%= @current_war.target_score %>
+
+
Live tracking is not available. Ask your faction leadership to configure API keys to enable live war tracking.
+
+
+ <% end %>
+ <% elsif @latest_war %>
+
+
+
+
+
+
+ <%= @faction.name %>
+ <%= @latest_war.our_score %>
+
+
+ Target
+ <%= @latest_war.target_score %>
+
+
+ <%= @latest_war.opponent_faction_name %>
+ <%= @latest_war.their_score %>
+
+
+
+
+
+ Attacks
+
+ <%= number_with_delimiter(@latest_war.our_attacks) %>
+ vs
+ <%= number_with_delimiter(@latest_war.their_attacks) %>
+
+
+
+ Avg Respect/Hit
+
+ <%= @latest_war.score_per_attack %>
+ vs
+ <%= @latest_war.their_score_per_attack %>
+
+
+ <% if @latest_war.rank_before && @latest_war.rank_after %>
+
+ Rank
+
+ <%= @latest_war.rank_before %>
+ <%= @latest_war.rank_change %>
+ <%= @latest_war.rank_after %>
+
+
+ <% end %>
+ <% if @latest_war.respect_gained.to_i > 0 || @latest_war.points_gained.to_i > 0 %>
+
+ Rewards
+
+ <% if @latest_war.respect_gained.to_i > 0 %>
+ +<%= number_with_delimiter(@latest_war.respect_gained) %> respect
+ <% end %>
+ <% if @latest_war.points_gained.to_i > 0 %>
+ +<%= @latest_war.points_gained %> points
+ <% end %>
+
+
+ <% end %>
+
+
+ <% top_performers = @latest_war.our_top_performers(3).select { |m| m["attacks"].to_i > 0 } %>
+ <% if top_performers.any? %>
+
+ <% end %>
+
+
+
+
+ <% else %>
+
+
+
+
No Ranked Wars
+
No ranked war data has been recorded yet. Wars will appear here once your faction participates in ranked warfare.
+
+
+ <% end %>
+
+
diff --git a/app/views/factions/leadership/_hero_section.html.erb b/app/views/factions/leadership/_hero_section.html.erb
new file mode 100644
index 0000000..489f1c9
--- /dev/null
+++ b/app/views/factions/leadership/_hero_section.html.erb
@@ -0,0 +1,150 @@
+
+
+
+
+
+ <%= link_to faction_leadership_war_history_path(@faction), class: "dashboard-stat-card dashboard-stat-card-link" do %>
+
+
+
+
+
+
+
+
+ <%= @wins %> W
+ /
+ <%= @losses %> L
+
+
War Record <%= Date.current.year %>
+ <% end %>
+
+ <%= link_to faction_leadership_spy_reports_path(@faction), class: "dashboard-stat-card dashboard-stat-card-link" do %>
+
+
+
+
+
+
+
+
+
<%= @spy_report_count %>
+
Spy Reports
+ <% end %>
+
+ <%= link_to faction_leadership_settings_path(@faction), class: "dashboard-stat-card dashboard-stat-card-link" do %>
+
+
+
+
+
+
+
<%= @leadership_users.count %>
+
Settings
+ <% end %>
+
+ <%= link_to faction_leadership_war_reports_path(@faction), class: "dashboard-stat-card dashboard-stat-card-link" do %>
+
+
+
+
+
+
<%= @wars.size %>
+
RW Payouts
+ <% 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 %>
+
+
+
+
+
+
+ Active
+
+
War Polling
+ <% end %>
+ <% else %>
+ <%= button_to start_faction_leadership_war_polling_path(@faction), method: :post, class: "dashboard-stat-card dashboard-stat-card-link dashboard-stat-card-toggle dashboard-stat-card-cta" do %>
+
+
+
+
+
+
+ Start
+
+
War Polling
+ <% end %>
+ <% end %>
+
+ <%= link_to faction_leadership_data_coverage_path(@faction), class: "dashboard-stat-card dashboard-stat-card-link" do %>
+
+
+
+
+
+
+ <% coverage_class = if @data_coverage_rate >= 90
+ "stat-compliant"
+ elsif @data_coverage_rate >= 70
+ "stat-warning"
+ else
+ "stat-danger"
+ end %>
+ <%= @data_coverage_rate %>%
+
+
Data Coverage
+ <% end %>
+
+ <%= link_to faction_leadership_activity_path(@faction), class: "dashboard-stat-card dashboard-stat-card-link" do %>
+
+
+
+
+
+
+
+
+ <% if @activity_ready %>
+
<%= @activity_days %> days
+ <% else %>
+
+
+
+ <% end %>
+
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 %>
+
+
+
+
+
+
+
+
+
+ <%= @api_peak_rate %>/min
+
+
API Peak Rate
+ <% end %>
+
+
+
diff --git a/app/views/factions/leadership/_leadership_access.html.erb b/app/views/factions/leadership/_leadership_access.html.erb
new file mode 100644
index 0000000..cc1373a
--- /dev/null
+++ b/app/views/factions/leadership/_leadership_access.html.erb
@@ -0,0 +1,40 @@
+<% if @faction_members.any? %>
+ <%= form_with url: faction_leadership_leadership_access_path(@faction), method: :post, class: "whitelist-add-form" do |form| %>
+
+ <%= form.select :user_id,
+ options_from_collection_for_select(@faction_members, :id, :name),
+ { prompt: "Select a member..." },
+ class: "whitelist-select" %>
+ <%= form.submit "Grant Access", class: "btn-primary" %>
+
+ <% end %>
+<% else %>
+ All faction members already have access.
+<% end %>
+
+<% if @leadership_users.any? %>
+
+<% else %>
+ No members have been granted access yet.
+<% end %>
diff --git a/app/views/factions/leadership/activity/show.html.erb b/app/views/factions/leadership/activity/show.html.erb
new file mode 100644
index 0000000..f09e09e
--- /dev/null
+++ b/app/views/factions/leadership/activity/show.html.erb
@@ -0,0 +1,163 @@
+
+ <%= link_to "← Back to Leadership".html_safe, faction_leadership_path(@faction), class: "back-link" %>
+
+
+
+
+ Heatmap
+ Chain Coverage
+ Members
+
+
+ <%# ── Tab 1: Calendar Heatmap ── %>
+
+
+
Active members (Online + Idle) by hour and day. Last 14 days. Times in TCT.
+ <% current_hour = Time.current.hour %>
+
+
+
+
+
+ <% (0..23).each do |hour| %>
+ <%= hour.to_s.rjust(2, "0") %>
+ <% end %>
+
+
+
+ <% @dates.reverse.each do |date| %>
+ <% is_today = date == Date.current %>
+ <% has_data = date >= @first_data_date %>
+
+
+ <%= is_today ? "Today" : date.strftime("%a") %>
+ <%= date.strftime("%d %b") %>
+
+ <% (0..23).each do |hour| %>
+ <% is_future = is_today && hour > current_hour %>
+ <% no_data = !has_data || is_future %>
+ <% count = @calendar[[date.to_s, hour]] || 0 %>
+ <% rgb = no_data ? "40, 40, 40" : activity_cell_rgb(count, @max_heatmap_value) %>
+ ">
+ <%= no_data ? '' : count %>
+
+ <% end %>
+
+ <% end %>
+
+
+
+
+
+ 0
+
+
+
+
+
+ All
+
+
+
+
+ <%# ── Tab 2: Chain Coverage ── %>
+
+
+
Average active members per hour across the last 14 days. Red bars indicate chain risk.
+
+ <% if @danger_windows.any? %>
+
+
Danger Windows
+
+ <% @danger_windows.each do |window| %>
+
+
+ <%= window[:start_hour].to_s.rjust(2, "0") %>:00 - <%= window[:end_hour].to_s.rjust(2, "0") %>:00 TCT
+
+
+ <%= window[:duration] %>h window
+ avg <%= window[:avg] %> active, min <%= window[:min] %>
+
+
+ <% end %>
+
+
+ <% end %>
+
+
+ <% @hourly_avg.each do |h| %>
+ <% pct = @member_count > 0 ? (h[:avg].to_f / @member_count * 100).round(1) : 0 %>
+ <% bar_color = if pct < 10
+ "rgb(239, 68, 68)"
+ elsif pct < 25
+ "rgb(249, 115, 22)"
+ elsif pct < 50
+ "rgb(234, 179, 8)"
+ else
+ "rgb(34, 197, 94)"
+ end %>
+
+
<%= h[:hour].to_s.rjust(2, "0") %>:00
+
+
<%= h[:avg].round %>
+
+ <% end %>
+
+
+
+
+ <%# ── Tab 3: Members ── %>
+
+
+
When each member is typically active. Last 7 days. Times in TCT.
+
+
+
+
+
+ Member
+
+
+
+
+ <% @members.each do |member| %>
+ <% hourly = @member_hourly[member.torn_member_id] || {} %>
+
+
+ <% if @has_enough_data %>
+
+ <%= member.member_name %>
+
+ <% else %>
+ <%= member.member_name %>
+ <% end %>
+
+
+
+ <% (0..23).each do |hour| %>
+ <% intensity = hourly[hour] || 0 %>
+ <% rgb = activity_cell_rgb((intensity * 100).to_i, 100) %>
+
+ <% end %>
+
+
+
+ <% end %>
+
+
+
+
+
+
+
+
diff --git a/app/views/factions/leadership/api_logs/show.html.erb b/app/views/factions/leadership/api_logs/show.html.erb
new file mode 100644
index 0000000..62db12d
--- /dev/null
+++ b/app/views/factions/leadership/api_logs/show.html.erb
@@ -0,0 +1,98 @@
+<%= link_to "← Back to Leadership", faction_leadership_path(@faction), class: "back-link" %>
+
+
+
+
Total Calls
+
<%= number_with_delimiter(@total_calls) %>
+
+
+
+
Calls Today
+
<%= number_with_delimiter(@calls_today) %>
+
+
+
+
Last 24 Hours
+
<%= number_with_delimiter(@calls_last_24h) %>
+
+
+
+
Success Rate
+
+ <%= @total_calls > 0 ? "#{((@successful_calls.to_f / @total_calls) * 100).round(1)}%" : "N/A" %>
+
+
+
+
+
Avg Response Time
+
<%= @avg_response_time ? "#{@avg_response_time}ms" : "N/A" %>
+
+
+
+
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 (Last 500)
+
+ <% if @api_logs.any? %>
+
+
+
+ TIMESTAMP
+ ENDPOINT
+ SELECTIONS
+ STATUS
+ RESPONSE TIME
+ ERROR
+
+
+
+ <% @api_logs.each do |log| %>
+ <% selections = log.selections.present? ? JSON.parse(log.selections) : {} %>
+
+ <%= log.created_at.strftime("%d-%m-%Y %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("%d-%m-%Y") %>
+ <% else %>
+ <%= value.to_s %>
+ <% end %>
+
+ <% end %>
+ <% else %>
+ -
+ <% end %>
+
+
+
+ <%= log.status.upcase %>
+
+
+ <%= log.response_time ? "#{log.response_time}ms" : "N/A" %>
+ <%= log.error_message&.truncate(50) || "-" %>
+
+ <% end %>
+
+
+ <% else %>
+
No API calls recorded for this faction's key.
+ <% end %>
+
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..de5e792
--- /dev/null
+++ b/app/views/factions/leadership/armory/show.html.erb
@@ -0,0 +1,295 @@
+
+ <%= link_to "← Back to Leadership".html_safe, faction_leadership_path(@faction), class: "back-link" %>
+
+
+
+ <% 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? %>
+
+
+
+
+
+
Armor Set
+
+ Assault
+ Delta
+ Riot
+ Dune
+ Combat
+ Other
+
+
+
+
Duplicates
+
+ Armor
+ Weapons
+ Both
+
+
+
+
+
+
+
+ <% if @armory_news.any? %>
+
+
+
+ <% @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/app/views/factions/leadership/data_coverage/show.html.erb b/app/views/factions/leadership/data_coverage/show.html.erb
new file mode 100644
index 0000000..82435dd
--- /dev/null
+++ b/app/views/factions/leadership/data_coverage/show.html.erb
@@ -0,0 +1,125 @@
+
+ <%= link_to "← Back to Leadership", faction_leadership_path(@faction), class: "back-link" %>
+
+
+
+
Overall Coverage
+
+ <% coverage_class = if @data_coverage_rate >= 90
+ "stat-compliant"
+ elsif @data_coverage_rate >= 70
+ "stat-warning"
+ else
+ "stat-danger"
+ end %>
+ <%= @data_coverage_rate %>%
+
+
+
+
+
Missing Yesterday
+
+ <% if @data_missing_yesterday > 0 %>
+ <%= @data_missing_yesterday %>
+ <% else %>
+ 0
+ <% end %>
+
+
+
+
+
Total Missing Days
+
+ <% if @data_total_missing_days > 0 %>
+ <%= number_with_delimiter(@data_total_missing_days) %>
+ <% else %>
+ 0
+ <% end %>
+
+
+
+
+
Tracking Period
+
<%= @tracking_start.strftime("%d %b") %> – <%= @tracking_end.strftime("%d %b %Y") %>
+
+
+
+
Next Backfill
+
+ --:--:--
+
+
Daily at 03:00 TCT
+
+
+
+
+
Members With Gaps (<%= @member_coverage.size %>)
+
+ <% if @member_coverage.any? %>
+
+
+
+
+ Member
+ Coverage
+ Days Present
+ Days Missing
+
+
+ <% @member_coverage.each do |member| %>
+
+
+
+
+ <%= member[:user].name %> [<%= member[:user].torn_id %>]
+
+
+
+ <% rate_class = if member[:rate] >= 90
+ "stat-compliant"
+ elsif member[:rate] >= 70
+ "stat-warning"
+ else
+ "stat-danger"
+ end %>
+ <%= member[:rate] %>%
+
+ <%= member[:existing] %>
+ <%= member[:missing] %>
+
+
+
+
+
+
Missing dates:
+
+ <% member[:missing_dates].each do |date| %>
+ <%= date.strftime("%d-%m-%Y") %>
+ <% end %>
+
+
+
+
+ <%= member[:missing] * 2 %> API calls needed to fetch <%= member[:missing] %> missing days.
+
+ <%= button_to "Backfill Now",
+ backfill_user_faction_leadership_data_coverage_path(@faction, user_id: member[:user].id),
+ method: :post,
+ class: "btn btn-sm btn-primary",
+ data: { action: "click->coverage-row#backfill", coverage_row_target: "button" } %>
+
+
+
+
+
+
+ <% end %>
+
+
+ <% else %>
+
+
All members have complete coverage!
+
+ <% end %>
+
+
diff --git a/app/views/factions/leadership/settings/show.html.erb b/app/views/factions/leadership/settings/show.html.erb
new file mode 100644
index 0000000..dacb1dd
--- /dev/null
+++ b/app/views/factions/leadership/settings/show.html.erb
@@ -0,0 +1,167 @@
+
+ <%= link_to "← Back to Leadership", faction_leadership_path(@faction), class: "back-link" %>
+
+
+
+
+
API Configuration
+
+
+
+
+ Torn API Key
+ <% if @torn_api_key&.access_type.present? %>
+ <%= @torn_api_key.access_type %>
+ <% end %>
+
+
+ <%= @torn_api_key_masked || "Not set" %>
+ <% if @torn_api_key.present? %>
+ <%= button_to faction_leadership_api_keys_path(@faction, key: "torn"),
+ method: :delete,
+ class: "leadership-key-delete-btn",
+ data: { turbo_confirm: "Delete the Torn API key?" } do %>
+
+
+
+ <% end %>
+ <% end %>
+
+
+
+
+
+ TornStats API Key
+
+
+ <%= @tornstats_api_key_masked || "Not set" %>
+ <% if @tornstats_api_key.present? %>
+ <%= button_to faction_leadership_api_keys_path(@faction, key: "tornstats"),
+ method: :delete,
+ class: "leadership-key-delete-btn",
+ data: { turbo_confirm: "Delete the TornStats API key?" } do %>
+
+
+
+ <% end %>
+ <% end %>
+
+
+
+
+ <%= form_with model: @faction_setting, url: faction_leadership_api_keys_path(@faction), method: :patch, class: "leadership-api-form" do |form| %>
+
+ <%= form.text_field :torn_api_key,
+ class: "leadership-api-input",
+ placeholder: "New Torn API key (Limited Access)",
+ autocomplete: "off",
+ value: "" %>
+ <%= form.text_field :tornstats_api_key,
+ class: "leadership-api-input",
+ placeholder: "New TornStats API key",
+ autocomplete: "off",
+ value: "" %>
+
+ <%= form.submit "Save Keys", class: "btn btn-sm btn-primary" %>
+ <% end %>
+
+
+ <%= render "shared/api_tos_table", key_access_level: "Limited Access (required)" %>
+
+
+
+
Leadership Access
+
Control who can access this dashboard.
+
+ <%= turbo_frame_tag "leadership-access" do %>
+ <%= render "factions/leadership/leadership_access" %>
+ <% end %>
+
+
+
+
Faction Subscription
+
Manage your faction's subscription.
+
+
+ <% if @faction_subscription_active %>
+ Status: Active
+ Expires: <%= @faction_subscription_expires_at.strftime("%d %B %Y") %>
+ Days remaining: <%= @faction_subscription_days_remaining %>
+ <% else %>
+ Status: Inactive
+ <% end %>
+
+
+
+
How it works
+
+
+
+ 2
+ Each Xanax = 2 weeks personal balance
+
+
+ 3
+ Transfer personal weeks to faction below
+
+
+
+
+
+
+ Faction members
+ <%= @faction_member_count %>
+
+
+ 1 faction week costs
+ <%= pluralize(@faction_week_cost, "personal week") %>
+
+
+ Your personal balance
+ <%= pluralize(@subscription_weeks_remaining, "week") %>
+
+
+ You can add
+ <%= pluralize(@max_faction_weeks, "faction week") %>
+
+
+
+ <% if @max_faction_weeks > 0 %>
+ <%= form_with url: faction_leadership_subscriptions_path(@faction), method: :post do |form| %>
+
+ <%= form.number_field :weeks, required: true, min: 1, max: @max_faction_weeks, value: 1, class: "api-key-form-input", placeholder: "Faction weeks", inputmode: "numeric", style: "max-width: 100px;" %>
+ <%= form.submit "Transfer to Faction", class: "btn btn-sm btn-primary",
+ data: { turbo_confirm: "This will cost #{@faction_week_cost} personal week(s) per faction week. Continue?" } %>
+
+ <% end %>
+ <% else %>
+
No personal weeks available. Send Xanax to Bram [2728237] to get started.
+ <% end %>
+
+
+
+
+
+
Delete Faction Data
+
This will permanently delete:
+
+ All personal stat snapshots for faction members
+ All spy reports collected for this faction
+ All ranked war history
+ The faction API keys
+ Leadership access for all members
+
+
+ Subscription time will NOT be revoked. Members keep their remaining subscription. You can set up the faction again at any time.
+
+ <%= button_to "Delete Collected Data",
+ faction_leadership_faction_data_path(@faction),
+ method: :delete,
+ class: "btn btn-danger",
+ data: { turbo_confirm: "Are you sure? This will permanently delete all faction data. This action cannot be undone." } %>
+
+
+
diff --git a/app/views/factions/leadership/setup/show.html.erb b/app/views/factions/leadership/setup/show.html.erb
new file mode 100644
index 0000000..d9bdcde
--- /dev/null
+++ b/app/views/factions/leadership/setup/show.html.erb
@@ -0,0 +1,79 @@
+
+
+
+
+ <%= form_with model: @faction_setting, url: faction_leadership_setup_path(@faction), method: :patch, class: "setup-form" do |form| %>
+
+
+
+ Torn API Key
+ Required
+
+ <%= form.text_field :torn_api_key,
+ class: "setup-input",
+ placeholder: "Enter your Limited Access API key",
+ autocomplete: "off",
+ required: true %>
+
+ Must be a Limited Access key.
+ Create one in Torn
+
+
+
+
+
+ TornStats API Key
+ Optional
+
+ <%= form.text_field :tornstats_api_key,
+ class: "setup-input",
+ placeholder: "Enter your TornStats API key",
+ autocomplete: "off" %>
+
+ For spy data imports.
+ Get from TornStats
+
+
+
+
+
+ <%= form.submit "Complete Setup", class: "btn btn-primary btn-lg setup-submit" %>
+ <%= link_to "Cancel", faction_path(@faction), class: "setup-cancel" %>
+
+ <% end %>
+
+
+
+
+
+
+
+
+ What are these keys used for?
+
+
+
+
Torn API Key
+
Fetches live member status during ranked wars. Limited Access ensures minimal permissions. Your key is stored securely.
+
+
+
TornStats API Key
+
Imports spy reports for enemy factions - estimated battle stats like strength, defense, speed, and dexterity.
+
+
+
+
+
+
+ <%= render "shared/api_tos_table", key_access_level: "Limited Access (required)" %>
+
+
diff --git a/app/views/factions/leadership/show.html.erb b/app/views/factions/leadership/show.html.erb
new file mode 100644
index 0000000..0e4246f
--- /dev/null
+++ b/app/views/factions/leadership/show.html.erb
@@ -0,0 +1,3 @@
+
+ <%= render "factions/leadership/hero_section" %>
+
diff --git a/app/views/factions/leadership/spy_reports/show.html.erb b/app/views/factions/leadership/spy_reports/show.html.erb
new file mode 100644
index 0000000..d9ad953
--- /dev/null
+++ b/app/views/factions/leadership/spy_reports/show.html.erb
@@ -0,0 +1,107 @@
+
+ <%= link_to "← Back to Leadership", faction_leadership_path(@faction), class: "back-link" %>
+
+
+ <% if @faction.tornstats_api_key&.key.present? %>
+
+ <%= form_with url: faction_leadership_spy_imports_path(@faction), method: :post, class: "leadership-import-form" do |form| %>
+ Import:
+ <%= form.text_field :target_faction_id,
+ class: "leadership-import-input",
+ placeholder: "Faction ID",
+ autocomplete: "off",
+ inputmode: "numeric",
+ pattern: "[0-9]*" %>
+ <% if @can_import %>
+ <%= form.submit "Import", class: "btn btn-sm btn-secondary" %>
+ <% else %>
+
+
+ <%= @seconds_until_import %>s
+
+
+ <% end %>
+ <% end %>
+
+ <% if @current_war %>
+ <%= button_to fetch_enemy_faction_leadership_spy_reports_path(@faction),
+ method: :post, class: "btn btn-sm btn-primary",
+ data: { turbo_submits_with: ' Fetching...' } do %>
+ Fetch <%= @current_war.opponent_faction_name %>
+ <% end %>
+ <% end %>
+
+ <% else %>
+
+
Configure your TornStats API key in <%= link_to "Settings", faction_leadership_settings_path(@faction) %> to import spy data.
+ <% if @current_war %>
+
+ Fetch <%= @current_war.opponent_faction_name %>
+
+ <% end %>
+
+ <% end %>
+
+
+
+
+
Spy Reports (<%= @spy_report_count %>)
+
+
+
+
+ #
+ Torn ID
+ Strength
+ Defense
+ Speed
+ Dexterity
+ Total
+ Age
+
+
+
+
+ <% if @spy_reports.empty? %>
+
+
+
+
No spy reports imported yet.
+
+
+
+ <% else %>
+ <% @spy_reports.each_with_index do |report, index| %>
+
+ <%= 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" %>
+
+ <%= button_to destroy_report_faction_leadership_spy_reports_path(@faction, report.id),
+ method: :delete,
+ class: "spy-delete-btn",
+ data: { turbo_confirm: "Delete spy report for #{report.torn_id}?" } do %>
+
+
+
+ <% end %>
+
+
+ <% end %>
+ <% end %>
+
+
+
+
+
diff --git a/app/views/factions/leadership/war_history/show.html.erb b/app/views/factions/leadership/war_history/show.html.erb
new file mode 100644
index 0000000..2946983
--- /dev/null
+++ b/app/views/factions/leadership/war_history/show.html.erb
@@ -0,0 +1,26 @@
+
+ <%= link_to "← Back to Leadership", faction_leadership_path(@faction), class: "back-link" %>
+
+ <%= turbo_frame_tag "war-history" do %>
+
+
+
+ <%= render "factions/ranked_wars/war_history_table", wars: @wars, faction: @faction %>
+ <%= render "factions/ranked_wars/member_performance_table", member_performance: @member_performance %>
+
+ <% end %>
+
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..f5ce31f
--- /dev/null
+++ b/app/views/factions/leadership/war_reports/show.html.erb
@@ -0,0 +1,284 @@
+
+ <%= link_to "← Back to Leadership", faction_leadership_path(@faction), class: "back-link" %>
+
+
+
+ Completed Wars
+ <% if @wars.any? %>
+
+ <% @wars.each do |war| %>
+ <%= link_to faction_leadership_war_reports_path(@faction, war: war.torn_war_id),
+ class: "war-reports-item #{'war-reports-item-selected' if @selected_war&.id == war.id}" do %>
+
+ <%= war.won? ? 'W' : 'L' %>
+
+
+ <%= war.opponent_faction_name %>
+ <%= war.started_at.strftime("%d %b %Y") %>
+
+
<%= war.our_score %>-<%= war.their_score %>
+ <% end %>
+ <% end %>
+
+ <% else %>
+ No completed wars.
+ <% end %>
+
+
+ <% if @selected_war %>
+ <% 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 %>
+ <% reward_items = @selected_war.our_rewards&.dig("items") || [] %>
+ <% has_prices = reward_items.any? { |i| i["market_price"] } %>
+ <% estimated_total = @selected_war.reward_estimated_value || (has_prices ? reward_items.sum { |i| (i["market_price"] || 0) * i["quantity"] } : nil) %>
+
+
+
+
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",
+ 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 %>
+ 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
+
+
+ <% if reward_items.any? %>
+
+ <% reward_items.each do |item| %>
+ <% price = item["market_price"] %>
+
+ <%= item["quantity"] %>x <%= item["name"].sub(" Cache", "") %>
+ <% if price %>
+ $<%= number_with_delimiter(price * item["quantity"]) %>
+ <% end %>
+
+ <% end %>
+ <% if estimated_total %>
+ = $<%= number_with_delimiter(estimated_total) %>
+ <% end %>
+
+ <% end %>
+
+ <% else %>
+
+
+
No completed wars found.
+
+
+ <% end %>
+
+
+ <% if @selected_war %>
+ <% if !@integrity_ok %>
+
+ Incomplete: <%= @integrity_message || "No attack data fetched yet" %>
+
+ <% end %>
+
+
+
+
+
+
+
+
+
+
+ Member
+ RW Hits
+ FF<1.25
+ FF<1.75
+ FF≥1.75
+ Overseas
+ Assists
+ Warlord
+ Respect
+ Avg
+ Payout
+
+
+ <% if @has_attacks %>
+ <% @member_stats.each do |member| %>
+
+
+
+
+ <%= 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] %>
+ —
+
+
+
+
+
+
+
+ Target
+ Result
+ Respect
+ FF
+ War
+ Warlord
+ Chain
+ Time
+
+
+
+ <% (@attacks_by_member[member[:torn_id]] || []).sort_by(&:started).each do |attack| %>
+
+
+
+ <%= attack.defender_name %>
+
+
+ <%= attack.result %>
+ <%= attack.respect_gain&.round(2) %>
+
+ <%= attack.fair_fight&.round(2) %>
+
+ <%= attack.war&.round(1) %>
+
+ <% if attack.used_warlord? %>
+ <%= attack.warlord&.round(2) %>
+ <% else %>
+ 1
+ <% end %>
+
+ <%= attack.chain %>
+ <%= Time.at(attack.started).strftime("%d %b %H:%M") %>
+
+ <% end %>
+
+
+
+
+
+
+ <% end %>
+ <% else %>
+
+ <% end %>
+
+
+
+
+ <% end %>
+
diff --git a/app/views/factions/public_war/show.html.erb b/app/views/factions/public_war/show.html.erb
new file mode 100644
index 0000000..e8786f6
--- /dev/null
+++ b/app/views/factions/public_war/show.html.erb
@@ -0,0 +1,10 @@
+
+
+<%= render "factions/ranked_wars/live_dashboard", war: @current_war, faction: @faction, war_data: @war_data %>
diff --git a/app/views/factions/ranked_wars/_live_dashboard.html.erb b/app/views/factions/ranked_wars/_live_dashboard.html.erb
new file mode 100644
index 0000000..2cf1aa6
--- /dev/null
+++ b/app/views/factions/ranked_wars/_live_dashboard.html.erb
@@ -0,0 +1,149 @@
+
+
+
+
+ <%= war.our_score %>
+ <%= link_to faction.name, "https://www.torn.com/factions.php?step=profile&ID=#{faction.torn_id}", target: "_blank", class: "faction-link" %>
+
+
+
+
+ <% if war.scheduled? %>
+
+
+ <% else %>
+
+ Live
+ <% end %>
+
+
+ <%= war.our_score - war.their_score %>
+ /
+ <%= war.target_score %>
+
+
+
+
+
+ <%= war.their_score %>
+ <%= link_to war.opponent_faction_name, "https://www.torn.com/factions.php?step=profile&ID=#{war.opponent_faction_id}", target: "_blank", class: "faction-link" %>
+
+
+
+
+
+
+
+
+
Status
+
+ Okay
+ Hospital
+ Jail
+ Traveling
+ Abroad
+
+
+
+
Activity
+
+ Online
+ Idle
+ Offline
+
+
+
+
Max Stats
+
+
+ No limit
+
+
+
+
+
+
+
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...
+
+
+
+
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..31a0e7f
--- /dev/null
+++ b/app/views/factions/ranked_wars/_member_performance_table.html.erb
@@ -0,0 +1,45 @@
+
+
+ Member Performance <%= Date.current.year %>
+
+
+ <% if member_performance.any? %>
+
+ <% 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...
+
+
+
+
+
+ Status
+ Opponent
+ Score
+ Duration
+ Attacks
+ Avg Respect
+ Rank
+ Rewards
+ Date
+
+
+
+
+ <% 5.times do %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <% end %>
+
+
+
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..2efe078
--- /dev/null
+++ b/app/views/factions/ranked_wars/_war_history_table.html.erb
@@ -0,0 +1,122 @@
+
+
+ War History
+
+
+ <% if wars.empty? %>
+
+
⚔
+
No ranked wars found.
+
+ <% else %>
+
+ <% 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..44723fe
--- /dev/null
+++ b/app/views/factions/ranked_wars/show.html.erb
@@ -0,0 +1,173 @@
+
+
+
+
+ Your Faction
+ <%= @faction.name %>
+ <%= @war.our_score %>
+
+
+
+
Target: <%= @war.target_score %>
+ <% if @war.completed? %>
+
+ <%= @war.won? ? "VICTORY" : "DEFEAT" %>
+
+ <% elsif @war.in_progress? %>
+
LIVE
+ <% else %>
+
SCHEDULED
+ <% end %>
+
<%= @war.duration_formatted %>
+
+
+
+ Opponent
+ <%= link_to @war.opponent_faction_name, "https://www.torn.com/factions.php?step=profile&ID=#{@war.opponent_faction_id}", target: "_blank", class: "faction-link" %>
+ <%= @war.their_score %>
+
+
+
+ <% if @war.respect_gained.to_i > 0 || @war.points_gained.to_i > 0 %>
+
+ <% if @war.respect_gained.to_i > 0 %>
+
+ +<%= number_with_delimiter(@war.respect_gained) %>
+ respect
+
+ <% end %>
+ <% if @war.points_gained.to_i > 0 %>
+
+ +<%= @war.points_gained %>
+ points
+
+ <% end %>
+
+ <% end %>
+
+
+
+
Stats Comparison
+
+
+
+ <%= number_with_delimiter(@war.our_attacks) %>
+ Attacks
+ <%= number_with_delimiter(@war.their_attacks) %>
+
+
+ <%= @war.score_per_attack %>
+ Avg Respect/Hit
+ <%= @war.their_score_per_attack %>
+
+ <% if @war.rank_before && @war.rank_after %>
+
+
+
+ <%= @war.rank_before %>
+ <%= @war.rank_change %>
+ <%= @war.rank_after %>
+
+
+ Rank
+
+
+ <% end %>
+
+
+
+
+
+
+ <%= @faction.name %> (<%= @our_members.count { |m| m["attacks"].to_i > 0 } %> fighters)
+
+
+
+ <% if @our_non_participants.any? %>
+
+ <%= @our_non_participants.count %> non-participants
+
+ <% @our_non_participants.each do |member| %>
+
+ <%= link_to member["name"], "https://www.torn.com/profiles.php?XID=#{member["id"]}", target: "_blank", class: "player-link" %>
+
+ <% end %>
+
+
+ <% end %>
+
+
+
+
+ <%= @war.opponent_faction_name %> (<%= @their_members.count { |m| m["attacks"].to_i > 0 } %> fighters)
+
+
+
+
+
+
+
diff --git a/app/views/factions/setup.html.erb b/app/views/factions/setup.html.erb
new file mode 100644
index 0000000..043ca37
--- /dev/null
+++ b/app/views/factions/setup.html.erb
@@ -0,0 +1,74 @@
+
+
+
+
+
+
Welcome! You're the first person from your faction to use TornManager. Everyone in your faction will receive a 1-month free trial to explore all features. This key will be stored as the faction's API key and used to fetch member stats, ranked wars, and other faction data.
+
+
+ <%= form_with url: setup_faction_path(torn_id: @faction.torn_id), method: :post, class: "setup-form" do |form| %>
+
+
+
+ Limited Access API Key
+ Required
+
+ <%= form.text_field :api_key,
+ value: @api_key_prefill,
+ class: "setup-input",
+ placeholder: "Enter your Limited Access API key",
+ autocomplete: "off",
+ required: true %>
+
+ Must be a Limited Access key that belongs to you.
+ Create one in Torn
+
+
+
+
+
+ <%= form.submit "Set Up My Faction", class: "btn btn-primary btn-lg setup-submit" %>
+
+ <% end %>
+
+
+
+
+
+
+
+
+ What happens during setup?
+
+
+
+
Leadership permissions
+
You'll be granted leadership access since you're setting up the faction. You can invite others from the Leadership dashboard. Leaders and Co-leaders are added automatically.
+
+
+
Member sync
+
All faction members are synced from the Torn API so everyone can access the dashboard (1 API call, instant).
+
+
+
Ranked war history
+
War history is fetched in the background (~20 API calls).
+
+
+
Personal stats backfill
+
Stats are backfilled for all members from January 1st, 2026. This requires ~2 API calls per member per day and may take a while at the rate limit of 60 calls/min.
+
+
+
API key security
+
Your key is stored securely and only used for faction-related API calls. Limited Access keys cannot perform actions on your account. You can revoke it at any time.
+
+
+
+
+
+
+ <%= render "shared/api_tos_table", key_access_level: "Limited Access (required)" %>
+
+
diff --git a/app/views/factions/setup_unavailable.html.erb b/app/views/factions/setup_unavailable.html.erb
new file mode 100644
index 0000000..9df8a35
--- /dev/null
+++ b/app/views/factions/setup_unavailable.html.erb
@@ -0,0 +1,25 @@
+
+
+
+
+
+
Only your faction's Leader or Co-leader can complete the initial setup. Once done, all members automatically receive a 1-month free trial .
+
+
+
+ Ask your Leader or Co-leader to sign in to TornManager and complete the one-time setup.
+
+
+ Need help? Reach out to Bram [2728237] on Torn.
+
+
+
+
+
+ <%= link_to "Go to Settings", settings_path, class: "btn btn-primary btn-lg setup-submit" %>
+
+
+
diff --git a/app/views/factions/show.html.erb b/app/views/factions/show.html.erb
new file mode 100644
index 0000000..bda0f82
--- /dev/null
+++ b/app/views/factions/show.html.erb
@@ -0,0 +1,5 @@
+
+ <%= render "factions/hero_section" %>
+ <%= render "factions/war_section" %>
+ <%= render "factions/training_section" %>
+
diff --git a/app/views/factions/subscription_expired.html.erb b/app/views/factions/subscription_expired.html.erb
new file mode 100644
index 0000000..327d881
--- /dev/null
+++ b/app/views/factions/subscription_expired.html.erb
@@ -0,0 +1,22 @@
+
+
+
+
+
+
Your subscription has expired. An active subscription is required to access faction features like the training dashboard, ranked wars, and personal stats tracking.
+
+
+
+ Ask your faction leadership to share subscription time with you from the Leadership dashboard.
+
+
+
+
+
+ <%= link_to "Go to Settings", settings_path, class: "btn btn-primary btn-lg setup-submit" %>
+
+
+
diff --git a/app/views/factions/training/_table_skeleton.html.erb b/app/views/factions/training/_table_skeleton.html.erb
new file mode 100644
index 0000000..363c654
--- /dev/null
+++ b/app/views/factions/training/_table_skeleton.html.erb
@@ -0,0 +1,94 @@
+
+
+
Daily Targets
+
+ Xanax: <%= @faction.xanax_target %>/day
+ Energy Refills: <%= @faction.energy_refill_target %>/day
+ Nerve Refills: <%= @faction.nerve_refill_target %>/day
+
+
+
+
+
+
+
+
+
+
+ Status
+ MEMBER
+ XANAXTarget: <%= @faction.xanax_target %>/day
+ ENERGY REFILLSTarget: <%= @faction.energy_refill_target %>/day
+ NERVE REFILLSTarget: <%= @faction.nerve_refill_target %>/day
+ CONTRACTS
+ CRIMES
+ ACTIVITYmin/day
+
+
+
+
+ <% 10.times do %>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <% end %>
+
+
+
diff --git a/app/views/factions/war_history/show.html.erb b/app/views/factions/war_history/show.html.erb
new file mode 100644
index 0000000..bf4c56e
--- /dev/null
+++ b/app/views/factions/war_history/show.html.erb
@@ -0,0 +1,18 @@
+
+ <%= link_to "← Back to Dashboard", faction_path(@faction), class: "back-link" %>
+
+
+
+
+ <%= render "factions/ranked_wars/war_history_table", wars: @wars, faction: @faction %>
+ <%= render "factions/ranked_wars/member_performance_table", member_performance: @member_performance %>
+
+
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..6c3f232
--- /dev/null
+++ b/app/views/hall_of_famers/index.html.erb
@@ -0,0 +1,119 @@
+
+
+<%
+ def sortable_header(sort_info)
+ chevron = ' '
+ 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]} #{chevron}".html_safe, hall_of_famers_path(url_params), class: "sort-link"
+ end
+%>
+
+
+
+ <%= 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: 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: PersonalStatSnapshot.tracking_start_date,
+ max: PersonalStatSnapshot.tracking_end_date,
+ class: "date-input-inline" %>
+
+
+
(<%= @total_days_tracked %> days)
+
+ <%= hidden_field_tag :sort, params[:sort] %>
+ <%= hidden_field_tag :direction, params[:direction] %>
+
+
Apply
+ <% 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_gained", "XANAX GAINED")) %>
+ <%= 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")) %>
+
+
+
+
+ <% @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..b85bd71 100644
--- a/app/views/home/index.html.erb
+++ b/app/views/home/index.html.erb
@@ -1,2 +1,337 @@
+
+
+
TornManager
+ <%= link_to "Sign In", new_session_path, class: "hero-signin-button" %>
+
+
+
+
+
-Comming Soon!
+
+
+
Professional Faction Management for Torn City
+
+ TornManager gives faction leaders and members the tools to track compliance, dominate ranked wars, and run a tight operation.
+ Paired with a companion browser script for in-game overlays. Fully mobile-friendly.
+
+
+
+
+
+
Features
+
Everything your faction needs
+
From live war intelligence to daily training compliance, TornManager covers the full lifecycle of faction management.
+
+
+
+
+
+
Live War Dashboard
+
+ Real-time ranked war tracking with enemy status, hospital timers, travel countdowns, and online indicators.
+ Polls every 3 seconds during active wars. See who to hit and when.
+
+
+
+
+ Okay
+ Hospital
+ Traveling
+
+
+
+ Online
+ Idle
+ Offline
+
+
(10/10)
+
+
+
+
+
+
+
+
Training & Compliance
+
+ Track daily xanax, energy refills, nerve refills, missions, and crimes per member.
+ Compliance scores, color-coded status, and sortable training tables with configurable targets.
+
+
+
+
+ 0
+ Compliant
+
+
+ 0
+ Needs Attn
+
+
+ 0
+ Below Target
+
+
+
+
+
+
+
+
And much more
+
+
+
+
+
+
+
+
+
War History & Analytics
+
Full ranked war history with scores, attacks, respect per hit, rank changes, and member performance tables.
+
+
+
+
+
+
+
+
+
+
+
Spy Reports
+
Import spy data from TornStats. View estimated battle stats with strength and defense breakdowns.
+
+
+
+
+
+
+
+
+
+
+
Member Activity Tracker
+
14-day activity heatmap, chain coverage analysis with danger windows, and per-member hourly timelines.
+
+
+
+
+
+
+
+
+
+
Ranked War Payouts
+
Auto-estimate reward value from market prices. Configure faction cut and assist weighting. Export to clipboard.
+
+
+
+
+
+
+
+
+
+
+
Public War Lobbies
+
Share live war dashboards with allies. Password-protected or open, with inline editable spy stats.
+
+
+
+
+
+
+
+
+
+
Browser Userscript
+
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.
+
+
+
+
+
+
+
+
Leadership Dashboard
+
API key management, access control, data coverage monitoring, war polling, and faction subscription management.
+
+
+
+
+
+
+
+
+
Fully Mobile Friendly
+
Every feature works on your phone. Manage your faction during wars, check compliance on the go.
+
+
+
+
+
+
+
+
+
+
Trust & Security
+
Your data, your control
+
Built with transparency and privacy as core principles.
+
+
+
+
+
+
Full API Key Transparency
+
Every API call made with your key is logged and visible to you. See exactly what data is being accessed, when, and how often. No hidden requests.
+
+
+
+
Built-in Rate Limiting
+
Automatic 1-second delay between API calls ensures TornManager never exceeds 60 requests per minute per key. Your key stays safe from rate limit violations.
+
+
+
+
GDPR Compliant
+
Full data export, one-click data deletion, transparent privacy policy. Your data is never sold or shared with third parties. You own your data.
+
+
+
+
+
+
+
Pricing
+
Simple, faction-wide pricing
+
One price covers your entire faction. No per-feature upsells, no hidden costs.
+
+
+
+
1 Xanax = 2 weeks
+
personal balance, transferable to your faction
+
+
+
+
+
+ Faction subscription covers all members
+
+
+
+ Cost scales with faction size (1 personal week per 4 members)
+
+
+
+ All features included - no tiers
+
+
+
+ Leaders transfer personal weeks to the faction
+
+
+
+ Individual subscriptions also available
+
+
+
+
14-day free trial for new factions
+
+
+
+
+
+
In Development
+
What I'm building next
+
TornManager is actively developed. Here's what's coming soon.
+
+
+
+
+
Battle Stat Estimation
+
ML-powered stat predictions using spy data and player activity. Trained on 11,000+ samples with Random Forest.
+
+
+
Chain Tracking
+
Monitor chain progress, timeout warnings, and member contributions in real time.
+
+
+
Organised Crimes 2.0
+
Crime team management with CPR tracking, cost analysis, and optimal team suggestions.
+
+
+
Discord Integration
+
War notifications, compliance alerts, and member updates pushed directly to your faction's Discord server.
+
+
+
+
+
+
+
+ Getting Started
+
Up and running in minutes
+
+
+
+
+
1
+
Sign in with Torn
+
Use your Torn API key to authenticate. No passwords, no Discord — just your Torn identity.
+
+
+
2
+
Leader sets up faction
+
A faction leader provides a Limited Access API key. TornManager starts syncing your faction's data automatically.
+
+
+
3
+
Start tracking
+
Your dashboard is live. Training compliance, war analytics, and member management — all in one place.
+
+
+
+
+
+
Ready to level up your faction?
+
Join the factions already using TornManager.
+ <%= link_to "Sign In", new_session_path, class: "landing-cta-button" %>
+
+ <%= link_to "Privacy Policy", legal_path(anchor: "privacy-policy") %>
+ <%= link_to "Terms of Service", legal_path(anchor: "terms-of-service") %>
+
+
+
diff --git a/app/views/key_log/index.html.erb b/app/views/key_log/index.html.erb
new file mode 100644
index 0000000..eda3784
--- /dev/null
+++ b/app/views/key_log/index.html.erb
@@ -0,0 +1,69 @@
+
+
+
+ <%= 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"
+ } %>
+
+
+
+ <%= render "shared/api_tos_table",
+ data_storage: "Not stored - fetched and displayed only",
+ key_storage: "Never stored / Not shared",
+ purpose: "View API key activity log (3 API calls)",
+ key_access_level: "Any access level" %>
+
+
+
+
+ <%= 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..c5d32de
--- /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
+
+
+
+
+ Timestamp
+ Type
+ Selections
+ ID
+ IP Address
+ Comment
+
+
+
+ <% @log_data.log.each do |entry| %>
+
+ <%= Time.at(entry.timestamp).utc.strftime("%d-%m-%Y %H:%M:%S") %>
+ <%= entry.type %>
+ <%= entry.selections %>
+ <%= entry.id || "-" %>
+ <%= entry.ip %>
+ <%= entry.comment || "-" %>
+
+ <% end %>
+
+
+
+
+ <% 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/_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 @@
+
+
+
+ <% if type == 'notice' %>
+
+
+
+ <% else %>
+
+
+
+
+
+ <% end %>
+
+
<%= message %>
+
+
+
+
+
+
+
diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb
index f5f20e6..c2742ad 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/_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/layouts/_navbar.html.erb b/app/views/layouts/_navbar.html.erb
index 02339e5..4bc5eb9 100644
--- a/app/views/layouts/_navbar.html.erb
+++ b/app/views/layouts/_navbar.html.erb
@@ -1,8 +1,125 @@
-
+
<% if authenticated? %>
- <%= link_to "TornManager", progress_index_path, class: "navbar-link navbar-title" %>
- <%= 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 "TornManager", Current.user&.faction ? factions_path : root_path, class: "navbar-link navbar-title" %>
+
+
+
+ <% if navbar_faction %>
+
+
+ <%= viewing_other_faction? ? navbar_faction.name : "My Faction" %>
+
+
+
+
+
+ <%= link_to "Dashboard", faction_path(navbar_faction), class: "navbar-dropdown-item" %>
+ <%= link_to "Leadership", faction_leadership_path(navbar_faction), class: "navbar-dropdown-item" %>
+
+
+ <% else %>
+ <%= link_to "My Faction", factions_path, class: "navbar-link #{'navbar-link-active' if request.path.start_with?('/factions')}" %>
+ <% end %>
+ <% if Current.user&.hof_access? %>
+ <%= link_to "Hall of Famers", hall_of_famers_path, class: "navbar-link #{'navbar-link-active' if request.path.start_with?('/hall-of-famers')}" %>
+ <% end %>
+ <%= link_to "Public Wars", public_wars_path, class: "navbar-link #{'navbar-link-active' if request.path.start_with?('/public_wars')}" %>
+ <% if Current.user&.admin? %>
+ <%= link_to "Admin", admin_dashboard_path, class: "navbar-link #{'navbar-link-active' if request.path.start_with?('/admin')}" %>
+ <% end %>
+
+
+
+
+ <% if Current.user.profile_image.present? %>
+
+ <% end %>
+
+ <%= Current.user.name %>
+
+
+
+
+
+ <%= link_to "API Calls", user_api_calls_path, class: "navbar-dropdown-item" %>
+ <%= link_to "Stocks", stocks_path, class: "navbar-dropdown-item" %>
+ <%= link_to "Settings", settings_path, class: "navbar-dropdown-item" %>
+ <%= link_to "Join Discord", "https://discord.gg/JpuNR2qTyn", target: "_blank", class: "navbar-dropdown-item" %>
+ <%= button_to "Sign Out", session_path, method: :delete, class: "navbar-dropdown-item navbar-dropdown-signout" %>
+
+
+
+
+
+ <% if Current.user.profile_image.present? %>
+
+ <% end %>
+
+
+
+
+
+
+
+
+
+
+
+ <% else %>
+
+ <%= link_to "TornManager", root_path, class: "navbar-link navbar-title" %>
+
+
+ <%= link_to "Public Wars", public_wars_path, class: "navbar-link #{'navbar-link-active' if request.path.start_with?('/public_wars')}" %>
+ <%= link_to "Key Activity Log", key_log_path, class: "navbar-link #{'navbar-link-active' if request.path.start_with?('/key-log')}" %>
+
+
+ <%= link_to "Sign In", new_session_path, class: "navbar-signin" %>
+
+
+
+
+
+
+
+
+
+
+
+
<% end %>
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index e8f7a5e..5225525 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -11,12 +11,8 @@
<%= yield :head %>
- <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
- <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
-
- <%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
<%= javascript_importmap_tags %>
@@ -27,5 +23,11 @@
<%= yield %>
<%= render "layouts/footer" %>
+
+
+ <% flash.each do |type, message| %>
+ <%= render "layouts/flash", type: type, message: message %>
+ <% end %>
+