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`)} + + + ` + + 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 += ` + + + + + + + + ` + }) + + html += "
Contracts ${indicator("contracts")}Crimes ${indicator("crimes")}
${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)}
" + 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 = ` + + + + + + + + ` + + sorted.forEach(m => { + const rowClass = m.status === "Hospital" ? "demo-row-hospital" : "" + html += ` + + + + + + + ` + }) + + html += "
Member ${indicator("name")}Lvl ${indicator("level")}Status ${indicator("status")}Activity ${indicator("activity")}Timer ${indicator("timer")}Total Stats ${indicator("total")}
${m.name}${m.level}${this.renderStatus(m.status)}${this.renderActivity(m.activity)}${this.renderTimer(m)}${this.formatStats(m.total)}
" + 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 = ` + + ` + + 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 `${this.formatStat(value)}` + } + return `${this.formatStat(value)}` + }).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? %> + + + + + + + + + + + + + <% @api_logs.each do |log| %> + <% selections = log.selections.present? ? JSON.parse(log.selections) : {} %> + + + + + + + + + <% end %> + +
TIMESTAMPENDPOINTSELECTIONSSTATUSRESPONSE TIMEERROR
<%= 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) || "-" %>
+ <% 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? %> + + <% 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" %> +
+ + + + + + <%= 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 %> + +
+ +
+
+ <% 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 @@ + + +
+
+

Daily Targets

+
+ + <%= 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 @@ + + +
+
+

Factions

+
+ + <%= turbo_frame_tag "add_faction" do %> + <%= render "form" %> + <% end %> + +
+ + + + + + + + + + + + + <%= render @factions %> + +
NameTorn IDMembersDaily TargetsPublic WarsActions
+
+ + <% 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 @@ +
+
+

<%= col %>

+ <% if d[:zero_pct] > 50 %> + <%= d[:zero_pct] %>% zeros + <% elsif d[:zero_pct] > 20 %> + <%= d[:zero_pct] %>% zeros + <% end %> +
+ +
+ <% 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) %>
+
+ + Player <%= torn_id %> + + Level <%= features["level"] %> +
+
+ +
+ Feature values used +
+ <% features.sort_by { |k, _| Recon::TrainingSample::FEATURE_COLUMNS.index(k) || 99 }.each do |key, value| %> +
+ <%= key %> + <%= number_with_delimiter(value) %> +
+ <% end %> +
+
+
+ <% end %> +
diff --git a/app/views/admin/recon/show.html.erb b/app/views/admin/recon/show.html.erb 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? %> +
+ + + + + + + + + + + + + + <% @samples.each do |sample| %> + + + + + + + + + + + + + + + + <% end %> +
PlayerSTRDEFSPDDEXTotalSpied AtCollected
+ + <%= 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") %>
+
+ <% 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 %> + + <%= 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 @@ + + +
+
+

New Release

+
+ + <%= 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 %> + +
+
+ <%= form.label :version %> + <%= form.text_field :version, placeholder: "1.0.0", class: "form-input" %> +
+ +
+ <%= form.label :changelog %> + <%= form.text_field :changelog, placeholder: "What changed in this release", class: "form-input" %> +
+ +
+ <%= form.label :released_at, "Release date" %> + <%= form.date_field :released_at, class: "form-input" %> +
+ +
+ <%= form.label :script_file, "Script file (.user.js)" %> + <%= form.file_field :script_file, accept: ".js", required: true, class: "form-input" %> +
+ +
+ <%= form.submit "Create Version", class: "btn-primary" %> +
+
+ <% end %> +
+ +<% if @script_versions.any? %> +
+
+

Version History

