diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index 505b818..91bd060 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -19,12 +19,14 @@ 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";
+import UpMatchesConnectionController from "controllers/up_matches_connection_controller";
// Register controllers
application.register("wrestler-color", WrestlerColorController);
application.register("match-score", MatchScoreController);
application.register("match-data", MatchDataController);
application.register("match-spectate", MatchSpectateController);
+application.register("up-matches-connection", UpMatchesConnectionController);
// Your existing Action Cable consumer setup
(function() {
diff --git a/app/assets/javascripts/controllers/up_matches_connection_controller.js b/app/assets/javascripts/controllers/up_matches_connection_controller.js
new file mode 100644
index 0000000..fbbdfb1
--- /dev/null
+++ b/app/assets/javascripts/controllers/up_matches_connection_controller.js
@@ -0,0 +1,70 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = ["stream", "statusIndicator"]
+
+ connect() {
+ this.setupSubscription()
+ }
+
+ disconnect() {
+ this.cleanupSubscription()
+ }
+
+ setupSubscription() {
+ this.cleanupSubscription()
+ this.setStatus("Connecting to server for real-time bout board updates...", "info")
+
+ if (!this.hasStreamTarget) {
+ this.setStatus("Error: Stream source not found.", "danger")
+ return
+ }
+
+ const signedStreamName = this.streamTarget.getAttribute("signed-stream-name")
+ if (!signedStreamName) {
+ this.setStatus("Error: Invalid stream source.", "danger")
+ return
+ }
+
+ if (!window.App || !window.App.cable) {
+ this.setStatus("Error: WebSockets unavailable. Bout board won't update in real-time. Refresh the page to update.", "danger")
+ return
+ }
+
+ this.subscription = App.cable.subscriptions.create(
+ {
+ channel: "Turbo::StreamsChannel",
+ signed_stream_name: signedStreamName
+ },
+ {
+ connected: () => {
+ this.setStatus("Connected: Bout board updating in real-time.", "success")
+ },
+ disconnected: () => {
+ this.setStatus("Disconnected: Live bout board updates paused.", "warning")
+ },
+ rejected: () => {
+ this.setStatus("Error: Live bout board connection rejected.", "danger")
+ }
+ }
+ )
+ }
+
+ cleanupSubscription() {
+ if (!this.subscription) return
+ this.subscription.unsubscribe()
+ this.subscription = null
+ }
+
+ setStatus(message, type) {
+ if (!this.hasStatusIndicatorTarget) return
+
+ this.statusIndicatorTarget.innerText = message
+ this.statusIndicatorTarget.classList.remove("alert-secondary", "alert-info", "alert-success", "alert-warning", "alert-danger")
+
+ if (type === "success") this.statusIndicatorTarget.classList.add("alert-success")
+ else if (type === "warning") this.statusIndicatorTarget.classList.add("alert-warning")
+ else if (type === "danger") this.statusIndicatorTarget.classList.add("alert-danger")
+ else this.statusIndicatorTarget.classList.add("alert-info")
+ }
+}
diff --git a/app/controllers/tournaments_controller.rb b/app/controllers/tournaments_controller.rb
index 76562e9..17bfa9f 100644
--- a/app/controllers/tournaments_controller.rb
+++ b/app/controllers/tournaments_controller.rb
@@ -3,7 +3,7 @@ class TournamentsController < ApplicationController
before_action :check_access_manage, only: [:delete_school_keys, :generate_school_keys,:reset_bout_board,:calculate_team_scores,:swap,:weigh_in_sheet,:teampointadjust,:remove_teampointadjust,:remove_school_delegate,:school_delegate,:weigh_in,:weigh_in_weight,:create_custom_weights,:update,:edit,:generate_matches,:matches,:qrcode]
before_action :check_access_destroy, only: [:destroy,:delegate,:remove_delegate]
before_action :check_tournament_errors, only: [:generate_matches]
- before_action :check_for_matches, only: [:all_results,:up_matches,:bracket,:all_brackets]
+ before_action :check_for_matches, only: [:all_results,:bracket,:all_brackets]
before_action :check_access_read, only: [:all_results,:up_matches,:bracket,:all_brackets]
def weigh_in_sheet
@@ -263,16 +263,8 @@ class TournamentsController < ApplicationController
def up_matches
- # .where.not(loser1_name: 'BYE') won't return matches with NULL loser1_name
- # so I was only getting back matches with Loser of BOUT_NUMBER
- @matches = @tournament.matches
- .where("mat_id is NULL and (finished != 1 or finished is NULL)")
- .where("loser1_name != ? OR loser1_name IS NULL", "BYE")
- .where("loser2_name != ? OR loser2_name IS NULL", "BYE")
- .order('bout_number ASC')
- .limit(10)
- .includes({ wrestler1: :school }, { wrestler2: :school }, { weight: :matches })
- @mats = @tournament.mats.includes(:matches)
+ @matches = @tournament.up_matches_unassigned_matches
+ @mats = @tournament.up_matches_mats
end
def bout_sheets
diff --git a/app/models/mat.rb b/app/models/mat.rb
index 68c00cb..2ef4085 100644
--- a/app/models/mat.rb
+++ b/app/models/mat.rb
@@ -9,6 +9,7 @@ class Mat < ApplicationRecord
QUEUE_SLOTS = %w[queue1 queue2 queue3 queue4].freeze
after_save :clear_queue_matches_cache
+ after_commit :broadcast_up_matches_board, on: :update, if: :up_matches_queue_changed?
def assign_next_match
slot = first_empty_queue_slot
@@ -276,4 +277,12 @@ class Mat < ApplicationRecord
)
end
+ def broadcast_up_matches_board
+ Tournament.broadcast_up_matches_board(tournament_id)
+ end
+
+ def up_matches_queue_changed?
+ saved_change_to_queue1? || saved_change_to_queue2? || saved_change_to_queue3? || saved_change_to_queue4?
+ end
+
end
diff --git a/app/models/match.rb b/app/models/match.rb
index 5d91498..b7b639b 100644
--- a/app/models/match.rb
+++ b/app/models/match.rb
@@ -17,6 +17,7 @@ class Match < ApplicationRecord
# update mat show with correct match if bout board is reset
# this is done with a turbo stream
after_commit :broadcast_mat_assignment_change, if: :saved_change_to_mat_id?, on: [:create, :update]
+ after_commit :broadcast_up_matches_board, on: :update, if: :saved_change_to_mat_id?
# Enqueue advancement and related actions after the DB transaction has committed.
# Using after_commit ensures any background jobs enqueued inside these callbacks
@@ -371,4 +372,8 @@ class Match < ApplicationRecord
)
end
end
+
+ def broadcast_up_matches_board
+ Tournament.broadcast_up_matches_board(tournament_id)
+ end
end
diff --git a/app/models/tournament.rb b/app/models/tournament.rb
index 120752a..a5f273b 100644
--- a/app/models/tournament.rb
+++ b/app/models/tournament.rb
@@ -69,6 +69,32 @@ class Tournament < ApplicationRecord
end
end
+ def up_matches_unassigned_matches
+ matches
+ .where("mat_id is NULL and (finished != 1 or finished is NULL)")
+ .where("loser1_name != ? OR loser1_name IS NULL", "BYE")
+ .where("loser2_name != ? OR loser2_name IS NULL", "BYE")
+ .order("bout_number ASC")
+ .limit(10)
+ .includes({ wrestler1: :school }, { wrestler2: :school }, { weight: :matches })
+ end
+
+ def up_matches_mats
+ mats.includes(:matches)
+ end
+
+ def self.broadcast_up_matches_board(tournament_id)
+ tournament = find_by(id: tournament_id)
+ return unless tournament
+
+ Turbo::StreamsChannel.broadcast_replace_to(
+ tournament,
+ target: "up_matches_board",
+ partial: "tournaments/up_matches_board",
+ locals: { tournament: tournament }
+ )
+ end
+
def destroy_all_matches
matches.destroy_all
end
diff --git a/app/services/tournament_services/generate_tournament_matches.rb b/app/services/tournament_services/generate_tournament_matches.rb
index a5cc291..4ac47e8 100644
--- a/app/services/tournament_services/generate_tournament_matches.rb
+++ b/app/services/tournament_services/generate_tournament_matches.rb
@@ -32,6 +32,7 @@ class GenerateTournamentMatches
@tournament.reset_and_fill_bout_board
@tournament.curently_generating_matches = nil
@tournament.save!
+ Tournament.broadcast_up_matches_board(@tournament.id)
end
def assignBouts
diff --git a/app/views/tournaments/_up_matches_board.html.erb b/app/views/tournaments/_up_matches_board.html.erb
new file mode 100644
index 0000000..523b73d
--- /dev/null
+++ b/app/views/tournaments/_up_matches_board.html.erb
@@ -0,0 +1,45 @@
+
+
Upcoming Matches
+
+
+
+ | Mat |
+ On Mat |
+ On Deck |
+ In The Hole |
+ Warm Up |
+
+
+
+
+ <% (local_assigns[:mats] || tournament.up_matches_mats).each do |m| %>
+ <%= render "tournaments/up_matches_mat_row", mat: m %>
+ <% end %>
+
+
+
+
Matches not assigned
+
+
+
+
+ | Round |
+ Bout Number |
+ Weight Class |
+ Matchup |
+
+
+
+
+ <% (local_assigns[:matches] || tournament.up_matches_unassigned_matches).each do |m| %>
+
+ | Round <%= m.round %> |
+ <%= m.bout_number %> |
+ <%= m.weight_max %> |
+ <%= m.w1_bracket_name %> vs. <%= m.w2_bracket_name %> |
+
+ <% end %>
+
+
+
+
diff --git a/app/views/tournaments/up_matches.html.erb b/app/views/tournaments/up_matches.html.erb
index 540388d..2db55c4 100644
--- a/app/views/tournaments/up_matches.html.erb
+++ b/app/views/tournaments/up_matches.html.erb
@@ -1,70 +1,19 @@
-
-
-
- This page reloads every 30s
-
- Upcoming Matches
-
-
-
-
- | Mat |
- On Mat |
- On Deck |
- In The Hole |
- Warm Up |
-
-
+ <%= turbo_stream_from @tournament, data: { up_matches_connection_target: "stream" } %>
+
+ Connecting to server for real-time up matches updates...
+
-
- <% @mats.each.map do |m| %>
- <%= render "up_matches_mat_row", mat: m %>
- <% end %>
-
-
-
- Matches not assigned
-
-
-
-
- | Round |
- Bout Number |
- Weight Class |
- Matchup |
-
-
-
-
- <% if @matches.size > 0 %>
- <% @matches.each.map do |m| %>
-
- | Round <%= m.round %> |
- <%= m.bout_number %> |
- <%= m.weight_max %> |
- <%= m.w1_bracket_name %> vs. <%= m.w2_bracket_name %> |
-
- <% end %>
- <% end %>
-
-
-
-
+ <%= render "up_matches_board", tournament: @tournament, mats: @mats, matches: @matches %>
+
diff --git a/config/cable.yml b/config/cable.yml
index b9adc5a..b981d12 100644
--- a/config/cable.yml
+++ b/config/cable.yml
@@ -1,9 +1,10 @@
-# Async adapter only works within the same process, so for manually triggering cable updates from a console,
-# and seeing results in the browser, you must do so from the web console (running inside the dev process),
-# not a terminal started via bin/rails console! Add "console" to any action or any ERB template view
-# to make the web console appear.
development:
- adapter: async
+ adapter: solid_cable
+ connects_to:
+ database:
+ writing: cable
+ polling_interval: 0.1.seconds
+ message_retention: 1.day
test:
adapter: test
diff --git a/test/controllers/tournaments_controller_test.rb b/test/controllers/tournaments_controller_test.rb
index 1531548..92a78fb 100644
--- a/test/controllers/tournaments_controller_test.rb
+++ b/test/controllers/tournaments_controller_test.rb
@@ -559,6 +559,36 @@ class TournamentsControllerTest < ActionController::TestCase
get_up_matches
success
end
+
+ test "up matches uses turbo stream updates instead of timer refresh script" do
+ @tournament.is_public = true
+ @tournament.save
+ get_up_matches
+ success
+ assert_includes response.body, "turbo-cable-stream-source"
+ assert_includes response.body, "data-controller=\"up-matches-connection\""
+ assert_includes response.body, "up-matches-cable-status-indicator"
+ assert_not_includes response.body, "This page reloads every 30s"
+ end
+
+ test "up matches shows full screen button when print param is not true" do
+ @tournament.is_public = true
+ @tournament.save
+ get :up_matches, params: { id: @tournament.id }
+ assert_response :success
+
+ assert_includes response.body, "Show Bout Board in Full Screen"
+ assert_includes response.body, "print=true"
+ end
+
+ test "up matches hides full screen button when print param is true" do
+ @tournament.is_public = true
+ @tournament.save
+ get :up_matches, params: { id: @tournament.id, print: "true" }
+ assert_response :success
+
+ assert_not_includes response.body, "Show Bout Board in Full Screen"
+ end
# END UP MATCHES PAGE PERMISSIONS
# ALL_RESULTS PAGE PERMISSIONS WHEN TOURNAMENT IS NOT PUBLIC
@@ -643,11 +673,11 @@ class TournamentsControllerTest < ActionController::TestCase
# END ALL_RESULTS PAGE PERMISSIONS
#TESTS THAT NEED MATCHES PUT ABOVE THIS
- test "redirect up_matches if no matches" do
+ test "up_matches renders when no matches exist" do
sign_in_owner
wipe
get :up_matches, params: { id: 1 }
- no_matches
+ success
end
test "redirect bracket if no matches" do
diff --git a/test/controllers/up_matches_cache_test.rb b/test/controllers/up_matches_cache_test.rb
index 81901ad..7c80274 100644
--- a/test/controllers/up_matches_cache_test.rb
+++ b/test/controllers/up_matches_cache_test.rb
@@ -40,9 +40,9 @@ class UpMatchesCacheTest < ActionController::TestCase
mat.reload
movable_match = mat.queue2_match || mat.queue1_match
assert movable_match, "Expected at least one queued match to move"
- mat.assign_match_to_queue!(movable_match, 4)
third_events = cache_events_for_up_matches do
+ mat.assign_match_to_queue!(movable_match, 4)
get :up_matches, params: { id: @tournament.id }
assert_response :success
end
diff --git a/test/integration/random_seeding_test.rb b/test/integration/random_seeding_test.rb
index 69b08d5..b105d62 100644
--- a/test/integration/random_seeding_test.rb
+++ b/test/integration/random_seeding_test.rb
@@ -25,6 +25,7 @@ class RandomSeedingTest < ActionDispatch::IntegrationTest
end
test "There are the same number of matches in the top half and bottom half of a double elimination tournament in round 1" do
+ # This has to be an even number otherwise there will obviously be a discrepency
create_double_elim_tournament_single_weight(18, "Regular Double Elimination 1-8")
clean_up_original_seeds(@tournament)
round_one_matches = @tournament.matches.reload.select{|m| m.round == 1}
@@ -35,6 +36,7 @@ class RandomSeedingTest < ActionDispatch::IntegrationTest
end
test "There are the same number of matches in the top half and bottom half of a double elimination tournament in round 1 in a 6 man bracket" do
+ # This has to be an even number otherwise there will obviously be a discrepency
create_double_elim_tournament_single_weight(6, "Regular Double Elimination 1-8")
clean_up_original_seeds(@tournament)
round_one_matches = @tournament.matches.reload.select{|m| m.round == 1}
@@ -52,4 +54,22 @@ class RandomSeedingTest < ActionDispatch::IntegrationTest
assert round_one_matches.select{|m| m.w1.nil? and m.w2.nil? }.size == 0
assert conso_round_one_matches.select{|m| m.loser1_name == "BYE" and m.loser2_name == "BYE" }.size == 0
end
+
+ test "There are no double byes in a 64 person double elimination tournament in round 1" do
+ create_double_elim_tournament_single_weight(33, "Regular Double Elimination 1-8")
+ clean_up_original_seeds(@tournament)
+ round_one_matches = @tournament.matches.reload.select{|m| m.round == 1}
+ assert round_one_matches.select{|m| m.w1.nil? and m.w2.nil? }.size == 0
+ end
+
+ test "There are the same number of matches in the top half and bottom half of a 64 person double elimination tournament in round 1" do
+ # This has to be an even number otherwise there will obviously be a discrepency
+ create_double_elim_tournament_single_weight(34, "Regular Double Elimination 1-8")
+ clean_up_original_seeds(@tournament)
+ round_one_matches = @tournament.matches.reload.select{|m| m.round == 1}
+ # 64 man bracket there are 32 matches so top half is bracket_position_number 1-16 and bottom is 17-32
+ round_one_top_half = round_one_matches.select{|m| !m.w1.nil? and !m.w2.nil? and m.bracket_position_number < 17}
+ round_one_bottom_half = round_one_matches.select{|m| !m.w1.nil? and !m.w2.nil? and m.bracket_position_number > 16}
+ assert round_one_top_half.size == round_one_bottom_half.size
+ end
end
\ No newline at end of file
diff --git a/test/models/up_matches_broadcast_test.rb b/test/models/up_matches_broadcast_test.rb
new file mode 100644
index 0000000..1e3126b
--- /dev/null
+++ b/test/models/up_matches_broadcast_test.rb
@@ -0,0 +1,87 @@
+require "test_helper"
+
+class UpMatchesBroadcastTest < ActiveSupport::TestCase
+ test "tournament broadcaster emits replace action for up matches board" do
+ tournament = tournaments(:one)
+ stream = stream_name_for(tournament)
+ clear_streams(stream)
+
+ Tournament.broadcast_up_matches_board(tournament.id)
+
+ assert_operator broadcasts_for(stream).size, :>, 0
+ payload = broadcasts_for(stream).last
+ assert_up_matches_replace_payload(payload)
+ end
+
+ test "mat queue change broadcasts up matches board update" do
+ tournament = tournaments(:one)
+ mat = mats(:one)
+ match = matches(:tournament_1_bout_2000)
+ stream = stream_name_for(tournament)
+ clear_streams(stream)
+
+ mat.update!(queue1: match.id)
+
+ assert_operator broadcasts_for(stream).size, :>, 0
+ assert_up_matches_replace_payload(broadcasts_for(stream).last)
+ end
+
+ test "match mat assignment change broadcasts up matches board update" do
+ tournament = tournaments(:one)
+ mat = mats(:one)
+ match = matches(:tournament_1_bout_2001)
+ stream = stream_name_for(tournament)
+ clear_streams(stream)
+
+ match.update!(mat_id: mat.id)
+
+ assert_operator broadcasts_for(stream).size, :>, 0
+ assert_up_matches_replace_payload(broadcasts_for(stream).last)
+ end
+
+ test "mat update without queue slot changes does not broadcast up matches board update" do
+ tournament = tournaments(:one)
+ mat = mats(:one)
+ stream = stream_name_for(tournament)
+ clear_streams(stream)
+
+ mat.update!(name: "Mat One Renamed")
+
+ assert_equal 0, broadcasts_for(stream).size
+ end
+
+ test "match update without mat_id change does not broadcast up matches board update" do
+ tournament = tournaments(:one)
+ match = matches(:tournament_1_bout_2001)
+ stream = stream_name_for(tournament)
+ clear_streams(stream)
+
+ match.update!(w1_stat: "Local stat change")
+
+ assert_equal 0, broadcasts_for(stream).size
+ end
+
+ private
+
+ def broadcasts_for(stream)
+ ActionCable.server.pubsub.broadcasts(stream)
+ end
+
+ def clear_streams(*streams)
+ ActionCable.server.pubsub.clear
+ streams.each do |stream|
+ broadcasts_for(stream).clear
+ end
+ end
+
+ def stream_name_for(streamable)
+ Turbo::StreamsChannel.send(:stream_name_from, [streamable])
+ end
+
+ # Broadcast payloads may be JSON-escaped in test adapters, so assert semantic markers.
+ def assert_up_matches_replace_payload(payload)
+ assert_includes payload, "up_matches_board"
+ assert_includes payload, "replace"
+ assert_includes payload, "turbo-stream"
+ end
+end