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

+ + + + + + + + + + + + + <% (local_assigns[:mats] || tournament.up_matches_mats).each do |m| %> + <%= render "tournaments/up_matches_mat_row", mat: m %> + <% end %> + +
Mat      On MatOn DeckIn The HoleWarm Up
+
+

Matches not assigned

+
+ + + + + + + + + + + + <% (local_assigns[:matches] || tournament.up_matches_unassigned_matches).each do |m| %> + + + + + + + <% end %> + +
RoundBout NumberWeight ClassMatchup
Round <%= m.round %><%= m.bout_number %><%= m.weight_max %><%= m.w1_bracket_name %> vs. <%= m.w2_bracket_name %>
+
+
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

-
- - - - - - - - - - + <%= 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 %> - -
Mat      On MatOn DeckIn The HoleWarm Up
-
-

Matches not assigned

-
- - - - - - - - - - - - <% if @matches.size > 0 %> - <% @matches.each.map do |m| %> - - - - - - - <% end %> - <% end %> - -
RoundBout NumberWeight ClassMatchup
Round <%= m.round %><%= m.bout_number %><%= m.weight_max %><%= m.w1_bracket_name %> vs. <%= m.w2_bracket_name %>
- -
+ <%= 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