+
+ +
+ + + + + + + + + + + <% @script_versions.each do |version| %> + + + + + + + <% end %> + +
VersionReleasedChanges
<%= 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 %> diff --git a/app/views/admin/snapshot_management/_gap_table.html.erb b/app/views/admin/snapshot_management/_gap_table.html.erb new file mode 100644 index 0000000..955460d --- /dev/null +++ b/app/views/admin/snapshot_management/_gap_table.html.erb @@ -0,0 +1,68 @@ +
+ + + + + + + + + + + + <% users_with_gaps.each do |data| %> + + + + + + + + + + + + + + <% end %> +
UserFactionMissingOldest GapLatest SnapshotActions
+ + <%= data[:user].name %> + + [<%= data[:user].torn_id %>] + + <% if data[:user].faction %> + <%= data[:user].faction.name %> + <% else %> + HoF + <% end %> + + + <%= data[:missing_count] %> days + + <%= data[:oldest_missing]&.strftime("%d-%m-%Y") || "-" %><%= data[:latest_snapshot]&.strftime("%d-%m-%Y") || "Never" %> + <%= button_to "Backfill", + backfill_user_admin_snapshot_management_path(data[:user]), + method: :post, + class: "btn-small btn-primary", + data: { action: "click->backfill-row#remove" } %> + + +
+
diff --git a/app/views/admin/snapshot_management/index.html.erb b/app/views/admin/snapshot_management/index.html.erb 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 @@ +
+
+ <%= link_to "← Admin".html_safe, admin_dashboard_path, class: "back-link" %> +

System Stats

+ as of <%= Time.current.strftime("%d %b %Y %H:%M") %> TCT · activity metrics cached ≤15 min +
+ + <%# ── 1. Health strip: do I need to act? ── %> +
+ <% if @users_missing_yesterday > 0 %> + Collection<%= @users_missing_yesterday %> users missing yesterday's snapshot + <% else %> + Collectionall tracked users snapshotted yesterday + <% end %> + + <% if @keys_over_budget > 0 %> + Budget<%= @keys_over_budget %> keys peaked over <%= @per_key_budget %>/min (24h) + <% else %> + Budgetall keys ≤<%= @per_key_budget %>/min (24h) + <% end %> + + <% if @pipeline_stats %> + <% if @pipeline_stats[:failed] > 0 %> + <%= link_to "/jobs", class: "admin-hpill bad", data: { turbo_prefetch: false, turbo: false } do %>Queue<%= number_with_delimiter(@pipeline_stats[:failed]) %> failed jobs awaiting triage<% end %> + <% else %> + Queueno failed jobs + <% end %> + <% end %> + + <% if @api_peak_rate_today > @global_budget %> + Aggregate API<%= @api_peak_rate_today %>/min peak today · global cap <%= @global_budget %> + <% else %> + Aggregate API<%= @api_peak_rate_today %>/min peak today · global cap <%= @global_budget %> + <% end %> + + <% if @completeness_pct %> + Completeness<%= @completeness_pct %>% of <%= number_with_delimiter(@total_snapshots) %> snapshots + <% end %> +
+ + <%# ── 2. KPI hero row ── %> +
+
+
<%= @tracked_users %>
+
Tracked users
+
of <%= number_with_delimiter(@total_users) %> total
+
+
+
<%= @total_subscribers %>
+
Active subs
+
<%= @xanax_received_past_month %> xanax (30d)
+
+
+
<%= number_with_delimiter(@api_calls_today) %>
+
API calls today
+
<%= number_with_delimiter(@api_calls_this_week) %> this week
+
+
+
<%= @api_peak_rate_today %>/min
+
Peak today
+
global budget <%= @global_budget %>/min
+
+
+
<%= @users_missing_yesterday %>
+
Missing yesterday
+
of <%= @tracked_users %> tracked (03:00 TCT)
+
+
+
<%= number_with_delimiter(@snapshots_this_week) %>
+
Snapshots this week
+
today: <%= @snapshots_today %>
+
+
+ + <%# ── 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| %> +
+ <%= 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? %> +
+ + + + + + <% @missing_dates_summary.each do |date, count| %> + + + + + + <% end %> + +
DateMissingof tracked
<%= date.strftime("%d %b") %><%= count %><%= @tracked_users > 0 ? "#{((count.to_f / @tracked_users) * 100).round(1)}%" : "—" %>
+
+ <% end %> +
+ +
+

