diff --git a/.cursorrules b/.cursorrules
index 01862e5..453bb64 100644
--- a/.cursorrules
+++ b/.cursorrules
@@ -4,4 +4,7 @@
- Do not add unnecessary comments to the code where you remove things.
- Cypress tests are created for js tests. They can be found in cypress-tests/cypress
- Cypress tests can be run with docker: bash cypress-tests/run-cypress-tests.sh
-- Write as little code as possible. I do not want crazy non standard rails implementations.
\ No newline at end of file
+- Write as little code as possible. I do not want crazy non standard rails implementations.
+- This project is using propshaft and importmap.
+- Stimulus is used for javascript.
+- Use the context7 mcp server for context.
\ No newline at end of file
diff --git a/Gemfile b/Gemfile
index ad93562..b72ad50 100644
--- a/Gemfile
+++ b/Gemfile
@@ -29,6 +29,8 @@ gem 'sqlite3', ">= 2.1", :group => :development
gem 'jquery-rails'
# Turbo for modern page interactions
gem 'turbo-rails'
+# Stimulus for JavaScript behaviors
+gem 'stimulus-rails'
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
gem 'jbuilder'
# bundle exec rake doc:rails generates the API under doc/api.
diff --git a/Gemfile.lock b/Gemfile.lock
index 76ba936..9c950e8 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -263,6 +263,8 @@ GEM
sqlite3 (2.6.0-x86_64-darwin)
sqlite3 (2.6.0-x86_64-linux-gnu)
sqlite3 (2.6.0-x86_64-linux-musl)
+ stimulus-rails (1.3.4)
+ railties (>= 6.0.0)
stringio (3.1.7)
thor (1.3.2)
timeout (0.4.3)
@@ -320,6 +322,7 @@ DEPENDENCIES
solid_queue
spring
sqlite3 (>= 2.1)
+ stimulus-rails
turbo-rails
tzinfo-data
diff --git a/README.md b/README.md
index a94bdab..7e91e20 100644
--- a/README.md
+++ b/README.md
@@ -8,10 +8,12 @@ This application is being created to run a wrestling tournament.
**App Info**
* Ruby 3.2.0
-* Rails 8.0.0
+* Rails 8.0.2
* DB MySQL/MariaDB
-* Memcached
-* Solid Queue for background job processing
+* Solid Cache -> MySQL/MariaDB for html partial caching
+* Solid Queue -> MySQL/MariaDB for background job processing
+* Solid Cable -> MySQL/MariaDB for websocket channels
+* Hotwired Stimulus for client-side JavaScript
# Development
@@ -106,9 +108,24 @@ See `SOLID_QUEUE.md` for more details about the job system.
Note: If updating rails, do not change the version in `Gemfile` until after you run `bash bin/rails-dev-run.sh wrestlingdev-dev`. Creating the container will fail due to a mismatch in Gemfile and Gemfile.lock.
Then run `rails app:update` to update rails.
+## Stimulus Controllers
+
+The application uses Hotwired Stimulus for client-side JavaScript interactivity. Controllers can be found in `app/asssets/javascripts/controllers`
+
+### Testing Stimulus Controllers
+
+The Stimulus controllers are tested using Cypress end-to-end tests:
+
+```bash
+# Run Cypress tests in headless mode
+bash cypress-tests/run-cypress-tests.sh
+```
+
# Deployment
-The production version of this is currently deployed in Kubernetes. See [Deploying with Kubernetes](deploy/kubernetes/README.md)
+The production version of this is currently deployed in Kubernetes (via K3s). See [Deploying with Kubernetes](deploy/kubernetes/README.md)
+
+I'm using a Hetzner dedicated server with an i7-8700, 500GB NVME (RAID1), and 64GB ECC RAM. I have a hot standby (SQL read only replication) in my homelab.
## Server Configuration
@@ -122,11 +139,6 @@ The application uses an intelligent auto-scaling configuration for Puma (the web
- **SolidQueue Integration**: When `SOLID_QUEUE_IN_PUMA=true`, background jobs run within the Puma process.
- **Database Connection Pool**: Automatically sized based on the maximum number of threads across all workers.
-The configuration is designed to adapt to different environments:
-- Small servers: Uses fewer workers to avoid memory exhaustion
-- Large servers: Scales up to utilize available CPU cores
-- Development: Uses a single worker for simplicity
-
All of these settings can be overridden with environment variables if needed.
To see the current configuration in the logs, look for these lines on startup:
@@ -172,11 +184,11 @@ SolidQueue plugin enabled in Puma
* `WRESTLINGDEV_INFLUXDB_USERNAME` - InfluxDB username (optional)
* `WRESTLINGDEV_INFLUXDB_PASSWORD` - InfluxDB password (optional)
-See `SOLID_QUEUE.md` for details about the job system configuration.
-
This project provides multiple ways to develop and deploy, with Docker being the primary method.
-# Sprockets to Propshaft Migration
+# Frontend Assets
+
+## Sprockets to Propshaft Migration
- Propshaft will automatically include in its search paths the folders vendor/assets, lib/assets and app/assets of your project and of all the gems in your Gemfile. You can see all included files by using the reveal rake task: `rake assets:reveal`. When importing you'll use the relative path from this command.
- All css files are imported via `app/assets/stylesheets/application.css`. This is imported on `app/views/layouts/application.html.erb`.
@@ -185,4 +197,13 @@ This project provides multiple ways to develop and deploy, with Docker being the
- Jquery, bootstrap, datatables have been downloaded locally to `vendor/`
- Turbo and action cable are gems and get pathed properly by propshaft.
- development is "nobuild" with `config.assets.build_assets = false` in `config/environments/development.rb`
-- production needs to run rake assets:precompile. This is done in the `deploy/rails-prod-Dockerfile`.
\ No newline at end of file
+- production needs to run rake assets:precompile. This is done in the `deploy/rails-prod-Dockerfile`.
+
+## Stimulus Implementation
+
+The application has been migrated from using vanilla JavaScript to Hotwired Stimulus. The Stimulus controllers are organized in:
+
+- `app/assets/javascripts/controllers/` - Contains all Stimulus controllers
+- `app/assets/javascripts/application.js` - Registers and loads all controllers
+
+The importmap configuration in `config/importmap.rb` handles the loading of all JavaScript dependencies including Stimulus controllers.
\ No newline at end of file
diff --git a/SOLID_QUEUE.md b/SOLID_QUEUE.md
deleted file mode 100644
index 6274fd3..0000000
--- a/SOLID_QUEUE.md
+++ /dev/null
@@ -1,288 +0,0 @@
-# SolidQueue, SolidCache, and SolidCable Setup
-
-This application uses Rails 8's built-in background job processing, caching, and ActionCable features with separate dedicated databases.
-
-## Database Configuration
-
-We use separate databases for the main application, SolidQueue, SolidCache, and SolidCable. This ensures complete separation and avoids any conflicts or performance issues.
-
-In `config/database.yml`, we have the following setup:
-
-```yaml
-development:
- primary:
- database: db/development.sqlite3
- queue:
- database: db/development-queue.sqlite3
- cache:
- database: db/development-cache.sqlite3
- cable:
- database: db/development-cable.sqlite3
-
-test:
- primary:
- database: db/test.sqlite3
- queue:
- database: db/test-queue.sqlite3
- cache:
- database: db/test-cache.sqlite3
- cable:
- database: db/test-cable.sqlite3
-
-production:
- primary:
- database: <%= ENV['WRESTLINGDEV_DB_NAME'] %>
- queue:
- database: <%= ENV['WRESTLINGDEV_DB_NAME'] %>-queue
- cache:
- database: <%= ENV['WRESTLINGDEV_DB_NAME'] %>-cache
- cable:
- database: <%= ENV['WRESTLINGDEV_DB_NAME'] %>-cable
-```
-
-## Migration Structure
-
-Migrations for each database are stored in their respective directories:
-
-- Main application migrations: `db/migrate/`
-- SolidQueue migrations: `db/queue/migrate/`
-- SolidCache migrations: `db/cache/migrate/`
-- SolidCable migrations: `db/cable/migrate/`
-
-## Running Migrations
-
-When deploying the application, you need to run migrations for each database separately:
-
-```bash
-# Run main application migrations
-rails db:migrate
-
-# Run SolidQueue migrations
-rails db:migrate:queue
-
-# Run SolidCache migrations
-rails db:migrate:cache
-
-# Run SolidCable migrations
-rails db:migrate:cable
-```
-
-## Environment Configuration
-
-In the environment configuration files (`config/environments/*.rb`), we've configured the paths for migrations and set up the appropriate adapters:
-
-```ruby
-# SolidCache configuration
-config.cache_store = :solid_cache_store
-config.paths["db/migrate"] << "db/cache/migrate"
-
-# SolidQueue configuration
-config.active_job.queue_adapter = :solid_queue
-config.paths["db/migrate"] << "db/queue/migrate"
-
-# ActionCable configuration
-config.paths["db/migrate"] << "db/cable/migrate"
-```
-
-The database connections are configured in their respective YAML files:
-
-### config/cache.yml
-```yaml
-production:
- database: cache
- # other options...
-```
-
-### config/queue.yml
-```yaml
-production:
- database: queue
- # other options...
-```
-
-### config/cable.yml
-```yaml
-production:
- adapter: solid_cable
- database: cable
- # other options...
-```
-
-## SolidQueue Configuration
-
-SolidQueue is used for background job processing in all environments except test. The application is configured to run jobs as follows:
-
-### Development and Production
-In both development and production environments, SolidQueue is configured to process jobs asynchronously. This provides consistent behavior across environments while maintaining performance.
-
-### Test
-In the test environment only, jobs are executed synchronously using the inline adapter. This makes testing more predictable and avoids the need for separate worker processes during tests.
-
-Configuration is in `config/initializers/solid_queue.rb`:
-
-```ruby
-# Configure ActiveJob queue adapter based on environment
-if Rails.env.test?
- # In test, use inline adapter for simplicity and predictability
- Rails.application.config.active_job.queue_adapter = :inline
-else
- # In development and production, use solid_queue with async execution
- Rails.application.config.active_job.queue_adapter = :solid_queue
-
- # Configure for regular async processing
- Rails.application.config.active_job.queue_adapter_options = {
- execution_mode: :async,
- logger: Rails.logger
- }
-end
-```
-
-## Running with Puma
-
-By default, the application is configured to run SolidQueue workers within the Puma processes. This is done by setting the `SOLID_QUEUE_IN_PUMA` environment variable to `true` in the production Dockerfile, which enables the Puma plugin for SolidQueue.
-
-This means you don't need to run separate worker processes in production - the same Puma processes that handle web requests also handle background jobs. This simplifies deployment and reduces resource requirements.
-
-The application uses an intelligent auto-scaling configuration for SolidQueue when running in Puma:
-
-1. **Auto Detection**: The Puma configuration automatically detects available CPU cores and memory
-2. **Worker Scaling**: Puma workers are calculated based on available memory and CPU cores
-3. **SolidQueue Integration**: When enabled, SolidQueue simply runs within the Puma process
-
-You can enable SolidQueue in Puma by setting:
-```bash
-SOLID_QUEUE_IN_PUMA=true
-```
-
-In `config/puma.rb`:
-```ruby
-# Run the Solid Queue supervisor inside of Puma for single-server deployments
-if ENV["SOLID_QUEUE_IN_PUMA"] == "true" && !Rails.env.test?
- # Simply load the SolidQueue plugin with default settings
- plugin :solid_queue
-
- # Log that SolidQueue is enabled
- puts "SolidQueue plugin enabled in Puma"
-end
-```
-
-On startup, you'll see a log message confirming that SolidQueue is enabled in Puma.
-
-## Job Owner Tracking
-
-Jobs in this application include metadata about their owner (typically a tournament) to allow tracking and displaying job status to tournament directors. Each job includes:
-
-- `job_owner_id`: Usually the tournament ID
-- `job_owner_type`: A description of the job (e.g., "Create a backup")
-
-This information is serialized with the job and can be used to filter and display jobs on tournament pages.
-
-## Job Pattern: Simplified Job Enqueuing
-
-Our job classes follow a simplified pattern that works consistently across all environments:
-
-1. Service classes always use `perform_later` to enqueue jobs
-2. The execution mode is determined centrally by the ActiveJob adapter configuration
-3. Each job finds the needed records and calls the appropriate method on the service class or model
-
-Example service class method:
-```ruby
-def create_backup
- # Set up job owner information for tracking
- job_owner_id = @tournament.id
- job_owner_type = "Create a backup"
-
- # Use perform_later which will execute based on centralized adapter config
- TournamentBackupJob.perform_later(@tournament.id, @reason, job_owner_id, job_owner_type)
-end
-```
-
-Example job class:
-```ruby
-class TournamentBackupJob < ApplicationJob
- queue_as :default
-
- # For storing job owner metadata
- attr_accessor :job_owner_id, :job_owner_type
-
- # For execution via job queue with IDs
- def perform(tournament_id, reason, job_owner_id = nil, job_owner_type = nil)
- # Store job owner metadata
- self.job_owner_id = job_owner_id
- self.job_owner_type = job_owner_type
-
- # Find the record
- tournament = Tournament.find_by(id: tournament_id)
- return unless tournament
-
- # Create the service class and call the raw method
- TournamentBackupService.new(tournament, reason).create_backup_raw
- end
-end
-```
-
-## Job Classes
-
-The following job classes are available:
-
-- `AdvanceWrestlerJob`: For advancing wrestlers in brackets
-- `TournamentBackupJob`: For creating tournament backups
-- `WrestlingdevImportJob`: For importing from wrestlingdev
-- `GenerateTournamentMatchesJob`: For generating tournament matches
-- `CalculateSchoolScoreJob`: For calculating school scores
-
-## Job Status
-
-Jobs in this application can have the following statuses:
-
-1. **Running**: Job is currently being executed. This is determined by checking if a record exists in the `solid_queue_claimed_executions` table for the job.
-
-2. **Scheduled**: Job is scheduled to run at a future time. This is determined by checking if `scheduled_at` is in the future.
-
-3. **Error**: Job has failed. This is determined by:
- - Checking if a record exists in the `solid_queue_failed_executions` table for the job
- - Checking if `failed_at` is present
-
-4. **Completed**: Job has finished successfully. This is determined by checking if `finished_at` is present and no error records exist.
-
-5. **Pending**: Job is waiting to be picked up by a worker. This is the default status when none of the above conditions are met.
-
-## Testing Job Status
-
-To help with testing the job status display in the UI, several rake tasks are provided:
-
-```bash
-# Create a test "Running" job for the first tournament
-rails jobs:create_running
-
-# Create a test "Completed" job for the first tournament
-rails jobs:create_completed
-
-# Create a test "Error" job for the first tournament
-rails jobs:create_failed
-```
-
-## Troubleshooting
-
-If you encounter issues with SolidQueue or the separate databases:
-
-1. Make sure all databases exist:
- ```sql
- CREATE DATABASE IF NOT EXISTS wrestlingtourney;
- CREATE DATABASE IF NOT EXISTS wrestlingtourney-queue;
- CREATE DATABASE IF NOT EXISTS wrestlingtourney-cache;
- CREATE DATABASE IF NOT EXISTS wrestlingtourney-cable;
- ```
-
-2. Ensure all migrations have been run for each database.
-
-3. Check that environment configurations properly connect to the right databases.
-
-4. Verify that the database user has appropriate permissions for all databases.
-
-5. If jobs aren't processing in production, check that `SOLID_QUEUE_IN_PUMA` is set to `true` in your environment.
-
-## References
-
-- [SolidQueue README](https://github.com/rails/solid_queue)
-- [Rails Multiple Database Configuration](https://guides.rubyonrails.org/active_record_multiple_databases.html)
\ No newline at end of file
diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 4baac29..72b3fe7 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -7,19 +7,39 @@ import "jquery";
import "bootstrap";
import "datatables.net";
+// Stimulus setup
+import { Application } from "@hotwired/stimulus";
+
+// Initialize Stimulus application
+const application = Application.start();
+window.Stimulus = application;
+
+// Load all controllers from app/assets/javascripts/controllers
+// Import controllers manually
+import WrestlerColorController from "controllers/wrestler_color_controller";
+import MatchScoreController from "controllers/match_score_controller";
+import MatchDataController from "controllers/match_data_controller";
+import MatchSpectateController from "controllers/match_spectate_controller";
+
+// Register controllers
+application.register("wrestler-color", WrestlerColorController);
+application.register("match-score", MatchScoreController);
+application.register("match-data", MatchDataController);
+application.register("match-spectate", MatchSpectateController);
+
// Your existing Action Cable consumer setup
(function() {
try {
window.App || (window.App = {});
window.App.cable = createConsumer(); // Use the imported createConsumer
- console.log('Action Cable Consumer Created via app/javascript/application.js');
+ console.log('Action Cable Consumer Created via app/assets/javascripts/application.js');
} catch (e) {
console.error('Error creating ActionCable consumer:', e);
console.error('ActionCable not loaded or createConsumer failed, App.cable not created.');
}
}).call(this);
-console.log("Propshaft/Importmap application.js initialized with jQuery, Bootstrap, and DataTables.");
+console.log("Propshaft/Importmap application.js initialized with jQuery, Bootstrap, Stimulus, and DataTables.");
// If you have custom JavaScript files in app/javascript/ that were previously
// handled by Sprockets `require_tree`, you'll need to import them here explicitly.
diff --git a/app/assets/javascripts/controllers/match_data_controller.js b/app/assets/javascripts/controllers/match_data_controller.js
new file mode 100644
index 0000000..62232ba
--- /dev/null
+++ b/app/assets/javascripts/controllers/match_data_controller.js
@@ -0,0 +1,384 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = [
+ "w1Stat", "w2Stat", "statusIndicator"
+ ]
+
+ static values = {
+ tournamentId: Number,
+ boutNumber: Number,
+ matchId: Number
+ }
+
+ connect() {
+ console.log("Match data controller connected")
+
+ this.w1 = {
+ name: "w1",
+ stats: "",
+ updated_at: null,
+ timers: {
+ "injury": { time: 0, startTime: null, interval: null },
+ "blood": { time: 0, startTime: null, interval: null }
+ }
+ }
+
+ this.w2 = {
+ name: "w2",
+ stats: "",
+ updated_at: null,
+ timers: {
+ "injury": { time: 0, startTime: null, interval: null },
+ "blood": { time: 0, startTime: null, interval: null }
+ }
+ }
+
+ // Initial values
+ this.updateJsValues()
+
+ // Set up debounced handlers for text areas
+ this.debouncedW1Handler = this.debounce((el) => this.handleTextAreaInput(el, this.w1), 400)
+ this.debouncedW2Handler = this.debounce((el) => this.handleTextAreaInput(el, this.w2), 400)
+
+ // Set up text area event listeners
+ this.w1StatTarget.addEventListener('input', (event) => this.debouncedW1Handler(event.target))
+ this.w2StatTarget.addEventListener('input', (event) => this.debouncedW2Handler(event.target))
+
+ // Initialize from local storage
+ this.initializeFromLocalStorage()
+
+ // Setup ActionCable
+ if (this.matchIdValue) {
+ this.setupSubscription(this.matchIdValue)
+ }
+ }
+
+ disconnect() {
+ this.cleanupSubscription()
+ }
+
+ // Match stats core functionality
+ updateStats(wrestler, text) {
+ if (!wrestler) {
+ console.error("updateStats called with undefined wrestler")
+ return
+ }
+
+ wrestler.stats += text + " "
+ wrestler.updated_at = new Date().toISOString()
+ this.updateHtmlValues()
+ this.saveToLocalStorage(wrestler)
+
+ // Send the update via Action Cable if subscribed
+ if (this.matchSubscription) {
+ let payload = {}
+ if (wrestler.name === 'w1') payload.new_w1_stat = wrestler.stats
+ else if (wrestler.name === 'w2') payload.new_w2_stat = wrestler.stats
+
+ if (Object.keys(payload).length > 0) {
+ console.log('[ActionCable] updateStats performing send_stat:', payload)
+ this.matchSubscription.perform('send_stat', payload)
+ }
+ } else {
+ console.warn('[ActionCable] updateStats called but matchSubscription is null.')
+ }
+ }
+
+ // Specific methods for updating each wrestler
+ updateW1Stats(event) {
+ const text = event.currentTarget.dataset.matchDataText || ''
+ this.updateStats(this.w1, text)
+ }
+
+ updateW2Stats(event) {
+ const text = event.currentTarget.dataset.matchDataText || ''
+ this.updateStats(this.w2, text)
+ }
+
+ // End period action
+ endPeriod() {
+ this.updateStats(this.w1, '|End Period|')
+ this.updateStats(this.w2, '|End Period|')
+ }
+
+ handleTextAreaInput(textAreaElement, wrestler) {
+ const newValue = textAreaElement.value
+ console.log(`Text area input detected for ${wrestler.name}:`, newValue.substring(0, 50) + "...")
+
+ // Update the internal JS object
+ wrestler.stats = newValue
+ wrestler.updated_at = new Date().toISOString()
+
+ // Save to localStorage
+ this.saveToLocalStorage(wrestler)
+
+ // Send the update via Action Cable if subscribed
+ if (this.matchSubscription) {
+ let payload = {}
+ if (wrestler.name === 'w1') {
+ payload.new_w1_stat = wrestler.stats
+ } else if (wrestler.name === 'w2') {
+ payload.new_w2_stat = wrestler.stats
+ }
+ if (Object.keys(payload).length > 0) {
+ console.log('[ActionCable] Performing send_stat from textarea with payload:', payload)
+ this.matchSubscription.perform('send_stat', payload)
+ }
+ }
+ }
+
+ // Timer functions
+ startTimer(wrestler, timerKey) {
+ const timer = wrestler.timers[timerKey]
+ if (timer.interval) return // Prevent multiple intervals
+
+ timer.startTime = Date.now()
+ timer.interval = setInterval(() => {
+ const elapsedSeconds = Math.floor((Date.now() - timer.startTime) / 1000)
+ this.updateTimerDisplay(wrestler, timerKey, timer.time + elapsedSeconds)
+ }, 1000)
+ }
+
+ stopTimer(wrestler, timerKey) {
+ const timer = wrestler.timers[timerKey]
+ if (!timer.interval || !timer.startTime) return
+
+ clearInterval(timer.interval)
+ const elapsedSeconds = Math.floor((Date.now() - timer.startTime) / 1000)
+ timer.time += elapsedSeconds
+ timer.interval = null
+ timer.startTime = null
+
+ this.saveToLocalStorage(wrestler)
+ this.updateTimerDisplay(wrestler, timerKey, timer.time)
+ this.updateStatsBox(wrestler, timerKey, elapsedSeconds)
+ }
+
+ resetTimer(wrestler, timerKey) {
+ const timer = wrestler.timers[timerKey]
+ this.stopTimer(wrestler, timerKey)
+ timer.time = 0
+ this.updateTimerDisplay(wrestler, timerKey, 0)
+ this.saveToLocalStorage(wrestler)
+ }
+
+ // Timer control methods for W1
+ startW1InjuryTimer() {
+ this.startTimer(this.w1, 'injury')
+ }
+
+ stopW1InjuryTimer() {
+ this.stopTimer(this.w1, 'injury')
+ }
+
+ resetW1InjuryTimer() {
+ this.resetTimer(this.w1, 'injury')
+ }
+
+ startW1BloodTimer() {
+ this.startTimer(this.w1, 'blood')
+ }
+
+ stopW1BloodTimer() {
+ this.stopTimer(this.w1, 'blood')
+ }
+
+ resetW1BloodTimer() {
+ this.resetTimer(this.w1, 'blood')
+ }
+
+ // Timer control methods for W2
+ startW2InjuryTimer() {
+ this.startTimer(this.w2, 'injury')
+ }
+
+ stopW2InjuryTimer() {
+ this.stopTimer(this.w2, 'injury')
+ }
+
+ resetW2InjuryTimer() {
+ this.resetTimer(this.w2, 'injury')
+ }
+
+ startW2BloodTimer() {
+ this.startTimer(this.w2, 'blood')
+ }
+
+ stopW2BloodTimer() {
+ this.stopTimer(this.w2, 'blood')
+ }
+
+ resetW2BloodTimer() {
+ this.resetTimer(this.w2, 'blood')
+ }
+
+ updateTimerDisplay(wrestler, timerKey, totalTime) {
+ const elementId = `${wrestler.name}-${timerKey}-time`
+ const element = document.getElementById(elementId)
+ if (element) {
+ element.innerText = `${Math.floor(totalTime / 60)}m ${totalTime % 60}s`
+ }
+ }
+
+ updateStatsBox(wrestler, timerKey, elapsedSeconds) {
+ const timerType = timerKey.includes("injury") ? "Injury Time" : "Blood Time"
+ const formattedTime = `${Math.floor(elapsedSeconds / 60)}m ${elapsedSeconds % 60}s`
+ this.updateStats(wrestler, `${timerType}: ${formattedTime}`)
+ }
+
+ // Utility functions
+ generateKey(wrestler_name) {
+ return `${wrestler_name}-${this.tournamentIdValue}-${this.boutNumberValue}`
+ }
+
+ loadFromLocalStorage(wrestler_name) {
+ const key = this.generateKey(wrestler_name)
+ const data = localStorage.getItem(key)
+ return data ? JSON.parse(data) : null
+ }
+
+ saveToLocalStorage(person) {
+ const key = this.generateKey(person.name)
+ const data = {
+ stats: person.stats,
+ updated_at: person.updated_at,
+ timers: person.timers
+ }
+ localStorage.setItem(key, JSON.stringify(data))
+ }
+
+ updateHtmlValues() {
+ if (this.w1StatTarget) this.w1StatTarget.value = this.w1.stats
+ if (this.w2StatTarget) this.w2StatTarget.value = this.w2.stats
+ }
+
+ updateJsValues() {
+ if (this.w1StatTarget) this.w1.stats = this.w1StatTarget.value
+ if (this.w2StatTarget) this.w2.stats = this.w2StatTarget.value
+ }
+
+ debounce(func, wait) {
+ let timeout
+ return (...args) => {
+ clearTimeout(timeout)
+ timeout = setTimeout(() => func(...args), wait)
+ }
+ }
+
+ initializeTimers(wrestler) {
+ for (const timerKey in wrestler.timers) {
+ this.updateTimerDisplay(wrestler, timerKey, wrestler.timers[timerKey].time)
+ }
+ }
+
+ initializeFromLocalStorage() {
+ const w1Data = this.loadFromLocalStorage('w1')
+ if (w1Data) {
+ this.w1.stats = w1Data.stats || ''
+ this.w1.updated_at = w1Data.updated_at
+ if (w1Data.timers) this.w1.timers = w1Data.timers
+ this.initializeTimers(this.w1)
+ }
+
+ const w2Data = this.loadFromLocalStorage('w2')
+ if (w2Data) {
+ this.w2.stats = w2Data.stats || ''
+ this.w2.updated_at = w2Data.updated_at
+ if (w2Data.timers) this.w2.timers = w2Data.timers
+ this.initializeTimers(this.w2)
+ }
+
+ this.updateHtmlValues()
+ }
+
+ cleanupSubscription() {
+ if (this.matchSubscription) {
+ console.log(`[Stats AC Cleanup] Unsubscribing from match channel.`)
+ try {
+ this.matchSubscription.unsubscribe()
+ } catch (e) {
+ console.error(`[Stats AC Cleanup] Error during unsubscribe:`, e)
+ }
+ this.matchSubscription = null
+ }
+ }
+
+ setupSubscription(matchId) {
+ this.cleanupSubscription()
+ console.log(`[Stats AC Setup] Attempting subscription for match ID: ${matchId}`)
+
+ // Update status indicator
+ if (this.statusIndicatorTarget) {
+ this.statusIndicatorTarget.innerText = "Connecting to server for real-time stat updates..."
+ this.statusIndicatorTarget.classList.remove('alert-success', 'alert-warning', 'alert-danger')
+ this.statusIndicatorTarget.classList.add('alert-info')
+ }
+
+ // Exit if we don't have App.cable
+ if (!window.App || !window.App.cable) {
+ console.error(`[Stats AC Setup] Error: App.cable is not available.`)
+ if (this.statusIndicatorTarget) {
+ this.statusIndicatorTarget.innerText = "Error: WebSockets unavailable. Stats won't update in real-time."
+ this.statusIndicatorTarget.classList.remove('alert-info', 'alert-success', 'alert-warning')
+ this.statusIndicatorTarget.classList.add('alert-danger')
+ }
+ return
+ }
+
+ this.matchSubscription = App.cable.subscriptions.create(
+ {
+ channel: "MatchChannel",
+ match_id: matchId
+ },
+ {
+ connected: () => {
+ console.log(`[Stats AC] Connected to MatchStatsChannel for match ID: ${matchId}`)
+ if (this.statusIndicatorTarget) {
+ this.statusIndicatorTarget.innerText = "Connected: Stats will update in real-time."
+ this.statusIndicatorTarget.classList.remove('alert-info', 'alert-warning', 'alert-danger')
+ this.statusIndicatorTarget.classList.add('alert-success')
+ }
+ },
+
+ disconnected: () => {
+ console.log(`[Stats AC] Disconnected from MatchStatsChannel`)
+ if (this.statusIndicatorTarget) {
+ this.statusIndicatorTarget.innerText = "Disconnected: Stats updates paused."
+ this.statusIndicatorTarget.classList.remove('alert-info', 'alert-success', 'alert-danger')
+ this.statusIndicatorTarget.classList.add('alert-warning')
+ }
+ },
+
+ received: (data) => {
+ console.log(`[Stats AC] Received data:`, data)
+
+ // Update w1 stats
+ if (data.w1_stat !== undefined && this.w1StatTarget) {
+ console.log(`[Stats AC] Updating w1_stat: ${data.w1_stat.substring(0, 30)}...`)
+ this.w1.stats = data.w1_stat
+ this.w1StatTarget.value = data.w1_stat
+ }
+
+ // Update w2 stats
+ if (data.w2_stat !== undefined && this.w2StatTarget) {
+ console.log(`[Stats AC] Updating w2_stat: ${data.w2_stat.substring(0, 30)}...`)
+ this.w2.stats = data.w2_stat
+ this.w2StatTarget.value = data.w2_stat
+ }
+ },
+
+ receive_error: (error) => {
+ console.error(`[Stats AC] Error:`, error)
+ this.matchSubscription = null
+
+ if (this.statusIndicatorTarget) {
+ this.statusIndicatorTarget.innerText = "Error: Connection issue. Stats won't update in real-time."
+ this.statusIndicatorTarget.classList.remove('alert-info', 'alert-success', 'alert-warning')
+ this.statusIndicatorTarget.classList.add('alert-danger')
+ }
+ }
+ }
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/assets/javascripts/controllers/match_score_controller.js b/app/assets/javascripts/controllers/match_score_controller.js
new file mode 100644
index 0000000..34d431c
--- /dev/null
+++ b/app/assets/javascripts/controllers/match_score_controller.js
@@ -0,0 +1,237 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = [
+ "winType", "winnerSelect", "submitButton", "dynamicScoreInput",
+ "finalScoreField", "validationAlerts", "pinTimeTip"
+ ]
+
+ static values = {
+ winnerScore: { type: String, default: "0" },
+ loserScore: { type: String, default: "0" }
+ }
+
+ connect() {
+ console.log("Match score controller connected")
+ // Use setTimeout to ensure the DOM is fully loaded
+ setTimeout(() => {
+ this.updateScoreInput()
+ this.validateForm()
+ }, 50)
+ }
+
+ winTypeChanged() {
+ this.updateScoreInput()
+ this.validateForm()
+ }
+
+ winnerChanged() {
+ this.validateForm()
+ }
+
+ updateScoreInput() {
+ const winType = this.winTypeTarget.value
+ this.dynamicScoreInputTarget.innerHTML = ""
+
+ // Add section header
+ const header = document.createElement("h5")
+ header.innerText = `Score Input for ${winType}`
+ header.classList.add("mt-2", "mb-3")
+ this.dynamicScoreInputTarget.appendChild(header)
+
+ if (winType === "Pin") {
+ this.pinTimeTipTarget.style.display = "block"
+
+ const minuteInput = this.createTextInput("minutes", "Minutes (MM)", "Pin Time Minutes")
+ const secondInput = this.createTextInput("seconds", "Seconds (SS)", "Pin Time Seconds")
+
+ this.dynamicScoreInputTarget.appendChild(minuteInput)
+ this.dynamicScoreInputTarget.appendChild(secondInput)
+
+ // Add event listeners to the new inputs
+ const inputs = this.dynamicScoreInputTarget.querySelectorAll("input")
+ inputs.forEach(input => {
+ input.addEventListener("input", () => {
+ this.updatePinTimeScore()
+ this.validateForm()
+ })
+ })
+
+ this.updatePinTimeScore()
+ } else if (["Decision", "Major", "Tech Fall"].includes(winType)) {
+ this.pinTimeTipTarget.style.display = "none"
+
+ const winnerScoreInput = this.createTextInput(
+ "winner-score",
+ "Winner's Score",
+ "Enter the winner's score"
+ )
+ const loserScoreInput = this.createTextInput(
+ "loser-score",
+ "Loser's Score",
+ "Enter the loser's score"
+ )
+
+ this.dynamicScoreInputTarget.appendChild(winnerScoreInput)
+ this.dynamicScoreInputTarget.appendChild(loserScoreInput)
+
+ // Restore stored values
+ const winnerInput = winnerScoreInput.querySelector("input")
+ const loserInput = loserScoreInput.querySelector("input")
+
+ winnerInput.value = this.winnerScoreValue
+ loserInput.value = this.loserScoreValue
+
+ // Add event listeners to the new inputs
+ winnerInput.addEventListener("input", (event) => {
+ this.winnerScoreValue = event.target.value || "0"
+ this.updatePointScore()
+ this.validateForm()
+ })
+
+ loserInput.addEventListener("input", (event) => {
+ this.loserScoreValue = event.target.value || "0"
+ this.updatePointScore()
+ this.validateForm()
+ })
+
+ this.updatePointScore()
+ } else {
+ // For other types (forfeit, etc.), clear the input and hide pin time tip
+ this.pinTimeTipTarget.style.display = "none"
+ this.finalScoreFieldTarget.value = ""
+
+ // Show message for non-score win types
+ const message = document.createElement("p")
+ message.innerText = `No score required for ${winType} win type.`
+ message.classList.add("text-muted")
+ this.dynamicScoreInputTarget.appendChild(message)
+ }
+
+ this.validateForm()
+ }
+
+ updatePinTimeScore() {
+ const minuteInput = this.dynamicScoreInputTarget.querySelector("#minutes")
+ const secondInput = this.dynamicScoreInputTarget.querySelector("#seconds")
+
+ if (minuteInput && secondInput) {
+ const minutes = (minuteInput.value || "0").padStart(2, "0")
+ const seconds = (secondInput.value || "0").padStart(2, "0")
+ this.finalScoreFieldTarget.value = `${minutes}:${seconds}`
+
+ // Validate after updating pin time
+ this.validateForm()
+ }
+ }
+
+ updatePointScore() {
+ const winnerScore = this.winnerScoreValue || "0"
+ const loserScore = this.loserScoreValue || "0"
+ this.finalScoreFieldTarget.value = `${winnerScore}-${loserScore}`
+
+ // Validate immediately after updating scores
+ this.validateForm()
+ }
+
+ validateForm() {
+ const winType = this.winTypeTarget.value
+ const winner = this.winnerSelectTarget?.value
+ let isValid = true
+ let alertMessage = ""
+ let winTypeShouldBe = "Decision"
+
+ // Clear previous validation messages
+ this.validationAlertsTarget.innerHTML = ""
+ this.validationAlertsTarget.style.display = "none"
+ this.validationAlertsTarget.classList.remove("alert", "alert-danger", "p-3")
+
+ if (["Decision", "Major", "Tech Fall"].includes(winType)) {
+ // Get scores and ensure they're valid numbers
+ const winnerScore = parseInt(this.winnerScoreValue || "0", 10)
+ const loserScore = parseInt(this.loserScoreValue || "0", 10)
+
+ console.log(`Validating scores: winner=${winnerScore}, loser=${loserScore}, type=${winType}`)
+
+ // Check if winner score > loser score
+ if (winnerScore <= loserScore) {
+ isValid = false
+ alertMessage += "Error: Winner's score must be higher than loser's score.
"
+ } else {
+ // Calculate score difference and determine correct win type
+ const scoreDifference = winnerScore - loserScore
+
+ if (scoreDifference < 8) {
+ winTypeShouldBe = "Decision"
+ } else if (scoreDifference >= 8 && scoreDifference < 15) {
+ winTypeShouldBe = "Major"
+ } else if (scoreDifference >= 15) {
+ winTypeShouldBe = "Tech Fall"
+ }
+
+ // Check if selected win type matches the correct one based on score difference
+ if (winTypeShouldBe !== winType) {
+ isValid = false
+ alertMessage += `
+ Win Type Error: Win type should be ${winTypeShouldBe}.
+
| Name: <%= @wrestler1_name %>
- School: <%= @wrestler1_school_name %> - Last Match: <%= @wrestler1_last_match && @wrestler1_last_match.finished_at ? time_ago_in_words(@wrestler1_last_match.finished_at) : "N/A" %> |
- Name: <%= @wrestler2_name %>
- School: <%= @wrestler2_school_name %> - Last Match: <%= @wrestler2_last_match && @wrestler2_last_match.finished_at ? time_ago_in_words(@wrestler2_last_match.finished_at) : "N/A" %> |
-
|---|---|
| <%= @wrestler1_name %> Stats: <%= f.text_area :w1_stat, cols: "30", rows: "10" %> |
- <%= @wrestler2_name %> Stats: <%= f.text_area :w2_stat, cols: "30", rows: "10" %> |
-
| <%= @wrestler1_name %> Scoring - - - - - - - - |
- <%= @wrestler2_name %> Scoring - - - - - - - - |
-
| <%= @wrestler1_name %> Choice - - - |
- <%= @wrestler2_name %> Choice - - - |
-
| <%= @wrestler1_name %> Warnings - |
- <%= @wrestler2_name %> Warnings - |
-
| Match Options |
- - |
- <%= @wrestler1_name %> Timer Controls- Injury Time (90 second max): 0 sec - - - -- Blood Time (600 second max): 0 sec - - - - |
-
- <%= @wrestler2_name %> Timer Controls- Injury Time (90 second max): 0 sec - - - -- Blood Time (600 second max): 0 sec - - - - |
-
| Name: <%= @wrestler1_name %>
+ School: <%= @wrestler1_school_name %> + Last Match: <%= @wrestler1_last_match && @wrestler1_last_match.finished_at ? time_ago_in_words(@wrestler1_last_match.finished_at) : "N/A" %> |
+ Name: <%= @wrestler2_name %>
+ School: <%= @wrestler2_school_name %> + Last Match: <%= @wrestler2_last_match && @wrestler2_last_match.finished_at ? time_ago_in_words(@wrestler2_last_match.finished_at) : "N/A" %> |
+
|---|---|
| <%= @wrestler1_name %> Stats: <%= f.text_area :w1_stat, cols: "30", rows: "10", data: { match_data_target: "w1Stat" } %> |
+ <%= @wrestler2_name %> Stats: <%= f.text_area :w2_stat, cols: "30", rows: "10", data: { match_data_target: "w2Stat" } %> |
+
| <%= @wrestler1_name %> Scoring + + + + + + + + |
+ <%= @wrestler2_name %> Scoring + + + + + + + + |
+
| <%= @wrestler1_name %> Choice + + + |
+ <%= @wrestler2_name %> Choice + + + |
+
| <%= @wrestler1_name %> Warnings + |
+ <%= @wrestler2_name %> Warnings + |
+
| Match Options |
+ + |
+ <%= @wrestler1_name %> Timer Controls+ Injury Time (90 second max): 0 sec + + + ++ Blood Time (600 second max): 0 sec + + + + |
+
+ <%= @wrestler2_name %> Timer Controls+ Injury Time (90 second max): 0 sec + + + ++ Blood Time (600 second max): 0 sec + + + + |
+
<%= @match.w1_stat %>+ + +
<%= @match.w1_stat %>+
<%= @match.w2_stat %>+
Winner: <%= @match.winner_id ? @match.winner.name : '-' %>
+Win Type: <%= @match.win_type || '-' %>
+Score: <%= @match.score || '-' %>
+Finished: <%= @match.finished ? 'Yes' : 'No' %>
<%= @match.w2_stat %>-
Winner: <%= @match.winner_id ? @match.winner.name : '-' %>
-Win Type: <%= @match.win_type || '-' %>
-Score: <%= @match.score || '-' %>
-Finished: <%= @match.finished ? 'Yes' : 'No' %>
-