Pipeline health

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

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

+ <% else %> +

Queue database unavailable.

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

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

+ <% end %> +
+ +
+
+ + <%# ── 5. Factions ── %> +
+

Factions

<%= @total_factions %> total · <%= @active_factions %> active · sorted by status, then members
+
+ <% if @faction_rows.any? %> +
+ + + + + + <% @faction_rows.each do |row| %> + + + + + + + + <% end %> + +
FactionMembersStatusSetup · API · TS · Poll · BFLast sync
<%= row[:faction].name %><%= row[:member_count] %> + <% case row[:status] %> + <% when :active %> + active + <% when :stale %> + stale <%= row[:stale_days] %>d + <% else %> + no setup + <% end %> + + + <% row[:caps].each do |on| %> + + <% end %> + + <%= row[:last_sync]&.strftime("%d %b %H:%M") || "—".html_safe %>
+
+

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

+ <% end %> +
+
+ + <%# ── 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? %> +
+ + + + + + <% @armory_by_faction.sort_by { |f| -f[:count] }.each do |f| %> + + + + + + <% end %> + +
FactionEntriesBackfill
<%= f[:faction].name %><%= number_with_delimiter(f[:count]) %><%= f[:backfill_pending] ? "running" : "done" %>
+
+ <% end %> +
+ +
+

Users & sessions

+
+
+ HoF users + <%= @hof_stats_users %> +
+
+ API keys + <%= @api_keys_configured %> +
+
+ Sessions this week + <%= @sign_ins_this_week %> · <%= @unique_sign_ins_this_week %> unique +
+
+ <% if @subscribed_factions.any? %> +
+ <% @subscribed_factions.each do |f| %> +
+ <%= f.name %> + <%= f.subscription.days_remaining %>d left +
+ <% end %> +
+ <% end %> + <% if @new_sign_ins.any? %> + + + + + + <% @new_sign_ins.each do |user| %> + + + + + <% end %> + +
New UserJoined
<%= user.name %><%= @first_session_dates[user.id]&.strftime("%d %b") || "—".html_safe %>
+ <% end %> +
+ +
+
+
diff --git a/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? %> + + + + + + <% @faction_subscriptions.each do |sub| %> + <% faction = sub.subscribable %> + <% next unless faction %> + + + + + + + <% end %> + +
FactionMbrExpiresDays
<%= faction.name %><%= faction.users.count %><%= sub.expires_at.strftime("%d %b %Y") %> + <%= sub.days_remaining %>d + + +
+ <% else %> +

No faction subscriptions.

+ <% end %> +
+ +
+

Individuals (<%= @individual_subscriptions.count %>)

+ <% if @individual_subscriptions.any? %> + + + + + + <% @individual_subscriptions.each do |sub| %> + <% user = sub.subscribable %> + <% next unless user %> + + + + + + + + <% end %> + +
PlayerLvlExpiresDays
<%= user.name %><%= user.level %><%= sub.expires_at.strftime("%d %b %Y") %> + <%= sub.days_remaining %>d + + + <%= button_to "Login as", admin_impersonate_path(user), method: :post, class: "btn btn-sm btn-secondary" %>
+ <% else %> +

No individual subscriptions.

+ <% end %> +
+
+ +<% if @recent_payments.any? %> +
+ Recent Payments (<%= @recent_payments.size %>) + + + + + + <% @recent_payments.each do |payment| %> + + + + + + + <% end %> + +
SenderXanaxWeeksDate
<%= payment.sender.name %><%= payment.xanax_amount %><%= payment.weeks_granted %><%= payment.processed_at.strftime("%d %b %H:%M") %>
+
+<% 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 @@ +
+
+
+

<%= @faction.name %>

+
+ +
+
+ + + + + + + + + <%= @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 +
+
+ +
+
+
+

Top Performers Last Week

+ <%= @week_start.strftime("%b %d") %> - <%= @week_end.strftime("%b %d, %Y") %> +
+ <% if @weekly_top_performers.any? && !@faction.backfill_in_progress? %> + + <% end %> +
+ <% if @faction.backfill_in_progress? %> +
+
+ + + + +
+ Fetching historical data + Stats will appear once backfill completes in +
+
+
+
+ <% 5.times do %> +
+ + + + +
+ <% end %> +
+ <% elsif @weekly_top_performers.any? %> + <% if @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 %> +
+ <% @weekly_top_performers.each_with_index do |performer, index| %> +
+ <%= index + 1 %> + <%= performer[:name] %> + <%= performer[:xanax_daily] %> xan/day + + <% case performer[:compliance_level] %> + <% when :compliant %> + + <% when :warning %> + + <% else %> + + <% end %> + +
+ <% end %> +
+ <% else %> +
+

No data available for this week yet.

+
+ <% end %> +
+ +
+ + + +
+
+
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 = ' + +
+

Training Compliance

+
+ + <% 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] %> + + + <% 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 %> +
+ + + + + + + + + + + + + + + + <% @member_rows.each do |row| %> + + + + + + + + + + + + <% end %> + +
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
+ + <%= 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 %> +
+
+
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 %> +
+
+
+
+ <%= @latest_war.won? ? "Victory" : "Defeat" %> +
+

Latest Ranked War

+

+ vs + + <%= @latest_war.opponent_faction_name %> + +

+
+ +
+
+ <%= @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? %> +
+

Top Performers

+
+ <% top_performers.each_with_index do |member, index| %> +
+ <%= index + 1 %> + + <%= link_to member["name"], "https://www.torn.com/profiles.php?XID=#{member["id"]}", target: "_blank", class: "player-link" %> + + + <%= member["score"].to_f.round(1) %> score + <%= member["attacks"] %> hits + +
+ <% end %> +
+
+ <% 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 @@ +
+
+
+

<%= @faction.name %> Leadership

+
+ +
+ <%= 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? %> +
+
+ <% @leadership_users.each do |user| %> +
+ + <%= user.name %> [<%= user.torn_id %>] + <% if user.faction_leader? %> + <%= user.position %> + <% end %> + + <% if user == Current.user || user.faction_leader? %> + Remove + <% else %> + <%= button_to "Remove", + faction_leadership_leadership_access_path(@faction, user_id: user.id), + method: :delete, + class: "whitelist-remove-btn" %> + <% end %> +
+ <% end %> +
+
+<% 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" %> + +
+
+
+ + + +
+ + <%# ── Tab 1: Calendar Heatmap ── %> + + + <%# ── Tab 2: Chain Coverage ── %> + + + <%# ── Tab 3: Members ── %> + +
+
+
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? %> + + + + + + + + + + + + + <% @api_logs.each do |log| %> + <% selections = log.selections.present? ? JSON.parse(log.selections) : {} %> + + + + + + + + + <% end %> + +
TIMESTAMPENDPOINTSELECTIONSSTATUSRESPONSE TIMEERROR
<%= 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) || "-" %>
+ <% 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" %> + +
+
+

Armory

+

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

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

No members currently have loaned items.

+ <% end %> +
diff --git a/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.each do |member| %> + + + + + + + + + + + + <% end %> +
MemberCoverageDays PresentDays Missing
+ + <%= 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] %>
+
+ <% 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

+
+
+ 1 + Send Xanax to Bram [2728237] +
+
+ 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 @@ +
+
+
+
+ + + + +
+

<%= @faction.name %>

+

Leadership Dashboard Setup

+
+ + <%= form_with model: @faction_setting, url: faction_leadership_setup_path(@faction), method: :patch, class: "setup-form" do |form| %> +
+
+ + <%= 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 +

+
+ +
+ + <%= 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 %> + + + + <% 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 %> + + <% end %> +
+ <% end %> + +
+ +
+

Spy Reports (<%= @spy_report_count %>)

+
+ + + + + + + + + + + + + + + + <% if @spy_reports.empty? %> + + + + <% else %> + <% @spy_reports.each_with_index do |report, index| %> + + + + + + + + + + + + <% end %> + <% end %> + +
#Torn IDStrengthDefenseSpeedDexterityTotalAge
+
+

No spy reports imported yet.

+
+
<%= 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 %> +
+
+
+
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 %> +
+ <% if @can_refresh %> + <%= button_to refresh_faction_leadership_war_history_path(@faction), + method: :post, class: "btn btn-sm btn-primary", + data: { turbo_submits_with: ' Fetching...' } do %> + Fetch Latest + <% end %> + <% else %> + + + + <% end %> +
+ +
+ <%= 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" %> + +
+ + + <% 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 %> + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+ Faction Cut: 0 + Payout Pot: 0 +
+
+ +
+
+

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

+ +
+
+ + + + + + + + + + + + + + + + + <% if @has_attacks %> + <% @member_stats.each do |member| %> + + + + + + + + + + + + + + + + + + + <% end %> + <% else %> + + <% end %> +
MemberRW HitsFF<1.25FF<1.75FF≥1.75OverseasAssistsWarlordRespectAvgPayout
+ + <%= member[:name] %> + + <%= member[:hits] %> + <% if member[:ff_low] > 0 %> + <%= member[:ff_low] %> + <% else %> + 0 + <% end %> + + <% if member[:ff_mid] > 0 %> + <%= member[:ff_mid] %> + <% else %> + 0 + <% end %> + <%= member[:ff_high] %> + <% if member[:overseas_hits] > 0 %> + <%= member[:overseas_hits] %> + <% else %> + 0 + <% end %> + + <% if member[:assists] > 0 %> + <%= member[:assists] %> + <% else %> + 0 + <% end %> + + <% if member[:warlord_hits] > 0 %> + <%= member[:warlord_hits] %> + <% else %> + 0 + <% end %> + <%= number_with_delimiter(member[:respect].round(0)) %><%= member[:avg_respect] %>
+
+
+
+ <% end %> +
diff --git a/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" %> +
+
+ +
+
+
+ Enemy Members + (0/0) + +
+
+ + + + + Live + +
+
+ +
+
+ Status +
+ Okay + Hospital + Jail + Traveling + Abroad +
+
+
+ Activity +
+ Online + Idle + Offline +
+
+
+ Max Stats +
+ + No limit +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ Member + + Lvl + + Status + + Activity + + Timer + + ? + + Hospital/Jail: countdown until release.
+ Travel: estimated arrival based on plane type and destination. Timers end up to 6s early so you're at the airport in time.
+ Dual times (e.g. 4:20 / 12:34): airliner could be BCT (fast) or Standard (slow) — Torn's API doesn't distinguish them.
+ Destination only (no timer): traveler was mid-flight when tracking started — no accurate ETA available. +
+
+ +
+ Total Stats + + STR + + DEF + + SPD + + DEX + + Actions +
Loading enemy members...
+
+
+
diff --git a/app/views/factions/ranked_wars/_member_performance_skeleton.html.erb b/app/views/factions/ranked_wars/_member_performance_skeleton.html.erb new file mode 100644 index 0000000..5812028 --- /dev/null +++ b/app/views/factions/ranked_wars/_member_performance_skeleton.html.erb @@ -0,0 +1,37 @@ +
+
+ Member Performance (All Wars) + Syncing... +
+ +
+ + + + + + + + + + + + + + + <% 8.times do |i| %> + + + + + + + + + + + <% end %> + +
#MemberWars ParticipatedTotal AttacksAvg AttacksTotal ScoreAvg ScoreAvg Respect/Hit
+
+
diff --git a/app/views/factions/ranked_wars/_member_performance_table.html.erb b/app/views/factions/ranked_wars/_member_performance_table.html.erb new file mode 100644 index 0000000..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? %> +
+ + + + + + + + + + + + + + + <% member_performance.each_with_index do |member, index| %> + + + + + + + + + + + <% end %> + +
#MemberWars ParticipatedTotal AttacksAvg AttacksTotal ScoreAvg ScoreAvg Respect/Hit
<%= index + 1 %> + <%= link_to member[:name], "https://www.torn.com/profiles.php?XID=#{member[:torn_id]}", target: "_blank", class: "player-link" %> + <%= member[:wars_participated] %><%= number_with_delimiter(member[:total_attacks]) %><%= member[:avg_attacks] %><%= number_with_delimiter(member[:total_score].round(0)) %><%= member[:avg_score] %><%= member[:avg_respect_per_hit] %>
+
+ <% else %> +
+
👤
+

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

+
+ <% end %> +
diff --git a/app/views/factions/ranked_wars/_war_history_skeleton.html.erb b/app/views/factions/ranked_wars/_war_history_skeleton.html.erb new file mode 100644 index 0000000..8d01f30 --- /dev/null +++ b/app/views/factions/ranked_wars/_war_history_skeleton.html.erb @@ -0,0 +1,68 @@ +
+
+ War History + Syncing... +
+ + + + + + + + + + + + + + + + + + <% 5.times do %> + + + + + + + + + + + + + <% end %> + +
StatusOpponentScoreDurationAttacksAvg RespectRankRewardsDate
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/views/factions/ranked_wars/_war_history_table.html.erb b/app/views/factions/ranked_wars/_war_history_table.html.erb new file mode 100644 index 0000000..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 %> +
+ + + + + + + + + + + + + + + + + <% wars.each do |war| %> + + + + + + + + + + + + + <% end %> + +
StatusOpponentScoreDurationAttacksAvg RespectRankRewardsDate
+ <% if war.scheduled? %> + SOON + <% elsif war.in_progress? %> + LIVE + <% elsif war.won? %> + WIN + <% else %> + LOSS + <% end %> + +
+ <%= 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.opponent_faction_id %>] +
+
+
+ <%= war.our_score %> – <%= war.their_score %> + / <%= war.target_score %> +
+
+ <% if war.scheduled? %> + Starts in <%= distance_of_time_in_words(war.starts_in_seconds) %> + <% else %> + <%= war.duration_formatted %> + <% end %> + +
+ <%= war.our_attacks %> + vs + <%= war.their_attacks %> +
+
+
+ <% if war.our_attacks.to_i > 0 && war.their_attacks.to_i > 0 %> + <%= war.score_per_attack %> + vs + <%= war.their_score_per_attack %> + <% else %> + - + <% end %> +
+
+ <% if war.rank_before && war.rank_after %> +
+ <%= war.rank_before %> + <%= war.rank_change %> + <%= war.rank_after %> +
+ <% else %> + - + <% end %> +
+
+ <% if war.respect_gained && war.respect_gained > 0 %> + +<%= number_with_delimiter(war.respect_gained) %> respect + <% end %> + <% if war.points_gained && war.points_gained > 0 %> + +<%= war.points_gained %> points + <% end %> + <% if (war.respect_gained.nil? || war.respect_gained == 0) && (war.points_gained.nil? || war.points_gained == 0) %> + - + <% end %> +
+
+
+ <%= war.started_at.strftime("%d %b %Y") %> + <%= war.started_at.strftime("%H:%M") %> +
+
+ <%= link_to faction_ranked_war_path(faction, war), class: "view-link", title: "View details", data: { turbo_frame: "_top" } do %> + + + + <% end %> +
+
+ <% 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

+
+
+ <%= @faction.name %> + + <%= @war.opponent_faction_name %> +
+
+ <%= 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) +
+
+ + + + + + + + + + + + <% @our_members.select { |m| m["attacks"].to_i > 0 }.each_with_index do |member, index| %> + + + + + + + + <% end %> + +
#MemberAttacksScoreRespect/Hit
<%= index + 1 %> + <%= link_to member["name"], "https://www.torn.com/profiles.php?XID=#{member["id"]}", target: "_blank", class: "player-link" %> + <%= member["attacks"] %><%= member["score"].to_f.round(1) %> + <% attacks = member["attacks"].to_i %> + <% score = member["score"].to_f %> + <%= attacks > 0 ? (score / attacks).round(2) : "-" %> +
+
+ + <% 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) +
+
+ + + + + + + + + + + + <% @their_members.select { |m| m["attacks"].to_i > 0 }.each_with_index do |member, index| %> + + + + + + + + <% end %> + +
#MemberAttacksScoreRespect/Hit
<%= index + 1 %> + <%= link_to member["name"], "https://www.torn.com/profiles.php?XID=#{member["id"]}", target: "_blank", class: "player-link" %> + <%= member["attacks"] %><%= member["score"].to_f.round(1) %> + <% attacks = member["attacks"].to_i %> + <% score = member["score"].to_f %> + <%= attacks > 0 ? (score / attacks).round(2) : "-" %> +
+
+
+
+ + +
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 @@ +
+
+
+

Set Up Your Faction

+

<%= @faction.name %>

+
+ +
+

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| %> +
+
+ + <%= 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 @@ +
+
+
+

Faction Not Set Up Yet

+

<%= @faction.name %>

+
+ +
+

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 @@ +
+
+
+

Subscription Expired

+

<%= @faction.name %>

+
+ +
+

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
  • +
+
+ +
+
+
+ Compliant +
+
+
+ Needs Attention +
+
+
+ Below Target +
+
+
+ +
+ + + + + + + + + + + + + + + + <% 10.times do %> + + + + + + + + + + + + <% end %> + +
StatusMEMBERXANAX
Target: <%= @faction.xanax_target %>/day
ENERGY REFILLS
Target: <%= @faction.energy_refill_target %>/day
NERVE REFILLS
Target: <%= @faction.nerve_refill_target %>/day
CONTRACTSCRIMESACTIVITY
min/day
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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" %> + +
+

War History

+
+ <%= @wins %>W + / + <%= @losses %>L + <%= Date.current.year %> +
+
+ +
+ <%= 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 = ' + +
+
+ <%= 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] %> + + + <% if params[:start_date].present? || params[:end_date].present? %> + <%= link_to "Reset", hall_of_famers_path, class: "filter-reset-inline" %> + <% end %> +
+ <% end %> +
+ + + + + + + + + + + + + + + <% @table_rows.each do |row| %> + + + + + + + + + + <% 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")) %>
+ <%= 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 %> + + +
+
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. +

+
+ +
+
+ +

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.

+
+
+
+
+
+
+ +
+
+ +

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.

+
+
+
+ +
+
+ +

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
+
+
+ +
+
+ +

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.

+
+
+ +
+ +
+
+ +

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" %> + +
+
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 @@ +
+
+

API Key Activity Log

+

View what requests have been made with your API key

+
+ + <%= 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

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

No recent API activity found for this key.

+
+ <% end %> +
+ +
+

What Am I Looking At?

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

Detailed Log Fields

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

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

+
+
+
diff --git a/app/views/layouts/_flash.html.erb b/app/views/layouts/_flash.html.erb new file mode 100644 index 0000000..56dcbf8 --- /dev/null +++ b/app/views/layouts/_flash.html.erb @@ -0,0 +1,26 @@ + diff --git a/app/views/layouts/_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 @@ -