mirror of
https://github.com/jcwimer/wrestlingApp
synced 2026-05-13 09:09:18 +00:00
Compare commits
16 Commits
0df1706b2c
...
developmen
| Author | SHA1 | Date | |
|---|---|---|---|
| c1b01f0dac | |||
| a031cfb446 | |||
| c210b70c95 | |||
| 7526148ba5 | |||
| e8e0fa291b | |||
| 679fc2fcb9 | |||
| 18d39c6c8f | |||
| ca4d5ce0db | |||
| 654cb84827 | |||
| dc50efe8fc | |||
| 8670ce38c3 | |||
| d359be3ea1 | |||
| e97aa0d680 | |||
| ae8d995b2c | |||
| d57aaac09d | |||
| fcc8a9b9a9 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -21,6 +21,7 @@ tmp
|
||||
.rvmrc
|
||||
deploy/prod.env
|
||||
frontend/node_modules
|
||||
node_modules
|
||||
.aider*
|
||||
|
||||
# Ignore cypress test results
|
||||
@@ -34,3 +35,6 @@ cypress-tests/cypress/videos
|
||||
|
||||
# generated by cine mcp settings
|
||||
~/
|
||||
|
||||
/.ruby-lsp
|
||||
.codex
|
||||
|
||||
@@ -1 +1 @@
|
||||
ruby-3.2.0
|
||||
ruby-4.0.1
|
||||
11
AGENTS.md
Normal file
11
AGENTS.md
Normal file
@@ -0,0 +1,11 @@
|
||||
- I have two ways to run rails commands in the repo. Either use rvm with `rvm use 4.0.1; rvm gemset use wrestlingdev;` or use docker with `docker run -it -v $(pwd):/rails wrestlingdev-dev <rails command>`
|
||||
- If the docker image doesn't exist, use the build command: `docker build -t wrestlingdev-dev -f deploy/rails-dev-Dockerfile .`
|
||||
- If the Gemfile changes, you need to rebuild the docker image: `docker build -t wrestlingdev-dev -f deploy/rails-dev-Dockerfile .`
|
||||
- Do not add unnecessary comments to the code where you remove things.
|
||||
- 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.
|
||||
- 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
|
||||
- javascript tests are through vitest. See `vitest.config.js`. Run `npm run test:js`
|
||||
- importmap pins in `importmap.rb` and aliases in `vitest.config.js` need to match.
|
||||
4
Gemfile
4
Gemfile
@@ -1,6 +1,6 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '3.2.0'
|
||||
ruby '4.0.1'
|
||||
# Bundle edge Rails instead: gem 'rails', github: 'rails/rails'
|
||||
gem 'rails', '8.1.2'
|
||||
|
||||
@@ -67,6 +67,7 @@ gem 'influxdb-rails'
|
||||
gem 'cancancan'
|
||||
gem 'round_robin_tournament'
|
||||
gem 'rb-readline'
|
||||
gem 'rqrcode'
|
||||
# Replacing Delayed Job with Solid Queue
|
||||
# gem 'delayed_job_active_record'
|
||||
gem 'solid_queue'
|
||||
@@ -91,4 +92,3 @@ group :development, :test do
|
||||
# rails-controller-testing is needed for assert_template
|
||||
gem 'rails-controller-testing'
|
||||
end
|
||||
|
||||
|
||||
102
Gemfile.lock
102
Gemfile.lock
@@ -1,7 +1,7 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
action_text-trix (2.1.16)
|
||||
action_text-trix (2.1.18)
|
||||
railties
|
||||
actioncable (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
@@ -77,11 +77,11 @@ GEM
|
||||
uri (>= 0.13.1)
|
||||
ast (2.4.3)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.21)
|
||||
bigdecimal (4.0.1)
|
||||
bootsnap (1.22.0)
|
||||
bcrypt (3.1.22)
|
||||
bigdecimal (4.1.1)
|
||||
bootsnap (1.23.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (8.0.2)
|
||||
brakeman (8.0.4)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bullet (8.1.0)
|
||||
@@ -91,13 +91,14 @@ GEM
|
||||
bundler (>= 1.2.0)
|
||||
thor (~> 1.0)
|
||||
cancancan (3.6.1)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (3.0.2)
|
||||
crass (1.0.6)
|
||||
daemons (1.4.1)
|
||||
date (3.5.1)
|
||||
drb (2.2.3)
|
||||
erb (6.0.1)
|
||||
erb (6.0.2)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
@@ -117,8 +118,9 @@ GEM
|
||||
influxdb (~> 0.6, >= 0.6.4)
|
||||
railties (>= 5.0)
|
||||
io-console (0.8.2)
|
||||
irb (1.16.0)
|
||||
irb (1.17.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.14.1)
|
||||
@@ -128,11 +130,11 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.18.1)
|
||||
json (2.19.3)
|
||||
language_server-protocol (3.17.0.5)
|
||||
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)
|
||||
@@ -143,7 +145,8 @@ GEM
|
||||
net-smtp
|
||||
marcel (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
minitest (6.0.1)
|
||||
minitest (6.0.3)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
mission_control-jobs (1.1.0)
|
||||
actioncable (>= 7.1)
|
||||
@@ -155,12 +158,12 @@ 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)
|
||||
mysql2 (0.5.7)
|
||||
bigdecimal
|
||||
net-imap (0.6.2)
|
||||
net-imap (0.6.3)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -170,24 +173,24 @@ GEM
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.0-aarch64-linux-gnu)
|
||||
nokogiri (1.19.2-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.0-aarch64-linux-musl)
|
||||
nokogiri (1.19.2-aarch64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.0-arm-linux-gnu)
|
||||
nokogiri (1.19.2-arm-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.0-arm-linux-musl)
|
||||
nokogiri (1.19.2-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.0-arm64-darwin)
|
||||
nokogiri (1.19.2-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.0-x86_64-darwin)
|
||||
nokogiri (1.19.2-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.0-x86_64-linux-gnu)
|
||||
nokogiri (1.19.2-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.0-x86_64-linux-musl)
|
||||
nokogiri (1.19.2-x86_64-linux-musl)
|
||||
racc (~> 1.4)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.10.1)
|
||||
parallel (2.0.1)
|
||||
parser (3.3.11.1)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pp (0.6.3)
|
||||
@@ -201,12 +204,12 @@ GEM
|
||||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
puma (7.2.0)
|
||||
puma (8.0.0)
|
||||
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)
|
||||
@@ -235,8 +238,8 @@ GEM
|
||||
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)
|
||||
rails_12factor (0.0.3)
|
||||
rails_serve_static_assets
|
||||
@@ -253,28 +256,32 @@ GEM
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
rake (13.4.1)
|
||||
rb-readline (0.5.5)
|
||||
rdoc (7.1.0)
|
||||
rdoc (7.2.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
regexp_parser (2.11.3)
|
||||
regexp_parser (2.12.0)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
round_robin_tournament (0.1.2)
|
||||
rubocop (1.84.1)
|
||||
rqrcode (3.2.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.1.0)
|
||||
rubocop (1.86.1)
|
||||
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.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.0)
|
||||
rubocop-ast (1.49.1)
|
||||
parser (>= 3.3.7.2)
|
||||
prism (~> 1.7)
|
||||
ruby-progressbar (1.13.0)
|
||||
@@ -291,7 +298,7 @@ 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)
|
||||
@@ -299,26 +306,26 @@ GEM
|
||||
railties (>= 7.1)
|
||||
thor (>= 1.3.1)
|
||||
spring (4.4.2)
|
||||
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.2-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.2-aarch64-linux-musl)
|
||||
sqlite3 (2.9.2-arm-linux-gnu)
|
||||
sqlite3 (2.9.2-arm-linux-musl)
|
||||
sqlite3 (2.9.2-arm64-darwin)
|
||||
sqlite3 (2.9.2-x86_64-darwin)
|
||||
sqlite3 (2.9.2-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.2-x86_64-linux-musl)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.2.0)
|
||||
thor (1.5.0)
|
||||
timeout (0.6.0)
|
||||
timeout (0.6.1)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.23)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
tzinfo-data (1.2025.3)
|
||||
tzinfo-data (1.2026.1)
|
||||
tzinfo (>= 1.0.0)
|
||||
unicode-display_width (3.2.0)
|
||||
unicode-emoji (~> 4.1)
|
||||
@@ -330,7 +337,7 @@ GEM
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
zeitwerk (2.7.4)
|
||||
zeitwerk (2.7.5)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux-gnu
|
||||
@@ -365,6 +372,7 @@ DEPENDENCIES
|
||||
rails_12factor
|
||||
rb-readline
|
||||
round_robin_tournament
|
||||
rqrcode
|
||||
rubocop
|
||||
sdoc
|
||||
solid_cable
|
||||
@@ -377,7 +385,7 @@ DEPENDENCIES
|
||||
tzinfo-data
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.2.0p0
|
||||
ruby 4.0.1p0
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.9
|
||||
4.0.3
|
||||
|
||||
48
README.md
48
README.md
@@ -7,7 +7,7 @@ This application is being created to run a wrestling tournament.
|
||||
**Public Production Url:** [https://wrestlingdev.com](https://wrestlingdev.com)
|
||||
|
||||
**App Info**
|
||||
* Ruby 3.2.0
|
||||
* Ruby 4.0.1
|
||||
* Rails 8.1.2
|
||||
* DB MySQL/MariaDB
|
||||
* Solid Cache -> MySQL/MariaDB for html partial caching
|
||||
@@ -43,6 +43,46 @@ To run a single test inside a file:
|
||||
To run tests in verbose mode (outputs the time for each test file and the test file name)
|
||||
`rails test -v`
|
||||
|
||||
## JavaScript tests with Vitest
|
||||
|
||||
Stimulus controllers and match-state JavaScript helpers are tested with Vitest. These tests live in `test/javascript`.
|
||||
|
||||
Run all JavaScript tests:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run test:js
|
||||
```
|
||||
|
||||
Run one JavaScript test file:
|
||||
|
||||
```bash
|
||||
npm run test:js -- test/javascript/match_state/engine.test.js
|
||||
```
|
||||
|
||||
Run JavaScript tests in watch mode:
|
||||
|
||||
```bash
|
||||
npm run test:js:watch
|
||||
```
|
||||
|
||||
The full test runner also runs Vitest before Rails tests:
|
||||
|
||||
```bash
|
||||
bash bin/run-all-tests.sh
|
||||
```
|
||||
|
||||
Vitest currently covers client-side logic that is hard to test well with Minitest alone:
|
||||
|
||||
* The match-state rules engine: scoring, control changes, period choices, event replay, deletion, swapping, timers, accumulated match time, result defaults, and scoreboard payload generation.
|
||||
* Stimulus controller behavior for the state page, legacy stat page, match result form, mat state page, scoreboard, spectate page, and live score updates.
|
||||
* LocalStorage behavior for state/stat persistence, tournament-scoped keys, expiration timestamps, and cleanup of expired app-owned keys.
|
||||
* Websocket payload handling at the JavaScript boundary, including deduped outbound state/stat messages and inbound scoreboard/spectate updates.
|
||||
|
||||
Minitest still owns the Rails side: controllers, permissions, models, channels, redirects, rendered ERB, and database behavior. Vitest fills the gap for logic that runs entirely in the browser without needing Cypress or a full browser session.
|
||||
|
||||
Cypress tests are deprecated for this project. Use Vitest for JavaScript unit coverage and Minitest for Rails behavior.
|
||||
|
||||
## Develop with rvm
|
||||
With rvm installed, run `rvm install ruby-3.2.0`
|
||||
Then, `cd ../; cd wrestlingApp`. This will load the gemset file in this repo.
|
||||
@@ -83,6 +123,7 @@ Whether you have a shell from docker or are using rvm you can now run normal rai
|
||||
* etc.
|
||||
* `rake finish_seed_tournaments` will complete all matches from the seed data. This command takes about 5 minutes to execute
|
||||
* `rake assets:clobber` - removes previously compiled assets stored in `public/assets` forcing Rails to recompile them from scratch the next time they are requested.
|
||||
* `bundle-audit check --update` - check for vulnerabilities in Gemfile.lock
|
||||
|
||||
## Testing Job Status
|
||||
|
||||
@@ -117,11 +158,10 @@ The application uses Hotwired Stimulus for client-side JavaScript interactivity.
|
||||
|
||||
### Testing Stimulus Controllers
|
||||
|
||||
The Stimulus controllers are tested using Cypress end-to-end tests:
|
||||
Stimulus controllers are tested with Vitest:
|
||||
|
||||
```bash
|
||||
# Run Cypress tests in headless mode
|
||||
bash cypress-tests/run-cypress-tests.sh
|
||||
npm run test:js
|
||||
```
|
||||
|
||||
# Deployment
|
||||
|
||||
@@ -5,10 +5,10 @@ import "@hotwired/turbo-rails";
|
||||
import { createConsumer } from "@rails/actioncable"; // Import createConsumer directly
|
||||
import "jquery";
|
||||
import "bootstrap";
|
||||
import "datatables.net";
|
||||
|
||||
// Stimulus setup
|
||||
import { Application } from "@hotwired/stimulus";
|
||||
import { cleanupExpiredLocalStorage } from "match-state-transport";
|
||||
|
||||
// Initialize Stimulus application
|
||||
const application = Application.start();
|
||||
@@ -19,13 +19,28 @@ window.Stimulus = application;
|
||||
import WrestlerColorController from "controllers/wrestler_color_controller";
|
||||
import MatchScoreController from "controllers/match_score_controller";
|
||||
import MatchDataController from "controllers/match_data_controller";
|
||||
import MatchStateController from "controllers/match_state_controller";
|
||||
import MatchScoreboardController from "controllers/match_scoreboard_controller";
|
||||
import MatStateController from "controllers/mat_state_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-state", MatchStateController);
|
||||
application.register("match-scoreboard", MatchScoreboardController);
|
||||
application.register("mat-state", MatStateController);
|
||||
application.register("match-spectate", MatchSpectateController);
|
||||
application.register("up-matches-connection", UpMatchesConnectionController);
|
||||
|
||||
function cleanupWrestlingAppLocalStorage() {
|
||||
cleanupExpiredLocalStorage(window.localStorage);
|
||||
}
|
||||
|
||||
document.addEventListener("turbo:load", cleanupWrestlingAppLocalStorage);
|
||||
cleanupWrestlingAppLocalStorage();
|
||||
|
||||
// Your existing Action Cable consumer setup
|
||||
(function() {
|
||||
@@ -39,7 +54,7 @@ application.register("match-spectate", MatchSpectateController);
|
||||
}
|
||||
}).call(this);
|
||||
|
||||
console.log("Propshaft/Importmap application.js initialized with jQuery, Bootstrap, Stimulus, and DataTables.");
|
||||
console.log("Propshaft/Importmap application.js initialized with jQuery, Bootstrap, and Stimulus.");
|
||||
|
||||
// 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.
|
||||
|
||||
169
app/assets/javascripts/controllers/mat_state_controller.js
Normal file
169
app/assets/javascripts/controllers/mat_state_controller.js
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import {
|
||||
loadJson,
|
||||
removeKey,
|
||||
saveJson,
|
||||
SHORT_LIVED_TTL_MS
|
||||
} from "match-state-transport"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
tournamentId: Number,
|
||||
matId: Number,
|
||||
boutNumber: Number,
|
||||
matchId: Number,
|
||||
selectMatchUrl: String,
|
||||
weightLabel: String,
|
||||
w1Id: Number,
|
||||
w2Id: Number,
|
||||
w1Name: String,
|
||||
w2Name: String,
|
||||
w1School: String,
|
||||
w2School: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.boundHandleSubmit = this.handleSubmit.bind(this)
|
||||
this.saveSelectedBout()
|
||||
this.broadcastSelectedBout()
|
||||
this.element.addEventListener("submit", this.boundHandleSubmit)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener("submit", this.boundHandleSubmit)
|
||||
}
|
||||
|
||||
storageKey() {
|
||||
return `mat-selected-bout:${this.tournamentIdValue}:${this.matIdValue}`
|
||||
}
|
||||
|
||||
saveSelectedBout() {
|
||||
if (!this.matIdValue || this.matIdValue <= 0) return
|
||||
|
||||
try {
|
||||
saveJson(window.localStorage, this.storageKey(), {
|
||||
boutNumber: this.boutNumberValue,
|
||||
matchId: this.matchIdValue,
|
||||
updatedAt: Date.now()
|
||||
}, { ttlMs: SHORT_LIVED_TTL_MS })
|
||||
} catch (_error) {
|
||||
}
|
||||
}
|
||||
|
||||
broadcastSelectedBout() {
|
||||
if (!this.hasSelectMatchUrlValue || !this.selectMatchUrlValue) return
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
|
||||
const body = new URLSearchParams()
|
||||
if (this.matchIdValue) body.set("match_id", this.matchIdValue.toString())
|
||||
if (this.boutNumberValue) body.set("bout_number", this.boutNumberValue.toString())
|
||||
|
||||
const lastMatchResult = this.loadLastMatchResult()
|
||||
if (lastMatchResult) body.set("last_match_result", lastMatchResult)
|
||||
|
||||
fetch(this.selectMatchUrlValue, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-Token": csrfToken || "",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"
|
||||
},
|
||||
body,
|
||||
credentials: "same-origin"
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
lastMatchResultStorageKey() {
|
||||
return `mat-last-match-result:${this.tournamentIdValue}:${this.matIdValue}`
|
||||
}
|
||||
|
||||
saveLastMatchResult(text) {
|
||||
if (!this.matIdValue || this.matIdValue <= 0) return
|
||||
|
||||
try {
|
||||
if (text) {
|
||||
saveJson(window.localStorage, this.lastMatchResultStorageKey(), text, { ttlMs: SHORT_LIVED_TTL_MS })
|
||||
} else {
|
||||
removeKey(window.localStorage, this.lastMatchResultStorageKey())
|
||||
}
|
||||
} catch (_error) {
|
||||
}
|
||||
}
|
||||
|
||||
loadLastMatchResult() {
|
||||
try {
|
||||
return loadJson(window.localStorage, this.lastMatchResultStorageKey()) || ""
|
||||
} catch (_error) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
handleSubmit(event) {
|
||||
const form = event.target
|
||||
if (!(form instanceof HTMLFormElement)) return
|
||||
|
||||
const resultText = this.buildLastMatchResult(form)
|
||||
if (!resultText) return
|
||||
|
||||
this.saveLastMatchResult(resultText)
|
||||
this.broadcastCurrentState(resultText)
|
||||
}
|
||||
|
||||
broadcastCurrentState(lastMatchResult) {
|
||||
if (!this.hasSelectMatchUrlValue || !this.selectMatchUrlValue) return
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content
|
||||
const body = new URLSearchParams()
|
||||
if (this.matchIdValue) body.set("match_id", this.matchIdValue.toString())
|
||||
if (this.boutNumberValue) body.set("bout_number", this.boutNumberValue.toString())
|
||||
if (lastMatchResult) body.set("last_match_result", lastMatchResult)
|
||||
|
||||
fetch(this.selectMatchUrlValue, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-Token": csrfToken || "",
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"Accept": "text/vnd.turbo-stream.html, text/html, application/xhtml+xml"
|
||||
},
|
||||
body,
|
||||
credentials: "same-origin",
|
||||
keepalive: true
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
buildLastMatchResult(form) {
|
||||
const winnerId = form.querySelector("#match_winner_id")?.value
|
||||
const winType = form.querySelector("#match_win_type")?.value
|
||||
const score = form.querySelector("#final-score-field")?.value
|
||||
if (!winnerId || !winType) return ""
|
||||
|
||||
const winner = this.participantDataForId(winnerId)
|
||||
const loser = this.loserParticipantData(winnerId)
|
||||
if (!winner || !loser) return ""
|
||||
|
||||
const weightLabel = this.hasWeightLabelValue ? this.weightLabelValue : ""
|
||||
return `${weightLabel} lbs - ${winner.name} (${winner.school}) ${winType} ${loser.name} (${loser.school}) ${score || ""}`.trim()
|
||||
}
|
||||
|
||||
participantDataForId(participantId) {
|
||||
const normalizedId = String(participantId)
|
||||
if (normalizedId === String(this.w1IdValue)) {
|
||||
return { name: this.w1NameValue, school: this.w1SchoolValue }
|
||||
}
|
||||
if (normalizedId === String(this.w2IdValue)) {
|
||||
return { name: this.w2NameValue, school: this.w2SchoolValue }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
loserParticipantData(winnerId) {
|
||||
const normalizedId = String(winnerId)
|
||||
if (normalizedId === String(this.w1IdValue)) {
|
||||
return { name: this.w2NameValue, school: this.w2SchoolValue }
|
||||
}
|
||||
if (normalizedId === String(this.w2IdValue)) {
|
||||
return { name: this.w1NameValue, school: this.w1SchoolValue }
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import {
|
||||
loadJson,
|
||||
saveJson,
|
||||
MATCH_DATA_TTL_MS
|
||||
} from "match-state-transport"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
@@ -238,8 +243,7 @@ export default class extends Controller {
|
||||
|
||||
loadFromLocalStorage(wrestler_name) {
|
||||
const key = this.generateKey(wrestler_name)
|
||||
const data = localStorage.getItem(key)
|
||||
return data ? JSON.parse(data) : null
|
||||
return loadJson(localStorage, key)
|
||||
}
|
||||
|
||||
saveToLocalStorage(person) {
|
||||
@@ -249,7 +253,7 @@ export default class extends Controller {
|
||||
updated_at: person.updated_at,
|
||||
timers: person.timers
|
||||
}
|
||||
localStorage.setItem(key, JSON.stringify(data))
|
||||
saveJson(localStorage, key, data, { ttlMs: MATCH_DATA_TTL_MS })
|
||||
}
|
||||
|
||||
updateHtmlValues() {
|
||||
|
||||
@@ -2,25 +2,44 @@ import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"winType", "winnerSelect", "submitButton", "dynamicScoreInput",
|
||||
"winType", "overtimeSelect", "winnerSelect", "submitButton", "dynamicScoreInput",
|
||||
"finalScoreField", "validationAlerts", "pinTimeTip"
|
||||
]
|
||||
|
||||
static values = {
|
||||
winnerScore: { type: String, default: "0" },
|
||||
loserScore: { type: String, default: "0" }
|
||||
loserScore: { type: String, default: "0" },
|
||||
pinMinutes: { type: String, default: "0" },
|
||||
pinSeconds: { type: String, default: "00" },
|
||||
manualOverride: { type: Boolean, default: false },
|
||||
finished: { type: Boolean, default: false }
|
||||
}
|
||||
|
||||
connect() {
|
||||
console.log("Match score controller connected")
|
||||
// Use setTimeout to ensure the DOM is fully loaded
|
||||
this.boundMarkManualOverride = this.markManualOverride.bind(this)
|
||||
this.element.addEventListener("input", this.boundMarkManualOverride)
|
||||
this.element.addEventListener("change", this.boundMarkManualOverride)
|
||||
if (this.finishedValue) {
|
||||
this.validateForm()
|
||||
return
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.updateScoreInput()
|
||||
this.validateForm()
|
||||
}, 50)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener("input", this.boundMarkManualOverride)
|
||||
this.element.removeEventListener("change", this.boundMarkManualOverride)
|
||||
}
|
||||
|
||||
winTypeChanged() {
|
||||
if (this.finishedValue) {
|
||||
this.validateForm()
|
||||
return
|
||||
}
|
||||
this.updateScoreInput()
|
||||
this.validateForm()
|
||||
}
|
||||
@@ -30,6 +49,7 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
updateScoreInput() {
|
||||
if (this.finishedValue) return
|
||||
const winType = this.winTypeTarget.value
|
||||
this.dynamicScoreInputTarget.innerHTML = ""
|
||||
|
||||
@@ -48,6 +68,9 @@ export default class extends Controller {
|
||||
this.dynamicScoreInputTarget.appendChild(minuteInput)
|
||||
this.dynamicScoreInputTarget.appendChild(secondInput)
|
||||
|
||||
minuteInput.querySelector("input").value = this.pinMinutesValue || "0"
|
||||
secondInput.querySelector("input").value = this.pinSecondsValue || "00"
|
||||
|
||||
// Add event listeners to the new inputs
|
||||
const inputs = this.dynamicScoreInputTarget.querySelectorAll("input")
|
||||
inputs.forEach(input => {
|
||||
@@ -111,6 +134,43 @@ export default class extends Controller {
|
||||
this.validateForm()
|
||||
}
|
||||
|
||||
applyDefaultResults(defaults = {}) {
|
||||
if (this.manualOverrideValue || this.finishedValue) return
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(defaults, "winnerId") && this.hasWinnerSelectTarget) {
|
||||
this.winnerSelectTarget.value = defaults.winnerId ? String(defaults.winnerId) : ""
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(defaults, "overtimeType") && this.hasOvertimeSelectTarget) {
|
||||
const allowedValues = Array.from(this.overtimeSelectTarget.options).map((option) => option.value)
|
||||
this.overtimeSelectTarget.value = allowedValues.includes(defaults.overtimeType) ? defaults.overtimeType : ""
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(defaults, "winnerScore")) {
|
||||
this.winnerScoreValue = String(defaults.winnerScore)
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(defaults, "loserScore")) {
|
||||
this.loserScoreValue = String(defaults.loserScore)
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(defaults, "pinMinutes")) {
|
||||
this.pinMinutesValue = String(defaults.pinMinutes)
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(defaults, "pinSeconds")) {
|
||||
this.pinSecondsValue = String(defaults.pinSeconds).padStart(2, "0")
|
||||
}
|
||||
|
||||
this.updateScoreInput()
|
||||
this.validateForm()
|
||||
}
|
||||
|
||||
markManualOverride(event) {
|
||||
if (!event.isTrusted) return
|
||||
this.manualOverrideValue = true
|
||||
}
|
||||
|
||||
updatePinTimeScore() {
|
||||
const minuteInput = this.dynamicScoreInputTarget.querySelector("#minutes")
|
||||
const secondInput = this.dynamicScoreInputTarget.querySelector("#seconds")
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { getMatchStateConfig } from "match-state-config"
|
||||
import { loadJson } from "match-state-transport"
|
||||
import {
|
||||
buildScoreboardContext,
|
||||
connectionPlan,
|
||||
applyMatchPayloadContext,
|
||||
applyMatPayloadContext,
|
||||
applyStatePayloadContext,
|
||||
matchStorageKey,
|
||||
selectedBoutNumber,
|
||||
selectedBoutStorageKey as selectedBoutStorageKeyFromState,
|
||||
storageChangePlan
|
||||
} from "match-state-scoreboard-state"
|
||||
import {
|
||||
boardColors,
|
||||
emptyBoardViewModel,
|
||||
mainClockRunning as mainClockRunningFromPresenters,
|
||||
nextTimerBannerState,
|
||||
populatedBoardViewModel,
|
||||
timerBannerRenderState
|
||||
} from "match-state-scoreboard-presenters"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"redSection",
|
||||
"centerSection",
|
||||
"greenSection",
|
||||
"emptyState",
|
||||
"redName",
|
||||
"redSchool",
|
||||
"redScore",
|
||||
"redTimerIndicator",
|
||||
"greenName",
|
||||
"greenSchool",
|
||||
"greenScore",
|
||||
"greenTimerIndicator",
|
||||
"clock",
|
||||
"periodLabel",
|
||||
"weightLabel",
|
||||
"boutLabel",
|
||||
"timerBanner",
|
||||
"timerBannerLabel",
|
||||
"timerBannerClock",
|
||||
"redStats",
|
||||
"greenStats",
|
||||
"lastMatchResult"
|
||||
]
|
||||
|
||||
static values = {
|
||||
sourceMode: { type: String, default: "localstorage" },
|
||||
displayMode: { type: String, default: "fullscreen" },
|
||||
matchId: Number,
|
||||
matId: Number,
|
||||
tournamentId: Number,
|
||||
initialBoutNumber: Number
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.applyControllerContext(buildScoreboardContext({
|
||||
initialBoutNumber: this.initialBoutNumberValue,
|
||||
matchId: this.matchIdValue
|
||||
}))
|
||||
|
||||
const plan = connectionPlan(this.sourceModeValue, this.currentMatchId)
|
||||
if (plan.useStorageListener) {
|
||||
this.storageListener = this.handleStorageChange.bind(this)
|
||||
window.addEventListener("storage", this.storageListener)
|
||||
}
|
||||
if (plan.loadSelectedBout) {
|
||||
this.loadSelectedBoutNumber()
|
||||
}
|
||||
if (plan.subscribeMat) {
|
||||
this.setupMatSubscription()
|
||||
}
|
||||
if (plan.loadLocalState) {
|
||||
this.loadStateFromLocalStorage()
|
||||
}
|
||||
if (plan.subscribeMatch) {
|
||||
this.setupMatchSubscription(plan.matchId)
|
||||
}
|
||||
|
||||
this.startTicking()
|
||||
this.render()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.storageListener) {
|
||||
window.removeEventListener("storage", this.storageListener)
|
||||
this.storageListener = null
|
||||
}
|
||||
this.unsubscribeMatSubscription()
|
||||
this.unsubscribeMatchSubscription()
|
||||
if (this.tickInterval) {
|
||||
window.clearInterval(this.tickInterval)
|
||||
this.tickInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
setupMatSubscription() {
|
||||
if (!window.App || !window.App.cable || !this.matIdValue) return
|
||||
if (this.matSubscription) return
|
||||
|
||||
this.matSubscription = App.cable.subscriptions.create(
|
||||
{
|
||||
channel: "MatScoreboardChannel",
|
||||
mat_id: this.matIdValue
|
||||
},
|
||||
{
|
||||
received: (data) => this.handleMatPayload(data)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
unsubscribeMatSubscription() {
|
||||
if (this.matSubscription) {
|
||||
this.matSubscription.unsubscribe()
|
||||
this.matSubscription = null
|
||||
}
|
||||
}
|
||||
|
||||
setupMatchSubscription(matchId) {
|
||||
this.unsubscribeMatchSubscription()
|
||||
if (!window.App || !window.App.cable || !matchId) return
|
||||
|
||||
this.matchSubscription = App.cable.subscriptions.create(
|
||||
{
|
||||
channel: "MatchChannel",
|
||||
match_id: matchId
|
||||
},
|
||||
{
|
||||
connected: () => {
|
||||
this.matchSubscription.perform("request_sync")
|
||||
},
|
||||
received: (data) => {
|
||||
this.handleMatchPayload(data)
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
unsubscribeMatchSubscription() {
|
||||
if (this.matchSubscription) {
|
||||
this.matchSubscription.unsubscribe()
|
||||
this.matchSubscription = null
|
||||
}
|
||||
}
|
||||
|
||||
handleMatPayload(data) {
|
||||
const nextContext = applyMatPayloadContext(this.currentContext(), data)
|
||||
this.applyControllerContext(nextContext)
|
||||
|
||||
if (nextContext.loadSelectedBout) {
|
||||
this.loadSelectedBoutNumber()
|
||||
}
|
||||
if (nextContext.loadLocalState) {
|
||||
this.loadStateFromLocalStorage()
|
||||
}
|
||||
if (nextContext.resetTimerBanner) {
|
||||
this.resetTimerBannerState()
|
||||
}
|
||||
if (nextContext.unsubscribeMatch) {
|
||||
this.unsubscribeMatchSubscription()
|
||||
}
|
||||
if (nextContext.subscribeMatchId) {
|
||||
this.setupMatchSubscription(nextContext.subscribeMatchId)
|
||||
}
|
||||
if (nextContext.renderNow) {
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
handleMatchPayload(data) {
|
||||
this.applyControllerContext(applyMatchPayloadContext(this.currentContext(), data))
|
||||
}
|
||||
|
||||
storageKey() {
|
||||
return matchStorageKey(this.tournamentIdValue, this.currentBoutNumber)
|
||||
}
|
||||
|
||||
selectedBoutStorageKey() {
|
||||
return selectedBoutStorageKeyFromState(this.tournamentIdValue, this.matIdValue)
|
||||
}
|
||||
|
||||
handleStorageChange(event) {
|
||||
const plan = storageChangePlan(this.currentContext(), event.key, this.tournamentIdValue, this.matIdValue)
|
||||
if (plan.loadSelectedBout) this.loadSelectedBoutNumber()
|
||||
if (plan.loadLocalState) this.loadStateFromLocalStorage()
|
||||
if (plan.renderNow) this.render()
|
||||
}
|
||||
|
||||
loadSelectedBoutNumber() {
|
||||
const parsedSelection = loadJson(window.localStorage, this.selectedBoutStorageKey())
|
||||
this.currentBoutNumber = selectedBoutNumber(parsedSelection, this.currentQueueBoutNumber)
|
||||
}
|
||||
|
||||
loadStateFromLocalStorage() {
|
||||
const storageKey = this.storageKey()
|
||||
if (!storageKey) {
|
||||
this.state = null
|
||||
this.resetTimerBannerState()
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = loadJson(window.localStorage, storageKey)
|
||||
this.applyStatePayload(parsed)
|
||||
}
|
||||
|
||||
applyStatePayload(payload) {
|
||||
this.applyControllerContext(applyStatePayloadContext(this.currentContext(), payload))
|
||||
this.updateTimerBannerState()
|
||||
}
|
||||
|
||||
applyControllerContext(context) {
|
||||
this.currentQueueBoutNumber = context.currentQueueBoutNumber
|
||||
this.currentBoutNumber = context.currentBoutNumber
|
||||
this.currentMatchId = context.currentMatchId
|
||||
this.liveMatchData = context.liveMatchData
|
||||
this.lastMatchResult = context.lastMatchResult
|
||||
this.state = context.state
|
||||
this.finished = context.finished
|
||||
this.timerBannerState = context.timerBannerState || null
|
||||
this.previousTimerSnapshot = context.previousTimerSnapshot || {}
|
||||
}
|
||||
|
||||
currentContext() {
|
||||
return {
|
||||
sourceMode: this.sourceModeValue,
|
||||
currentQueueBoutNumber: this.currentQueueBoutNumber,
|
||||
currentBoutNumber: this.currentBoutNumber,
|
||||
currentMatchId: this.currentMatchId,
|
||||
liveMatchData: this.liveMatchData,
|
||||
lastMatchResult: this.lastMatchResult,
|
||||
state: this.state,
|
||||
finished: this.finished,
|
||||
timerBannerState: this.timerBannerState,
|
||||
previousTimerSnapshot: this.previousTimerSnapshot || {}
|
||||
}
|
||||
}
|
||||
|
||||
startTicking() {
|
||||
if (this.tickInterval) return
|
||||
this.tickInterval = window.setInterval(() => this.render(), 250)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state || !this.state.metadata) {
|
||||
this.renderEmptyState()
|
||||
return
|
||||
}
|
||||
|
||||
this.config = getMatchStateConfig(this.state.metadata.ruleset, this.state.metadata.bracketPosition)
|
||||
const viewModel = populatedBoardViewModel(
|
||||
this.config,
|
||||
this.state,
|
||||
this.liveMatchData,
|
||||
this.currentBoutNumber,
|
||||
(seconds) => this.formatClock(seconds)
|
||||
)
|
||||
|
||||
this.applyLiveBoardColors()
|
||||
if (this.hasEmptyStateTarget) this.emptyStateTarget.style.display = "none"
|
||||
this.applyBoardViewModel(viewModel)
|
||||
this.renderTimerBanner()
|
||||
this.renderLastMatchResult()
|
||||
}
|
||||
|
||||
renderEmptyState() {
|
||||
const viewModel = emptyBoardViewModel(this.currentBoutNumber, this.lastMatchResult)
|
||||
this.applyEmptyBoardColors()
|
||||
if (this.hasEmptyStateTarget) this.emptyStateTarget.style.display = "block"
|
||||
this.applyBoardViewModel(viewModel)
|
||||
this.hideTimerBanner()
|
||||
this.renderLastMatchResult()
|
||||
}
|
||||
|
||||
applyBoardViewModel(viewModel) {
|
||||
if (this.hasRedNameTarget) this.redNameTarget.textContent = viewModel.redName
|
||||
if (this.hasRedSchoolTarget) this.redSchoolTarget.textContent = viewModel.redSchool
|
||||
if (this.hasRedScoreTarget) this.redScoreTarget.textContent = viewModel.redScore
|
||||
if (this.hasRedTimerIndicatorTarget) this.redTimerIndicatorTarget.innerHTML = this.renderTimerIndicator(viewModel.redTimerIndicator)
|
||||
if (this.hasGreenNameTarget) this.greenNameTarget.textContent = viewModel.greenName
|
||||
if (this.hasGreenSchoolTarget) this.greenSchoolTarget.textContent = viewModel.greenSchool
|
||||
if (this.hasGreenScoreTarget) this.greenScoreTarget.textContent = viewModel.greenScore
|
||||
if (this.hasGreenTimerIndicatorTarget) this.greenTimerIndicatorTarget.innerHTML = this.renderTimerIndicator(viewModel.greenTimerIndicator)
|
||||
if (this.hasClockTarget) this.clockTarget.textContent = viewModel.clockText
|
||||
if (this.hasPeriodLabelTarget) this.periodLabelTarget.textContent = viewModel.phaseLabel
|
||||
if (this.hasWeightLabelTarget) this.weightLabelTarget.textContent = viewModel.weightLabel
|
||||
if (this.hasBoutLabelTarget) this.boutLabelTarget.textContent = viewModel.boutLabel
|
||||
if (this.hasRedStatsTarget) this.redStatsTarget.textContent = viewModel.redStats
|
||||
if (this.hasGreenStatsTarget) this.greenStatsTarget.textContent = viewModel.greenStats
|
||||
}
|
||||
|
||||
renderLastMatchResult() {
|
||||
if (this.hasLastMatchResultTarget) this.lastMatchResultTarget.textContent = this.lastMatchResult || "-"
|
||||
}
|
||||
|
||||
renderTimerIndicator(label) {
|
||||
if (!label) return ""
|
||||
return `<span class="label label-default">${label}</span>`
|
||||
}
|
||||
|
||||
applyLiveBoardColors() {
|
||||
this.applyBoardColors(boardColors(false))
|
||||
}
|
||||
|
||||
applyEmptyBoardColors() {
|
||||
this.applyBoardColors(boardColors(true))
|
||||
}
|
||||
|
||||
applyBoardColors(colors) {
|
||||
if (this.hasRedSectionTarget) this.redSectionTarget.style.background = colors.red
|
||||
if (this.hasCenterSectionTarget) this.centerSectionTarget.style.background = colors.center
|
||||
if (this.hasGreenSectionTarget) this.greenSectionTarget.style.background = colors.green
|
||||
}
|
||||
|
||||
updateTimerBannerState() {
|
||||
const nextState = nextTimerBannerState(this.state, this.previousTimerSnapshot)
|
||||
this.timerBannerState = nextState.timerBannerState
|
||||
this.previousTimerSnapshot = nextState.previousTimerSnapshot
|
||||
}
|
||||
|
||||
renderTimerBanner() {
|
||||
if (!this.hasTimerBannerTarget) return
|
||||
const renderState = timerBannerRenderState(
|
||||
this.config,
|
||||
this.state,
|
||||
this.timerBannerState,
|
||||
(seconds) => this.formatClock(seconds)
|
||||
)
|
||||
this.timerBannerState = renderState.timerBannerState
|
||||
|
||||
if (!renderState.visible) {
|
||||
this.hideTimerBanner()
|
||||
return
|
||||
}
|
||||
|
||||
const viewModel = renderState.viewModel
|
||||
this.timerBannerTarget.style.display = "block"
|
||||
this.timerBannerTarget.style.borderColor = viewModel.color === "green" ? "#1cab2d" : "#c91f1f"
|
||||
if (this.hasTimerBannerLabelTarget) this.timerBannerLabelTarget.textContent = viewModel.label
|
||||
if (this.hasTimerBannerClockTarget) this.timerBannerClockTarget.textContent = viewModel.clockText
|
||||
}
|
||||
|
||||
hideTimerBanner() {
|
||||
if (this.hasTimerBannerTarget) this.timerBannerTarget.style.display = "none"
|
||||
}
|
||||
|
||||
resetTimerBannerState() {
|
||||
this.timerBannerState = null
|
||||
this.previousTimerSnapshot = {}
|
||||
}
|
||||
|
||||
mainClockRunning() {
|
||||
return mainClockRunningFromPresenters(this.config, this.state)
|
||||
}
|
||||
|
||||
formatClock(totalSeconds) {
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus"
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"w1Stats", "w2Stats", "winner", "winType",
|
||||
"score", "finished", "statusIndicator"
|
||||
"score", "finished", "statusIndicator", "scoreboardContainer"
|
||||
]
|
||||
|
||||
static values = {
|
||||
@@ -134,6 +134,9 @@ export default class extends Controller {
|
||||
|
||||
if (data.finished !== undefined && this.hasFinishedTarget) {
|
||||
this.finishedTarget.textContent = data.finished ? 'Yes' : 'No'
|
||||
if (this.hasScoreboardContainerTarget) {
|
||||
this.scoreboardContainerTarget.style.display = data.finished ? 'none' : 'block'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
804
app/assets/javascripts/controllers/match_state_controller.js
Normal file
804
app/assets/javascripts/controllers/match_state_controller.js
Normal file
@@ -0,0 +1,804 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import { getMatchStateConfig } from "match-state-config"
|
||||
import {
|
||||
accumulatedMatchSeconds as accumulatedMatchSecondsFromEngine,
|
||||
activeClockForPhase,
|
||||
adjustClockState,
|
||||
applyChoiceAction,
|
||||
applyMatchAction,
|
||||
baseControlForPhase,
|
||||
buildEvent as buildEventFromEngine,
|
||||
buildClockState,
|
||||
buildInitialState,
|
||||
buildTimerState,
|
||||
controlForSelectedPhase,
|
||||
controlFromChoice,
|
||||
currentAuxiliaryTimerSeconds as currentAuxiliaryTimerSecondsFromEngine,
|
||||
currentClockSeconds as currentClockSecondsFromEngine,
|
||||
deleteEventFromState,
|
||||
derivedStats as derivedStatsFromEngine,
|
||||
hasRunningClockOrTimer as hasRunningClockOrTimerFromEngine,
|
||||
matchResultDefaults as matchResultDefaultsFromEngine,
|
||||
moveToNextPhase,
|
||||
moveToPreviousPhase,
|
||||
orderedEvents as orderedEventsFromEngine,
|
||||
opponentParticipant as opponentParticipantFromEngine,
|
||||
phaseIndexForKey as phaseIndexForKeyFromEngine,
|
||||
recomputeDerivedState as recomputeDerivedStateFromEngine,
|
||||
scoreboardStatePayload as scoreboardStatePayloadFromEngine,
|
||||
startAuxiliaryTimerState,
|
||||
startClockState,
|
||||
stopAuxiliaryTimerState,
|
||||
stopClockState,
|
||||
stopAllAuxiliaryTimers as stopAllAuxiliaryTimersFromEngine,
|
||||
swapEventParticipants,
|
||||
swapPhaseParticipants,
|
||||
syncClockSnapshot
|
||||
} from "match-state-engine"
|
||||
import {
|
||||
buildMatchMetadata,
|
||||
buildPersistedState,
|
||||
buildStorageKey,
|
||||
restorePersistedState
|
||||
} from "match-state-serializers"
|
||||
import {
|
||||
loadJson,
|
||||
performIfChanged,
|
||||
removeKey,
|
||||
saveJson,
|
||||
MATCH_DATA_TTL_MS
|
||||
} from "match-state-transport"
|
||||
import {
|
||||
choiceViewModel,
|
||||
eventLogSections
|
||||
} from "match-state-presenters"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"greenLabel",
|
||||
"redLabel",
|
||||
"greenPanel",
|
||||
"redPanel",
|
||||
"greenName",
|
||||
"redName",
|
||||
"greenSchool",
|
||||
"redSchool",
|
||||
"greenScore",
|
||||
"redScore",
|
||||
"periodLabel",
|
||||
"clock",
|
||||
"clockStatus",
|
||||
"accumulationClock",
|
||||
"matchPosition",
|
||||
"formatName",
|
||||
"choiceActions",
|
||||
"eventLog",
|
||||
"greenControls",
|
||||
"redControls",
|
||||
"matchResultsPanel",
|
||||
"w1StatField",
|
||||
"w2StatField"
|
||||
]
|
||||
|
||||
static values = {
|
||||
matchId: Number,
|
||||
tournamentId: Number,
|
||||
boutNumber: Number,
|
||||
weightLabel: String,
|
||||
bracketPosition: String,
|
||||
ruleset: String,
|
||||
w1Id: Number,
|
||||
w2Id: Number,
|
||||
w1Name: String,
|
||||
w2Name: String,
|
||||
w1School: String,
|
||||
w2School: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.config = getMatchStateConfig(this.rulesetValue, this.bracketPositionValue)
|
||||
this.boundHandleClick = this.handleDelegatedClick.bind(this)
|
||||
this.element.addEventListener("click", this.boundHandleClick)
|
||||
this.initializeState()
|
||||
this.loadPersistedState()
|
||||
this.syncClockFromActivePhase()
|
||||
if (this.hasRunningClockOrTimer()) {
|
||||
this.startTicking()
|
||||
}
|
||||
this.render({ rebuildControls: true })
|
||||
this.setupSubscription()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener("click", this.boundHandleClick)
|
||||
window.clearTimeout(this.matchResultsDefaultsTimeout)
|
||||
this.cleanupSubscription()
|
||||
this.saveState()
|
||||
this.stopTicking()
|
||||
this.stopAllAuxiliaryTimers()
|
||||
}
|
||||
|
||||
initializeState() {
|
||||
this.state = this.buildInitialState()
|
||||
}
|
||||
|
||||
buildInitialState() {
|
||||
return buildInitialState(this.config)
|
||||
}
|
||||
|
||||
render(options = {}) {
|
||||
const rebuildControls = options.rebuildControls === true
|
||||
if (this.hasGreenLabelTarget) this.greenLabelTarget.textContent = this.displayLabelForParticipant("w1")
|
||||
if (this.hasRedLabelTarget) this.redLabelTarget.textContent = this.displayLabelForParticipant("w2")
|
||||
if (this.hasGreenPanelTarget) this.applyPanelColor(this.greenPanelTarget, this.colorForParticipant("w1"))
|
||||
if (this.hasRedPanelTarget) this.applyPanelColor(this.redPanelTarget, this.colorForParticipant("w2"))
|
||||
if (this.hasGreenNameTarget) this.greenNameTarget.textContent = this.w1NameValue
|
||||
if (this.hasRedNameTarget) this.redNameTarget.textContent = this.w2NameValue
|
||||
if (this.hasGreenSchoolTarget) this.greenSchoolTarget.textContent = this.w1SchoolValue
|
||||
if (this.hasRedSchoolTarget) this.redSchoolTarget.textContent = this.w2SchoolValue
|
||||
if (this.hasGreenScoreTarget) this.greenScoreTarget.textContent = this.state.participantScores.w1.toString()
|
||||
if (this.hasRedScoreTarget) this.redScoreTarget.textContent = this.state.participantScores.w2.toString()
|
||||
if (this.hasPeriodLabelTarget) this.periodLabelTarget.textContent = this.currentPhase().label
|
||||
this.updateLiveDisplays()
|
||||
if (this.hasMatchPositionTarget) this.matchPositionTarget.textContent = this.humanizePosition(this.state.displayControl)
|
||||
if (this.hasFormatNameTarget) this.formatNameTarget.textContent = this.config.matchFormat.label
|
||||
if (rebuildControls) {
|
||||
if (this.hasGreenControlsTarget) this.greenControlsTarget.innerHTML = this.renderWrestlerControls("w1")
|
||||
if (this.hasRedControlsTarget) this.redControlsTarget.innerHTML = this.renderWrestlerControls("w2")
|
||||
}
|
||||
if (this.hasChoiceActionsTarget) this.choiceActionsTarget.innerHTML = this.renderChoiceActions()
|
||||
if (this.hasEventLogTarget) this.eventLogTarget.innerHTML = this.renderEventLog()
|
||||
this.updateTimerDisplays()
|
||||
this.updateStatFieldsAndBroadcast()
|
||||
this.scheduleApplyMatchResultDefaults()
|
||||
this.saveState()
|
||||
}
|
||||
|
||||
renderWrestlerControls(participantKey) {
|
||||
return Object.values(this.config.wrestler_actions).map((section) => {
|
||||
const content = this.renderWrestlerSection(participantKey, section)
|
||||
if (!content) return ""
|
||||
|
||||
return `
|
||||
<div style="margin-top: 12px;">
|
||||
<strong>${section.title}</strong>
|
||||
<div class="text-muted" style="margin: 4px 0 8px;">${section.description}</div>
|
||||
<div>${content}</div>
|
||||
</div>
|
||||
`
|
||||
}).join('<hr>')
|
||||
}
|
||||
|
||||
renderWrestlerSection(participantKey, section) {
|
||||
if (!section) return ""
|
||||
|
||||
if (section === this.config.wrestler_actions.timers) {
|
||||
return this.renderTimerSection(participantKey, section)
|
||||
}
|
||||
|
||||
const actionKeys = this.actionKeysForSection(participantKey, section)
|
||||
return this.renderActionButtons(participantKey, actionKeys)
|
||||
}
|
||||
|
||||
renderActionButtons(participantKey, actionKeys) {
|
||||
return actionKeys.map((actionKey) => {
|
||||
const action = this.config.actionsByKey[actionKey]
|
||||
if (!action) return ""
|
||||
|
||||
const buttonClass = this.colorForParticipant(participantKey) === "green" ? "btn-success" : "btn-danger"
|
||||
return `<button type="button" class="btn ${buttonClass} btn-sm" data-match-state-button="score-action" data-participant-key="${participantKey}" data-action-key="${actionKey}">${action.label}</button>`
|
||||
}).join(" ")
|
||||
}
|
||||
|
||||
actionKeysForSection(participantKey, section) {
|
||||
if (!section?.items) return []
|
||||
|
||||
return section.items.flatMap((itemKey) => {
|
||||
if (itemKey === "global") {
|
||||
return this.availableActionKeysForAvailability(participantKey, "global")
|
||||
}
|
||||
|
||||
if (itemKey === "position") {
|
||||
const position = this.positionForParticipant(participantKey)
|
||||
return this.availableActionKeysForAvailability(participantKey, position)
|
||||
}
|
||||
|
||||
return itemKey
|
||||
})
|
||||
}
|
||||
|
||||
availableActionKeysForAvailability(participantKey, availability) {
|
||||
if (this.currentPhase().type !== "period") return []
|
||||
|
||||
return Object.entries(this.config.actionsByKey)
|
||||
.filter(([, action]) => action.availability === availability)
|
||||
.map(([actionKey]) => actionKey)
|
||||
}
|
||||
|
||||
renderTimerSection(participantKey, section) {
|
||||
return (section.items || []).map((timerKey) => {
|
||||
const timerConfig = this.config.timers[timerKey]
|
||||
if (!timerConfig) return ""
|
||||
|
||||
return `
|
||||
<div style="margin-bottom: 12px;">
|
||||
<strong>${timerConfig.label}</strong>: <span data-match-state-timer-display="${participantKey}-${timerKey}">${this.formatClock(this.currentAuxiliaryTimerSeconds(participantKey, timerKey))}</span>
|
||||
<div class="btn-group btn-group-xs" style="margin-left: 8px;">
|
||||
<button type="button" class="btn btn-default" data-match-state-button="timer-action" data-participant-key="${participantKey}" data-timer-key="${timerKey}" data-timer-command="start">Start</button>
|
||||
<button type="button" class="btn btn-default" data-match-state-button="timer-action" data-participant-key="${participantKey}" data-timer-key="${timerKey}" data-timer-command="stop">Stop</button>
|
||||
<button type="button" class="btn btn-default" data-match-state-button="timer-action" data-participant-key="${participantKey}" data-timer-key="${timerKey}" data-timer-command="reset">Reset</button>
|
||||
</div>
|
||||
<div class="text-muted" data-match-state-timer-status="${participantKey}-${timerKey}">Max ${this.formatClock(timerConfig.maxSeconds)}</div>
|
||||
</div>
|
||||
`
|
||||
}).join("")
|
||||
}
|
||||
|
||||
handleDelegatedClick(event) {
|
||||
const button = event.target.closest("button")
|
||||
if (!button) return
|
||||
|
||||
// Buttons with direct Stimulus actions are handled separately.
|
||||
if (button.dataset.action && button.dataset.action.includes("match-state#")) return
|
||||
|
||||
const buttonType = button.dataset.matchStateButton
|
||||
if (buttonType === "score-action") {
|
||||
this.applyAction(button)
|
||||
} else if (buttonType === "choice-action") {
|
||||
this.applyChoice(button)
|
||||
} else if (buttonType === "timer-action") {
|
||||
this.handleTimerCommand(button)
|
||||
} else if (buttonType === "swap-phase") {
|
||||
this.swapPhase(button)
|
||||
} else if (buttonType === "swap-event") {
|
||||
this.swapEvent(button)
|
||||
} else if (buttonType === "delete-event") {
|
||||
this.deleteEvent(button)
|
||||
}
|
||||
}
|
||||
|
||||
applyAction(button) {
|
||||
const participantKey = button.dataset.participantKey
|
||||
const actionKey = button.dataset.actionKey
|
||||
if (!applyMatchAction(this.config, this.state, this.currentPhase(), this.currentClockSeconds(), participantKey, actionKey)) return
|
||||
|
||||
this.recomputeDerivedState()
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
applyChoice(button) {
|
||||
const phase = this.currentPhase()
|
||||
if (phase.type !== "choice") return
|
||||
|
||||
const participantKey = button.dataset.participantKey
|
||||
const choiceKey = button.dataset.choiceKey
|
||||
|
||||
const result = applyChoiceAction(this.state, phase, this.currentClockSeconds(), participantKey, choiceKey)
|
||||
if (!result.applied) return
|
||||
|
||||
if (result.deferred) {
|
||||
this.recomputeDerivedState()
|
||||
this.render({ rebuildControls: true })
|
||||
return
|
||||
}
|
||||
|
||||
this.advancePhase()
|
||||
}
|
||||
|
||||
swapColors() {
|
||||
this.state.assignment.w1 = this.state.assignment.w1 === "green" ? "red" : "green"
|
||||
this.state.assignment.w2 = this.state.assignment.w2 === "green" ? "red" : "green"
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
buildEvent(participantKey, actionKey, options = {}) {
|
||||
return buildEventFromEngine(this.state, this.currentPhase(), this.currentClockSeconds(), participantKey, actionKey, options)
|
||||
}
|
||||
|
||||
startClock() {
|
||||
if (this.currentPhase().type !== "period") return
|
||||
const activeClock = this.activeClock()
|
||||
if (!startClockState(activeClock)) return
|
||||
this.syncClockFromActivePhase()
|
||||
this.startTicking()
|
||||
this.render()
|
||||
}
|
||||
|
||||
stopClock() {
|
||||
const activeClock = this.activeClock()
|
||||
if (!stopClockState(activeClock)) return
|
||||
this.syncClockFromActivePhase()
|
||||
this.stopTicking()
|
||||
this.render()
|
||||
}
|
||||
|
||||
resetClock() {
|
||||
this.stopClock()
|
||||
const activeClock = this.activeClock()
|
||||
if (!activeClock) return
|
||||
activeClock.remainingSeconds = activeClock.durationSeconds
|
||||
this.syncClockFromActivePhase()
|
||||
this.render()
|
||||
}
|
||||
|
||||
addMinute() {
|
||||
this.adjustClock(60)
|
||||
}
|
||||
|
||||
subtractMinute() {
|
||||
this.adjustClock(-60)
|
||||
}
|
||||
|
||||
addSecond() {
|
||||
this.adjustClock(1)
|
||||
}
|
||||
|
||||
subtractSecond() {
|
||||
this.adjustClock(-1)
|
||||
}
|
||||
|
||||
previousPhase() {
|
||||
this.stopClock()
|
||||
if (!moveToPreviousPhase(this.config, this.state)) return
|
||||
this.applyPhaseDefaults()
|
||||
this.recomputeDerivedState()
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
nextPhase() {
|
||||
this.advancePhase()
|
||||
}
|
||||
|
||||
resetMatch() {
|
||||
const confirmed = window.confirm("Are you sure you want to reset the match? This will wipe the score, reset all timers, and wipe all stats")
|
||||
if (!confirmed) return
|
||||
|
||||
this.stopTicking()
|
||||
this.initializeState()
|
||||
this.syncClockFromActivePhase()
|
||||
this.clearPersistedState()
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
advancePhase() {
|
||||
this.stopClock()
|
||||
if (!moveToNextPhase(this.config, this.state)) return
|
||||
this.applyPhaseDefaults()
|
||||
this.recomputeDerivedState()
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
deleteEvent(button) {
|
||||
const eventId = Number(button.dataset.eventId)
|
||||
if (!deleteEventFromState(this.config, this.state, eventId)) return
|
||||
this.recomputeDerivedState()
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
swapEvent(button) {
|
||||
const eventId = Number(button.dataset.eventId)
|
||||
if (!swapEventParticipants(this.config, this.state, eventId)) return
|
||||
this.recomputeDerivedState()
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
swapPhase(button) {
|
||||
const phaseKey = button.dataset.phaseKey
|
||||
if (!swapPhaseParticipants(this.config, this.state, phaseKey)) return
|
||||
this.recomputeDerivedState()
|
||||
this.render({ rebuildControls: true })
|
||||
}
|
||||
|
||||
handleTimerCommand(button) {
|
||||
const participantKey = button.dataset.participantKey
|
||||
const timerKey = button.dataset.timerKey
|
||||
const command = button.dataset.timerCommand
|
||||
|
||||
if (command === "start") this.startAuxiliaryTimer(participantKey, timerKey)
|
||||
if (command === "stop") this.stopAuxiliaryTimer(participantKey, timerKey)
|
||||
if (command === "reset") this.resetAuxiliaryTimer(participantKey, timerKey)
|
||||
}
|
||||
|
||||
startAuxiliaryTimer(participantKey, timerKey) {
|
||||
const timer = this.state.timers[participantKey][timerKey]
|
||||
if (!startAuxiliaryTimerState(timer)) return
|
||||
this.startTicking()
|
||||
this.render()
|
||||
}
|
||||
|
||||
stopAuxiliaryTimer(participantKey, timerKey) {
|
||||
const timer = this.state.timers[participantKey][timerKey]
|
||||
const { stopped, elapsedSeconds } = stopAuxiliaryTimerState(timer)
|
||||
if (!stopped) return
|
||||
|
||||
if (elapsedSeconds > 0) {
|
||||
this.state.events.push({
|
||||
...this.buildEvent(participantKey, `timer_used_${timerKey}`),
|
||||
elapsedSeconds: elapsedSeconds
|
||||
})
|
||||
}
|
||||
|
||||
this.render()
|
||||
}
|
||||
|
||||
resetAuxiliaryTimer(participantKey, timerKey) {
|
||||
this.stopAuxiliaryTimer(participantKey, timerKey)
|
||||
const timer = this.state.timers[participantKey][timerKey]
|
||||
timer.remainingSeconds = this.config.timers[timerKey].maxSeconds
|
||||
this.render()
|
||||
}
|
||||
|
||||
buildTimerState() {
|
||||
return buildTimerState(this.config)
|
||||
}
|
||||
|
||||
buildClockState() {
|
||||
return buildClockState(this.config)
|
||||
}
|
||||
|
||||
currentClockSeconds() {
|
||||
return currentClockSecondsFromEngine(this.activeClock())
|
||||
}
|
||||
|
||||
currentAuxiliaryTimerSeconds(participantKey, timerKey) {
|
||||
return currentAuxiliaryTimerSecondsFromEngine(this.state.timers[participantKey][timerKey])
|
||||
}
|
||||
|
||||
hasRunningClockOrTimer() {
|
||||
return hasRunningClockOrTimerFromEngine(this.state)
|
||||
}
|
||||
|
||||
startTicking() {
|
||||
if (this.tickInterval) return
|
||||
this.tickInterval = window.setInterval(() => {
|
||||
if (this.activeClock()?.running && this.currentClockSeconds() === 0) {
|
||||
this.stopClock()
|
||||
return
|
||||
}
|
||||
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const timerKey of Object.keys(this.state.timers[participantKey])) {
|
||||
if (this.state.timers[participantKey][timerKey].running && this.currentAuxiliaryTimerSeconds(participantKey, timerKey) === 0) {
|
||||
this.stopAuxiliaryTimer(participantKey, timerKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.updateLiveDisplays()
|
||||
this.updateTimerDisplays()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
stopTicking() {
|
||||
if (!this.tickInterval) return
|
||||
window.clearInterval(this.tickInterval)
|
||||
this.tickInterval = null
|
||||
}
|
||||
|
||||
stopAllAuxiliaryTimers() {
|
||||
stopAllAuxiliaryTimersFromEngine(this.state)
|
||||
}
|
||||
|
||||
positionForParticipant(participantKey) {
|
||||
if (this.state.displayControl === "neutral") return "neutral"
|
||||
if (this.state.displayControl === `${participantKey}_control`) return "top"
|
||||
return "bottom"
|
||||
}
|
||||
|
||||
opponentParticipant(participantKey) {
|
||||
return opponentParticipantFromEngine(participantKey)
|
||||
}
|
||||
|
||||
humanizePosition(position) {
|
||||
if (position === "neutral") return "Neutral"
|
||||
if (position === "green_control") return "Green In Control"
|
||||
if (position === "red_control") return "Red In Control"
|
||||
return position
|
||||
}
|
||||
|
||||
recomputeDerivedState() {
|
||||
recomputeDerivedStateFromEngine(this.config, this.state)
|
||||
}
|
||||
|
||||
renderEventLog() {
|
||||
if (this.state.events.length === 0) {
|
||||
return '<p class="text-muted">No events yet.</p>'
|
||||
}
|
||||
|
||||
return eventLogSections(this.config, this.state, (seconds) => this.formatClock(seconds)).map((section) => {
|
||||
const items = section.items.map((eventRecord) => {
|
||||
return `
|
||||
<div class="well well-sm" style="margin-bottom: 8px;">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px;">
|
||||
<div style="flex: 1 1 260px; min-width: 0; overflow-wrap: anywhere;">
|
||||
<strong>${eventRecord.colorLabel}</strong> ${eventRecord.actionLabel}
|
||||
<span class="text-muted">(${eventRecord.clockLabel})</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px; flex: 0 0 auto;">
|
||||
<button type="button" class="btn btn-xs btn-link" data-match-state-button="swap-event" data-event-id="${eventRecord.id}">Swap</button>
|
||||
<button type="button" class="btn btn-xs btn-link" data-match-state-button="delete-event" data-event-id="${eventRecord.id}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}).join("")
|
||||
|
||||
return `
|
||||
<div style="margin-bottom: 16px;">
|
||||
<div style="display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px;">
|
||||
<h5 style="margin: 0;">${section.label}</h5>
|
||||
<button type="button" class="btn btn-xs btn-link" data-match-state-button="swap-phase" data-phase-key="${section.key}">Swap Entire Period</button>
|
||||
</div>
|
||||
${items}
|
||||
</div>
|
||||
`
|
||||
}).join("")
|
||||
}
|
||||
|
||||
updateLiveDisplays() {
|
||||
if (this.hasClockTarget) {
|
||||
this.clockTarget.textContent = this.currentPhase().type === "period" ? this.formatClock(this.currentClockSeconds()) : "-"
|
||||
}
|
||||
if (this.hasClockStatusTarget) {
|
||||
this.clockStatusTarget.textContent = this.currentPhase().type === "period"
|
||||
? (this.activeClock()?.running ? "Running" : "Stopped")
|
||||
: "Choice"
|
||||
}
|
||||
if (this.hasAccumulationClockTarget) {
|
||||
this.accumulationClockTarget.textContent = this.formatClock(this.accumulatedMatchSeconds())
|
||||
}
|
||||
}
|
||||
|
||||
updateTimerDisplays() {
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const [timerKey, timerConfig] of Object.entries(this.config.timers)) {
|
||||
const display = this.element.querySelector(`[data-match-state-timer-display="${participantKey}-${timerKey}"]`)
|
||||
const status = this.element.querySelector(`[data-match-state-timer-status="${participantKey}-${timerKey}"]`)
|
||||
if (display) {
|
||||
display.textContent = this.formatClock(this.currentAuxiliaryTimerSeconds(participantKey, timerKey))
|
||||
}
|
||||
if (status) {
|
||||
const running = this.state.timers[participantKey][timerKey].running
|
||||
status.textContent = `Max ${this.formatClock(timerConfig.maxSeconds)}${running ? " • running" : ""}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderChoiceActions() {
|
||||
const phase = this.currentPhase()
|
||||
const viewModel = choiceViewModel(this.config, this.state, phase, {
|
||||
w1: { name: this.w1NameValue },
|
||||
w2: { name: this.w2NameValue }
|
||||
})
|
||||
if (!viewModel) return ""
|
||||
|
||||
return `
|
||||
<div class="well well-sm">
|
||||
<div><strong>${viewModel.label}</strong></div>
|
||||
<div class="text-muted" style="margin: 6px 0;">${viewModel.selectionText}</div>
|
||||
<div>${viewModel.buttons.map((button) => `
|
||||
<button
|
||||
type="button"
|
||||
class="btn ${button.buttonClass} btn-sm"
|
||||
data-match-state-button="choice-action"
|
||||
data-participant-key="${button.participantKey}"
|
||||
data-choice-key="${button.choiceKey}">
|
||||
${button.text}
|
||||
</button>
|
||||
`).join(" ")}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
currentPhase() {
|
||||
return this.config.phaseSequence[this.state.phaseIndex]
|
||||
}
|
||||
|
||||
applyPhaseDefaults() {
|
||||
this.syncClockFromActivePhase()
|
||||
this.state.control = this.baseControlForCurrentPhase()
|
||||
}
|
||||
|
||||
baseControlForCurrentPhase() {
|
||||
return baseControlForPhase(this.currentPhase(), this.state.selections, this.state.control)
|
||||
}
|
||||
|
||||
controlFromChoice(selection) {
|
||||
return controlFromChoice(selection)
|
||||
}
|
||||
|
||||
colorForParticipant(participantKey) {
|
||||
return this.state.assignment[participantKey]
|
||||
}
|
||||
|
||||
displayLabelForParticipant(participantKey) {
|
||||
return this.colorForParticipant(participantKey) === "green" ? "Green" : "Red"
|
||||
}
|
||||
|
||||
applyPanelColor(panelElement, color) {
|
||||
panelElement.classList.remove("panel-success", "panel-danger")
|
||||
panelElement.classList.add(color === "green" ? "panel-success" : "panel-danger")
|
||||
}
|
||||
|
||||
controlForSelectedPhase() {
|
||||
return controlForSelectedPhase(this.config, this.state)
|
||||
}
|
||||
|
||||
baseControlForPhase(phase) {
|
||||
return baseControlForPhase(phase, this.state.selections, this.state.control)
|
||||
}
|
||||
|
||||
orderedEvents() {
|
||||
return orderedEventsFromEngine(this.config, this.state.events)
|
||||
}
|
||||
|
||||
phaseIndexForKey(phaseKey) {
|
||||
return phaseIndexForKeyFromEngine(this.config, phaseKey)
|
||||
}
|
||||
|
||||
activeClock() {
|
||||
return activeClockForPhase(this.state, this.currentPhase())
|
||||
}
|
||||
|
||||
setupSubscription() {
|
||||
this.cleanupSubscription()
|
||||
if (!this.matchIdValue || !window.App || !window.App.cable) return
|
||||
|
||||
this.matchSubscription = App.cable.subscriptions.create(
|
||||
{
|
||||
channel: "MatchChannel",
|
||||
match_id: this.matchIdValue
|
||||
},
|
||||
{
|
||||
connected: () => {
|
||||
this.isConnected = true
|
||||
this.pushDerivedStatsToChannel()
|
||||
this.pushScoreboardStateToChannel()
|
||||
},
|
||||
disconnected: () => {
|
||||
this.isConnected = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
cleanupSubscription() {
|
||||
if (!this.matchSubscription) return
|
||||
try {
|
||||
this.matchSubscription.unsubscribe()
|
||||
} catch (_error) {
|
||||
}
|
||||
this.matchSubscription = null
|
||||
this.isConnected = false
|
||||
}
|
||||
|
||||
updateStatFieldsAndBroadcast() {
|
||||
const derivedStats = this.derivedStats()
|
||||
|
||||
if (this.hasW1StatFieldTarget) this.w1StatFieldTarget.value = derivedStats.w1
|
||||
if (this.hasW2StatFieldTarget) this.w2StatFieldTarget.value = derivedStats.w2
|
||||
|
||||
this.lastDerivedStats = derivedStats
|
||||
this.pushDerivedStatsToChannel()
|
||||
this.pushScoreboardStateToChannel()
|
||||
}
|
||||
|
||||
pushDerivedStatsToChannel() {
|
||||
if (!this.matchSubscription || !this.lastDerivedStats) return
|
||||
this.lastBroadcastStats = performIfChanged(this.matchSubscription, "send_stat", {
|
||||
new_w1_stat: this.lastDerivedStats.w1,
|
||||
new_w2_stat: this.lastDerivedStats.w2
|
||||
}, this.lastBroadcastStats)
|
||||
}
|
||||
|
||||
pushScoreboardStateToChannel() {
|
||||
if (!this.matchSubscription) return
|
||||
|
||||
this.lastBroadcastScoreboardState = performIfChanged(this.matchSubscription, "send_scoreboard", {
|
||||
scoreboard_state: this.scoreboardStatePayload()
|
||||
}, this.lastBroadcastScoreboardState)
|
||||
}
|
||||
|
||||
applyMatchResultDefaults() {
|
||||
const controllerElement = this.matchResultsPanelTarget?.querySelector('[data-controller~="match-score"]')
|
||||
if (!controllerElement) return
|
||||
|
||||
const scoreController = this.application.getControllerForElementAndIdentifier(controllerElement, "match-score")
|
||||
if (!scoreController || typeof scoreController.applyDefaultResults !== "function") return
|
||||
|
||||
scoreController.applyDefaultResults(
|
||||
matchResultDefaultsFromEngine(this.state, {
|
||||
w1Id: this.w1IdValue,
|
||||
w2Id: this.w2IdValue,
|
||||
currentPhase: this.currentPhase(),
|
||||
accumulationSeconds: this.accumulatedMatchSeconds()
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
scheduleApplyMatchResultDefaults() {
|
||||
if (!this.hasMatchResultsPanelTarget) return
|
||||
|
||||
window.clearTimeout(this.matchResultsDefaultsTimeout)
|
||||
this.matchResultsDefaultsTimeout = window.setTimeout(() => {
|
||||
this.applyMatchResultDefaults()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
storageKey() {
|
||||
return buildStorageKey(this.tournamentIdValue, this.boutNumberValue)
|
||||
}
|
||||
|
||||
loadPersistedState() {
|
||||
const parsedState = loadJson(window.localStorage, this.storageKey())
|
||||
if (!parsedState) {
|
||||
if (window.localStorage.getItem(this.storageKey())) {
|
||||
this.clearPersistedState()
|
||||
this.state = this.buildInitialState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.state = restorePersistedState(this.config, parsedState)
|
||||
} catch (_error) {
|
||||
this.clearPersistedState()
|
||||
this.state = this.buildInitialState()
|
||||
}
|
||||
}
|
||||
|
||||
saveState() {
|
||||
const persistedState = buildPersistedState(this.state, this.matchMetadata())
|
||||
saveJson(window.localStorage, this.storageKey(), persistedState, { ttlMs: MATCH_DATA_TTL_MS })
|
||||
}
|
||||
|
||||
clearPersistedState() {
|
||||
removeKey(window.localStorage, this.storageKey())
|
||||
}
|
||||
|
||||
accumulatedMatchSeconds() {
|
||||
return accumulatedMatchSecondsFromEngine(this.config, this.state, this.currentPhase().key)
|
||||
}
|
||||
|
||||
syncClockFromActivePhase() {
|
||||
this.state.clock = syncClockSnapshot(this.activeClock())
|
||||
}
|
||||
|
||||
adjustClock(deltaSeconds) {
|
||||
if (this.currentPhase().type !== "period") return
|
||||
|
||||
const activeClock = this.activeClock()
|
||||
if (!adjustClockState(activeClock, deltaSeconds)) return
|
||||
this.syncClockFromActivePhase()
|
||||
this.render()
|
||||
}
|
||||
|
||||
formatClock(totalSeconds) {
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
derivedStats() {
|
||||
return derivedStatsFromEngine(this.config, this.state.events)
|
||||
}
|
||||
|
||||
scoreboardStatePayload() {
|
||||
return scoreboardStatePayloadFromEngine(this.config, this.state, this.matchMetadata())
|
||||
}
|
||||
|
||||
matchMetadata() {
|
||||
return buildMatchMetadata({
|
||||
tournamentId: this.tournamentIdValue,
|
||||
boutNumber: this.boutNumberValue,
|
||||
weightLabel: this.weightLabelValue,
|
||||
ruleset: this.rulesetValue,
|
||||
bracketPosition: this.bracketPositionValue,
|
||||
w1Name: this.w1NameValue,
|
||||
w2Name: this.w2NameValue,
|
||||
w1School: this.w1SchoolValue,
|
||||
w2School: this.w2SchoolValue
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
344
app/assets/javascripts/lib/match_state/config.js
Normal file
344
app/assets/javascripts/lib/match_state/config.js
Normal file
@@ -0,0 +1,344 @@
|
||||
/*
|
||||
State page config contract
|
||||
==========================
|
||||
|
||||
The state page responds to these top-level config objects:
|
||||
|
||||
1. `wrestler_actions`
|
||||
Drives the wrestler-side UI from top to bottom inside each wrestler panel.
|
||||
The controller renders these sections in order, so the order in this object
|
||||
controls the visual order underneath each wrestler's name, school, and score.
|
||||
Supported sections:
|
||||
- `match_actions`
|
||||
- `timers`
|
||||
- `extra_actions`
|
||||
|
||||
Each section may define:
|
||||
- `title`
|
||||
- `description`
|
||||
- `items`
|
||||
|
||||
How the state page uses it:
|
||||
- `match_actions.items`
|
||||
Each item is either:
|
||||
- a literal action key, or
|
||||
- a special alias such as `global` or `position`
|
||||
The state page expands those aliases into the currently legal actions for
|
||||
that wrestler and renders them as buttons.
|
||||
- `timers.items`
|
||||
Each item is a timer key. The state page renders the timer display plus
|
||||
start/stop/reset buttons for each listed timer.
|
||||
- `extra_actions.items`
|
||||
Each item is a literal action key rendered as an always-visible button
|
||||
underneath the timer section.
|
||||
|
||||
2. `actionsByKey`
|
||||
Canonical definitions for match actions and history actions.
|
||||
This is the source of truth for how a button behaves and how an action
|
||||
should appear in the event log.
|
||||
Each action may define:
|
||||
- `label`
|
||||
- `availability`
|
||||
- `statCode`
|
||||
- `effect`
|
||||
- `progression`
|
||||
|
||||
How the state page uses it:
|
||||
- `label`
|
||||
Used for button text and event log text.
|
||||
- `availability`
|
||||
Used when `wrestler_actions.match_actions.items` includes aliases like
|
||||
`global` or `position`.
|
||||
- `effect`
|
||||
Used by the rules engine to update score and match position when replaying
|
||||
the event list.
|
||||
- `statCode`
|
||||
Used when rewriting the hidden `w1_stat` / `w2_stat` fields from the
|
||||
structured event log for websocket sync and match submission.
|
||||
- `progression`
|
||||
Used for progressive actions like stalling, caution, and penalty to decide
|
||||
if the opponent should automatically receive a linked point-scoring event.
|
||||
|
||||
Supported `availability` values used by the wrestler-side UI:
|
||||
- `global`
|
||||
- `neutral`
|
||||
- `top`
|
||||
- `bottom`
|
||||
- `extra`
|
||||
|
||||
3. `timers`
|
||||
Canonical timer definitions keyed by timer name.
|
||||
This controls both the timer controls in the wrestler panel and how timer
|
||||
usage is labeled in the event log.
|
||||
|
||||
How the state page uses it:
|
||||
- `label`
|
||||
Displayed next to the running timer value in the wrestler panel.
|
||||
- `maxSeconds`
|
||||
Used to initialize, reset, clamp, and render the timer.
|
||||
- `historyLabel`
|
||||
Used when a timer stop event is recorded in history.
|
||||
- `statCode`
|
||||
Used when rewriting the hidden `w1_stat` / `w2_stat` fields for timer-used
|
||||
events.
|
||||
|
||||
4. `phases`
|
||||
Defines the period / choice sequence for this wrestling style.
|
||||
The active phase drives:
|
||||
- the main match clock
|
||||
- phase labels
|
||||
- start-of-period position behavior
|
||||
- choice button behavior
|
||||
- event grouping in the history list
|
||||
|
||||
How the state page uses it:
|
||||
- chooses which phase sequence to use from bracket position
|
||||
- builds the main match clock state for timed phases
|
||||
- determines whether the current phase is a period or a choice phase
|
||||
- determines how a period starts (`neutral` or from a prior choice)
|
||||
*/
|
||||
|
||||
const RULESETS = {
|
||||
folkstyle_usa: {
|
||||
id: "folkstyle_usa",
|
||||
|
||||
wrestler_actions: {
|
||||
match_actions: {
|
||||
title: "Match Actions",
|
||||
description: "Scoring and match-state actions available based on current position.",
|
||||
items: ["global", "position"]
|
||||
},
|
||||
timers: {
|
||||
title: "Wrestler Timers",
|
||||
description: "Track blood, injury, recovery, and head/neck time for this wrestler.",
|
||||
items: ["blood", "injury", "recovery", "head_neck"]
|
||||
},
|
||||
extra_actions: {
|
||||
title: "Extra Actions",
|
||||
description: "Force the match into a specific position and record it in history.",
|
||||
items: ["position_neutral", "position_top", "position_bottom"]
|
||||
}
|
||||
},
|
||||
|
||||
actionsByKey: {
|
||||
stalling: {
|
||||
label: "Stalling",
|
||||
availability: "global",
|
||||
statCode: "S",
|
||||
effect: { points: 0 },
|
||||
progression: [0, 1, 1, 2]
|
||||
},
|
||||
caution: {
|
||||
label: "Caution",
|
||||
availability: "global",
|
||||
statCode: "C",
|
||||
effect: { points: 0 },
|
||||
progression: [0, 0, 1]
|
||||
},
|
||||
penalty: {
|
||||
label: "Penalty",
|
||||
availability: "global",
|
||||
statCode: "P",
|
||||
effect: { points: 0 },
|
||||
progression: [1, 1, 2]
|
||||
},
|
||||
minus_1: {
|
||||
label: "-1 Point",
|
||||
availability: "global",
|
||||
statCode: "-1",
|
||||
effect: { points: -1 }
|
||||
},
|
||||
plus_1: {
|
||||
label: "+1 Point",
|
||||
availability: "global",
|
||||
statCode: "+1",
|
||||
effect: { points: 1 }
|
||||
},
|
||||
plus_2: {
|
||||
label: "+2 Points",
|
||||
statCode: "+2",
|
||||
effect: { points: 2 }
|
||||
},
|
||||
takedown_3: {
|
||||
label: "Takedown +3",
|
||||
availability: "neutral",
|
||||
statCode: "T3",
|
||||
effect: { points: 3, nextPosition: "top" }
|
||||
},
|
||||
nearfall_2: {
|
||||
label: "Nearfall +2",
|
||||
availability: "top",
|
||||
statCode: "N2",
|
||||
effect: { points: 2 }
|
||||
},
|
||||
nearfall_3: {
|
||||
label: "Nearfall +3",
|
||||
availability: "top",
|
||||
statCode: "N3",
|
||||
effect: { points: 3 }
|
||||
},
|
||||
nearfall_4: {
|
||||
label: "Nearfall +4",
|
||||
availability: "top",
|
||||
statCode: "N4",
|
||||
effect: { points: 4 }
|
||||
},
|
||||
nearfall_5: {
|
||||
label: "Nearfall +5",
|
||||
availability: "top",
|
||||
statCode: "N5",
|
||||
effect: { points: 5 }
|
||||
},
|
||||
escape_1: {
|
||||
label: "Escape +1",
|
||||
availability: "bottom",
|
||||
statCode: "E1",
|
||||
effect: { points: 1, nextPosition: "neutral" }
|
||||
},
|
||||
reversal_2: {
|
||||
label: "Reversal +2",
|
||||
availability: "bottom",
|
||||
statCode: "R2",
|
||||
effect: { points: 2, nextPosition: "top" }
|
||||
},
|
||||
position_neutral: {
|
||||
label: "Neutral",
|
||||
availability: "extra",
|
||||
statCode: "|Neutral|",
|
||||
effect: { points: 0, nextPosition: "neutral" }
|
||||
},
|
||||
position_top: {
|
||||
label: "Top",
|
||||
availability: "extra",
|
||||
statCode: "|Top|",
|
||||
effect: { points: 0, nextPosition: "top" }
|
||||
},
|
||||
position_bottom: {
|
||||
label: "Bottom",
|
||||
availability: "extra",
|
||||
statCode: "|Bottom|",
|
||||
effect: { points: 0, nextPosition: "bottom" }
|
||||
},
|
||||
choice_top: {
|
||||
label: "Choice: Top",
|
||||
statCode: "|Chose Top|"
|
||||
},
|
||||
choice_bottom: {
|
||||
label: "Choice: Bottom",
|
||||
statCode: "|Chose Bottom|"
|
||||
},
|
||||
choice_neutral: {
|
||||
label: "Choice: Neutral",
|
||||
statCode: "|Chose Neutral|"
|
||||
},
|
||||
choice_defer: {
|
||||
label: "Choice: Defer",
|
||||
statCode: "|Deferred|"
|
||||
}
|
||||
},
|
||||
|
||||
timers: {
|
||||
blood: { maxSeconds: 300, label: "Blood", historyLabel: "Blood Time Used", statCode: "Blood Time" },
|
||||
injury: { maxSeconds: 90, label: "Injury", historyLabel: "Injury Time Used", statCode: "Injury Time" },
|
||||
recovery: { maxSeconds: 120, label: "Recovery", historyLabel: "Recovery Time Used", statCode: "Recovery Time" },
|
||||
head_neck: { maxSeconds: 300, label: "Head/Neck", historyLabel: "Head/Neck Time Used", statCode: "Head/Neck Time" }
|
||||
},
|
||||
|
||||
phases: {
|
||||
championship: {
|
||||
label: "Championship Format",
|
||||
sequence: [
|
||||
{ key: "period_1", label: "Period 1", type: "period", startsIn: "neutral", clockSeconds: 120 },
|
||||
{ key: "choice_1", label: "Choice 1", type: "choice", chooser: "either", options: ["top", "bottom", "neutral", "defer"] },
|
||||
{ key: "period_2", label: "Period 2", type: "period", startsFromChoice: "choice_1", clockSeconds: 120 },
|
||||
{ key: "choice_2", label: "Choice 2", type: "choice", chooser: "other", options: ["top", "bottom", "neutral"] },
|
||||
{ key: "period_3", label: "Period 3", type: "period", startsFromChoice: "choice_2", clockSeconds: 120 },
|
||||
{ key: "sv_1", label: "SV-1", type: "period", startsIn: "neutral", clockSeconds: 60, overtimeType: "SV-1" },
|
||||
{ key: "choice_3", label: "Choice 3", type: "choice", chooser: "either", options: ["top", "bottom", "defer"] },
|
||||
{ key: "tb_1a", label: "TB-1A", type: "period", startsFromChoice: "choice_3", clockSeconds: 30, overtimeType: "TB-1" },
|
||||
{ key: "choice_4", label: "Choice 4", type: "choice", chooser: "other", options: ["top", "bottom"] },
|
||||
{ key: "tb_1b", label: "TB-1B", type: "period", startsFromChoice: "choice_4", clockSeconds: 30, overtimeType: "TB-1" },
|
||||
{ key: "choice_5", label: "Choice 5", type: "choice", chooser: "either", options: ["top", "bottom"] },
|
||||
{ key: "utb", label: "UTB", type: "period", startsFromChoice: "choice_5", clockSeconds: 30, overtimeType: "UTB" }
|
||||
]
|
||||
},
|
||||
consolation: {
|
||||
label: "Consolation Format",
|
||||
sequence: [
|
||||
{ key: "period_1", label: "Period 1", type: "period", startsIn: "neutral", clockSeconds: 60 },
|
||||
{ key: "choice_1", label: "Choice 1", type: "choice", chooser: "either", options: ["top", "bottom", "neutral", "defer"] },
|
||||
{ key: "period_2", label: "Period 2", type: "period", startsFromChoice: "choice_1", clockSeconds: 120 },
|
||||
{ key: "choice_2", label: "Choice 2", type: "choice", chooser: "other", options: ["top", "bottom", "neutral"] },
|
||||
{ key: "period_3", label: "Period 3", type: "period", startsFromChoice: "choice_2", clockSeconds: 120 },
|
||||
{ key: "sv_1", label: "SV-1", type: "period", startsIn: "neutral", clockSeconds: 60, overtimeType: "SV-1" },
|
||||
{ key: "choice_3", label: "Choice 3", type: "choice", chooser: "either", options: ["top", "bottom", "defer"] },
|
||||
{ key: "tb_1a", label: "TB-1A", type: "period", startsFromChoice: "choice_3", clockSeconds: 30, overtimeType: "TB-1" },
|
||||
{ key: "choice_4", label: "Choice 4", type: "choice", chooser: "other", options: ["top", "bottom"] },
|
||||
{ key: "tb_1b", label: "TB-1B", type: "period", startsFromChoice: "choice_4", clockSeconds: 30, overtimeType: "TB-1" },
|
||||
{ key: "choice_5", label: "Choice 5", type: "choice", chooser: "either", options: ["top", "bottom"] },
|
||||
{ key: "utb", label: "UTB", type: "period", startsFromChoice: "choice_5", clockSeconds: 30, overtimeType: "UTB" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function phaseStyleKeyForBracketPosition(bracketPosition) {
|
||||
if (!bracketPosition) return "championship"
|
||||
|
||||
if (
|
||||
bracketPosition.includes("Conso") ||
|
||||
["3/4", "5/6", "7/8"].includes(bracketPosition)
|
||||
) {
|
||||
return "consolation"
|
||||
}
|
||||
|
||||
return "championship"
|
||||
}
|
||||
|
||||
function buildActionEffects(actionsByKey) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(actionsByKey)
|
||||
.filter(([, action]) => action.effect)
|
||||
.map(([key, action]) => [key, action.effect])
|
||||
)
|
||||
}
|
||||
|
||||
function buildActionLabels(actionsByKey, timers) {
|
||||
const actionLabels = Object.fromEntries(
|
||||
Object.entries(actionsByKey)
|
||||
.filter(([, action]) => action.label)
|
||||
.map(([key, action]) => [key, action.label])
|
||||
)
|
||||
|
||||
Object.entries(timers || {}).forEach(([timerKey, timer]) => {
|
||||
if (timer.historyLabel) {
|
||||
actionLabels[`timer_used_${timerKey}`] = timer.historyLabel
|
||||
}
|
||||
})
|
||||
|
||||
return actionLabels
|
||||
}
|
||||
|
||||
function buildProgressionRules(actionsByKey) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(actionsByKey)
|
||||
.filter(([, action]) => Array.isArray(action.progression))
|
||||
.map(([key, action]) => [key, action.progression])
|
||||
)
|
||||
}
|
||||
|
||||
export function getMatchStateConfig(rulesetId, bracketPosition) {
|
||||
const ruleset = RULESETS[rulesetId] || RULESETS.folkstyle_usa
|
||||
const phaseStyleKey = phaseStyleKeyForBracketPosition(bracketPosition)
|
||||
const phaseStyle = ruleset.phases[phaseStyleKey]
|
||||
|
||||
return {
|
||||
...ruleset,
|
||||
actionEffects: buildActionEffects(ruleset.actionsByKey),
|
||||
actionLabels: buildActionLabels(ruleset.actionsByKey, ruleset.timers),
|
||||
progressionRules: buildProgressionRules(ruleset.actionsByKey),
|
||||
matchFormat: { id: phaseStyleKey, label: phaseStyle.label },
|
||||
phaseSequence: phaseStyle.sequence
|
||||
}
|
||||
}
|
||||
567
app/assets/javascripts/lib/match_state/engine.js
Normal file
567
app/assets/javascripts/lib/match_state/engine.js
Normal file
@@ -0,0 +1,567 @@
|
||||
export function buildTimerState(config) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(config.timers).map(([timerKey, timerConfig]) => [
|
||||
timerKey,
|
||||
{
|
||||
remainingSeconds: timerConfig.maxSeconds,
|
||||
running: false,
|
||||
startedAt: null
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function buildClockState(config) {
|
||||
return Object.fromEntries(
|
||||
config.phaseSequence
|
||||
.filter((phase) => phase.type === "period")
|
||||
.map((phase) => [
|
||||
phase.key,
|
||||
{
|
||||
durationSeconds: phase.clockSeconds,
|
||||
remainingSeconds: phase.clockSeconds,
|
||||
running: false,
|
||||
startedAt: null
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function buildInitialState(config) {
|
||||
const openingPhase = config.phaseSequence[0]
|
||||
|
||||
return {
|
||||
participantScores: {
|
||||
w1: 0,
|
||||
w2: 0
|
||||
},
|
||||
control: "neutral",
|
||||
displayControl: "neutral",
|
||||
phaseIndex: 0,
|
||||
selections: {},
|
||||
assignment: {
|
||||
w1: "green",
|
||||
w2: "red"
|
||||
},
|
||||
nextEventId: 1,
|
||||
nextEventGroupId: 1,
|
||||
events: [],
|
||||
clocksByPhase: buildClockState(config),
|
||||
clock: {
|
||||
durationSeconds: openingPhase.clockSeconds,
|
||||
remainingSeconds: openingPhase.clockSeconds,
|
||||
running: false,
|
||||
startedAt: null
|
||||
},
|
||||
timers: {
|
||||
w1: buildTimerState(config),
|
||||
w2: buildTimerState(config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildEvent(state, phase, clockSeconds, participantKey, actionKey, options = {}) {
|
||||
return {
|
||||
id: state.nextEventId++,
|
||||
phaseKey: phase.key,
|
||||
phaseLabel: phase.label,
|
||||
clockSeconds,
|
||||
participantKey,
|
||||
actionKey,
|
||||
actionGroupId: options.actionGroupId
|
||||
}
|
||||
}
|
||||
|
||||
export function opponentParticipant(participantKey) {
|
||||
return participantKey === "w1" ? "w2" : "w1"
|
||||
}
|
||||
|
||||
export function isProgressiveAction(config, actionKey) {
|
||||
return Object.prototype.hasOwnProperty.call(config.progressionRules || {}, actionKey)
|
||||
}
|
||||
|
||||
export function progressiveActionCountForParticipant(events, participantKey, actionKey) {
|
||||
return events.filter((eventRecord) =>
|
||||
eventRecord.participantKey === participantKey && eventRecord.actionKey === actionKey
|
||||
).length
|
||||
}
|
||||
|
||||
export function progressiveActionPointsForOffense(config, actionKey, offenseNumber) {
|
||||
const progression = config.progressionRules?.[actionKey] || []
|
||||
return progression[Math.min(offenseNumber - 1, progression.length - 1)] || 0
|
||||
}
|
||||
|
||||
export function recordProgressiveAction(config, state, participantKey, actionKey, buildEvent) {
|
||||
const offenseNumber = progressiveActionCountForParticipant(state.events, participantKey, actionKey) + 1
|
||||
const actionGroupId = state.nextEventGroupId++
|
||||
state.events.push(buildEvent(participantKey, actionKey, { actionGroupId }))
|
||||
|
||||
const awardedPoints = progressiveActionPointsForOffense(config, actionKey, offenseNumber)
|
||||
if (awardedPoints > 0) {
|
||||
state.events.push(buildEvent(opponentParticipant(participantKey), `plus_${awardedPoints}`, { actionGroupId }))
|
||||
}
|
||||
}
|
||||
|
||||
export function applyMatchAction(config, state, phase, clockSeconds, participantKey, actionKey) {
|
||||
const effect = config.actionEffects[actionKey]
|
||||
if (!effect) return false
|
||||
|
||||
if (isProgressiveAction(config, actionKey)) {
|
||||
recordProgressiveAction(
|
||||
config,
|
||||
state,
|
||||
participantKey,
|
||||
actionKey,
|
||||
(eventParticipantKey, eventActionKey, options = {}) =>
|
||||
buildEvent(state, phase, clockSeconds, eventParticipantKey, eventActionKey, options)
|
||||
)
|
||||
} else {
|
||||
state.events.push(buildEvent(state, phase, clockSeconds, participantKey, actionKey))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function applyChoiceAction(state, phase, clockSeconds, participantKey, choiceKey) {
|
||||
if (phase.type !== "choice") return { applied: false, deferred: false }
|
||||
|
||||
state.events.push(buildEvent(state, phase, clockSeconds, participantKey, `choice_${choiceKey}`))
|
||||
|
||||
if (choiceKey === "defer") {
|
||||
return { applied: true, deferred: true }
|
||||
}
|
||||
|
||||
state.selections[phase.key] = {
|
||||
participantKey,
|
||||
choiceKey
|
||||
}
|
||||
|
||||
return { applied: true, deferred: false }
|
||||
}
|
||||
|
||||
export function deleteEventFromState(config, state, eventId) {
|
||||
const eventRecord = state.events.find((eventItem) => eventItem.id === eventId)
|
||||
if (!eventRecord) return false
|
||||
|
||||
let eventIdsToDelete = [eventId]
|
||||
|
||||
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
|
||||
const timerKey = eventRecord.actionKey.replace("timer_used_", "")
|
||||
const timer = state.timers[eventRecord.participantKey]?.[timerKey]
|
||||
const maxSeconds = config.timers[timerKey]?.maxSeconds || 0
|
||||
if (timer) {
|
||||
timer.remainingSeconds = Math.min(maxSeconds, timer.remainingSeconds + eventRecord.elapsedSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
if (isProgressiveAction(config, eventRecord.actionKey)) {
|
||||
const linkedAward = findLinkedProgressiveAward(state.events, eventRecord)
|
||||
if (linkedAward) {
|
||||
eventIdsToDelete.push(linkedAward.id)
|
||||
}
|
||||
}
|
||||
|
||||
state.events = state.events.filter((eventItem) => !eventIdsToDelete.includes(eventItem.id))
|
||||
return true
|
||||
}
|
||||
|
||||
export function swapEventParticipants(config, state, eventId) {
|
||||
const eventRecord = state.events.find((eventItem) => eventItem.id === eventId)
|
||||
if (!eventRecord) return false
|
||||
|
||||
const originalParticipant = eventRecord.participantKey
|
||||
const swappedParticipant = opponentParticipant(originalParticipant)
|
||||
|
||||
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
|
||||
reassignTimerUsage(config, state, eventRecord, swappedParticipant)
|
||||
}
|
||||
|
||||
eventRecord.participantKey = swappedParticipant
|
||||
|
||||
if (isProgressiveAction(config, eventRecord.actionKey)) {
|
||||
swapLinkedProgressiveAward(state.events, eventRecord, swappedParticipant)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function swapPhaseParticipants(config, state, phaseKey) {
|
||||
const phaseEvents = state.events.filter((eventRecord) => eventRecord.phaseKey === phaseKey)
|
||||
if (phaseEvents.length === 0) return false
|
||||
|
||||
phaseEvents.forEach((eventRecord) => {
|
||||
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
|
||||
reassignTimerUsage(config, state, eventRecord, opponentParticipant(eventRecord.participantKey))
|
||||
}
|
||||
|
||||
eventRecord.participantKey = opponentParticipant(eventRecord.participantKey)
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function phaseIndexForKey(config, phaseKey) {
|
||||
const phaseIndex = config.phaseSequence.findIndex((phase) => phase.key === phaseKey)
|
||||
return phaseIndex === -1 ? Number.MAX_SAFE_INTEGER : phaseIndex
|
||||
}
|
||||
|
||||
export function activeClockForPhase(state, phase) {
|
||||
if (!phase || phase.type !== "period") return null
|
||||
return state.clocksByPhase[phase.key] || null
|
||||
}
|
||||
|
||||
export function hasRunningClockOrTimer(state) {
|
||||
const anyTimerRunning = ["w1", "w2"].some((participantKey) =>
|
||||
Object.values(state.timers[participantKey] || {}).some((timer) => timer.running)
|
||||
)
|
||||
const anyClockRunning = Object.values(state.clocksByPhase || {}).some((clock) => clock.running)
|
||||
return anyTimerRunning || anyClockRunning
|
||||
}
|
||||
|
||||
export function stopAllAuxiliaryTimers(state, now = Date.now()) {
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const timerKey of Object.keys(state.timers[participantKey] || {})) {
|
||||
const timer = state.timers[participantKey][timerKey]
|
||||
if (!timer.running) continue
|
||||
|
||||
const elapsedSeconds = Math.floor((now - timer.startedAt) / 1000)
|
||||
timer.remainingSeconds = Math.max(0, timer.remainingSeconds - elapsedSeconds)
|
||||
timer.running = false
|
||||
timer.startedAt = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function moveToPreviousPhase(config, state) {
|
||||
if (state.phaseIndex === 0) return false
|
||||
state.phaseIndex -= 1
|
||||
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
|
||||
return true
|
||||
}
|
||||
|
||||
export function moveToNextPhase(config, state) {
|
||||
if (state.phaseIndex >= config.phaseSequence.length - 1) return false
|
||||
state.phaseIndex += 1
|
||||
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
|
||||
return true
|
||||
}
|
||||
|
||||
export function orderedEvents(config, events) {
|
||||
return [...events].sort((leftEvent, rightEvent) => {
|
||||
const leftPhaseIndex = phaseIndexForKey(config, leftEvent.phaseKey)
|
||||
const rightPhaseIndex = phaseIndexForKey(config, rightEvent.phaseKey)
|
||||
if (leftPhaseIndex !== rightPhaseIndex) {
|
||||
return leftPhaseIndex - rightPhaseIndex
|
||||
}
|
||||
return leftEvent.id - rightEvent.id
|
||||
})
|
||||
}
|
||||
|
||||
export function controlFromChoice(selection) {
|
||||
if (!selection) return "neutral"
|
||||
if (selection.choiceKey === "neutral" || selection.choiceKey === "defer") return "neutral"
|
||||
if (selection.choiceKey === "top") return `${selection.participantKey}_control`
|
||||
if (selection.choiceKey === "bottom") return `${opponentParticipant(selection.participantKey)}_control`
|
||||
return "neutral"
|
||||
}
|
||||
|
||||
export function baseControlForPhase(phase, selections, fallbackControl) {
|
||||
if (phase.type !== "period") return fallbackControl
|
||||
if (phase.startsIn === "neutral") return "neutral"
|
||||
if (phase.startsFromChoice) {
|
||||
return controlFromChoice(selections[phase.startsFromChoice])
|
||||
}
|
||||
return "neutral"
|
||||
}
|
||||
|
||||
export function recomputeDerivedState(config, state) {
|
||||
state.participantScores = { w1: 0, w2: 0 }
|
||||
state.selections = {}
|
||||
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
|
||||
|
||||
orderedEvents(config, state.events).forEach((eventRecord) => {
|
||||
if (eventRecord.actionKey.startsWith("choice_")) {
|
||||
const choiceKey = eventRecord.actionKey.replace("choice_", "")
|
||||
if (choiceKey === "defer") return
|
||||
|
||||
state.selections[eventRecord.phaseKey] = {
|
||||
participantKey: eventRecord.participantKey,
|
||||
choiceKey: choiceKey
|
||||
}
|
||||
state.control = baseControlForPhase(config.phaseSequence[state.phaseIndex], state.selections, state.control)
|
||||
return
|
||||
}
|
||||
|
||||
const effect = config.actionEffects[eventRecord.actionKey]
|
||||
if (!effect) return
|
||||
|
||||
const scoringParticipant = effect.target === "opponent"
|
||||
? opponentParticipant(eventRecord.participantKey)
|
||||
: eventRecord.participantKey
|
||||
const nextScore = state.participantScores[scoringParticipant] + effect.points
|
||||
state.participantScores[scoringParticipant] = Math.max(0, nextScore)
|
||||
|
||||
if (effect.nextPosition === "neutral") {
|
||||
state.control = "neutral"
|
||||
} else if (effect.nextPosition === "top") {
|
||||
state.control = `${eventRecord.participantKey}_control`
|
||||
} else if (effect.nextPosition === "bottom") {
|
||||
state.control = `${opponentParticipant(eventRecord.participantKey)}_control`
|
||||
}
|
||||
})
|
||||
|
||||
state.displayControl = controlForSelectedPhase(config, state)
|
||||
}
|
||||
|
||||
export function controlForSelectedPhase(config, state) {
|
||||
const selectedPhase = config.phaseSequence[state.phaseIndex]
|
||||
let control = baseControlForPhase(selectedPhase, state.selections, state.control)
|
||||
const selectedPhaseIndex = phaseIndexForKey(config, selectedPhase.key)
|
||||
|
||||
orderedEvents(config, state.events).forEach((eventRecord) => {
|
||||
if (phaseIndexForKey(config, eventRecord.phaseKey) > selectedPhaseIndex) return
|
||||
if (eventRecord.phaseKey !== selectedPhase.key) return
|
||||
|
||||
const effect = config.actionEffects[eventRecord.actionKey]
|
||||
if (!effect) return
|
||||
|
||||
if (effect.nextPosition === "neutral") {
|
||||
control = "neutral"
|
||||
} else if (effect.nextPosition === "top") {
|
||||
control = `${eventRecord.participantKey}_control`
|
||||
} else if (effect.nextPosition === "bottom") {
|
||||
control = `${opponentParticipant(eventRecord.participantKey)}_control`
|
||||
}
|
||||
})
|
||||
|
||||
return control
|
||||
}
|
||||
|
||||
export function currentClockSeconds(clockState, now = Date.now()) {
|
||||
if (!clockState) return 0
|
||||
if (!clockState.running || !clockState.startedAt) {
|
||||
return clockState.remainingSeconds
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.floor((now - clockState.startedAt) / 1000)
|
||||
return Math.max(0, clockState.remainingSeconds - elapsedSeconds)
|
||||
}
|
||||
|
||||
export function currentAuxiliaryTimerSeconds(timerState, now = Date.now()) {
|
||||
if (!timerState) return 0
|
||||
if (!timerState.running || !timerState.startedAt) {
|
||||
return timerState.remainingSeconds
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.floor((now - timerState.startedAt) / 1000)
|
||||
return Math.max(0, timerState.remainingSeconds - elapsedSeconds)
|
||||
}
|
||||
|
||||
export function syncClockSnapshot(activeClock) {
|
||||
if (!activeClock) {
|
||||
return {
|
||||
durationSeconds: 0,
|
||||
remainingSeconds: 0,
|
||||
running: false,
|
||||
startedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
durationSeconds: activeClock.durationSeconds,
|
||||
remainingSeconds: activeClock.remainingSeconds,
|
||||
running: activeClock.running,
|
||||
startedAt: activeClock.startedAt
|
||||
}
|
||||
}
|
||||
|
||||
export function startClockState(activeClock, now = Date.now()) {
|
||||
if (!activeClock || activeClock.running) return false
|
||||
activeClock.running = true
|
||||
activeClock.startedAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
export function stopClockState(activeClock, now = Date.now()) {
|
||||
if (!activeClock || !activeClock.running) return false
|
||||
activeClock.remainingSeconds = currentClockSeconds(activeClock, now)
|
||||
activeClock.running = false
|
||||
activeClock.startedAt = null
|
||||
return true
|
||||
}
|
||||
|
||||
export function adjustClockState(activeClock, deltaSeconds, now = Date.now()) {
|
||||
if (!activeClock) return false
|
||||
|
||||
const currentSeconds = currentClockSeconds(activeClock, now)
|
||||
activeClock.remainingSeconds = Math.max(0, currentSeconds + deltaSeconds)
|
||||
if (activeClock.running) {
|
||||
activeClock.startedAt = now
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function startAuxiliaryTimerState(timerState, now = Date.now()) {
|
||||
if (!timerState || timerState.running) return false
|
||||
timerState.running = true
|
||||
timerState.startedAt = now
|
||||
return true
|
||||
}
|
||||
|
||||
export function stopAuxiliaryTimerState(timerState, now = Date.now()) {
|
||||
if (!timerState || !timerState.running) return { stopped: false, elapsedSeconds: 0 }
|
||||
|
||||
const elapsedSeconds = Math.floor((now - timerState.startedAt) / 1000)
|
||||
timerState.remainingSeconds = currentAuxiliaryTimerSeconds(timerState, now)
|
||||
timerState.running = false
|
||||
timerState.startedAt = null
|
||||
|
||||
return { stopped: true, elapsedSeconds }
|
||||
}
|
||||
|
||||
export function accumulatedMatchSeconds(config, state, activePhaseKey, now = Date.now()) {
|
||||
return config.phaseSequence
|
||||
.filter((phase) => phase.type === "period")
|
||||
.reduce((totalElapsed, phase) => {
|
||||
const clockState = state.clocksByPhase[phase.key]
|
||||
if (!clockState) return totalElapsed
|
||||
|
||||
const remainingSeconds = phase.key === activePhaseKey
|
||||
? currentClockSeconds(clockState, now)
|
||||
: clockState.remainingSeconds
|
||||
|
||||
const elapsedSeconds = Math.max(0, clockState.durationSeconds - remainingSeconds)
|
||||
return totalElapsed + elapsedSeconds
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export function derivedStats(config, events) {
|
||||
const grouped = config.phaseSequence.map((phase) => {
|
||||
const phaseEvents = orderedEvents(config, events).filter((eventRecord) => eventRecord.phaseKey === phase.key)
|
||||
if (phaseEvents.length === 0) return null
|
||||
|
||||
return {
|
||||
label: phase.label,
|
||||
w1: phaseEvents
|
||||
.filter((eventRecord) => eventRecord.participantKey === "w1")
|
||||
.map((eventRecord) => statTextForEvent(config, eventRecord))
|
||||
.filter(Boolean),
|
||||
w2: phaseEvents
|
||||
.filter((eventRecord) => eventRecord.participantKey === "w2")
|
||||
.map((eventRecord) => statTextForEvent(config, eventRecord))
|
||||
.filter(Boolean)
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
return {
|
||||
w1: formatStatsByPhase(grouped, "w1"),
|
||||
w2: formatStatsByPhase(grouped, "w2")
|
||||
}
|
||||
}
|
||||
|
||||
export function scoreboardStatePayload(config, state, metadata) {
|
||||
return {
|
||||
participantScores: state.participantScores,
|
||||
assignment: state.assignment,
|
||||
phaseIndex: state.phaseIndex,
|
||||
clocksByPhase: state.clocksByPhase,
|
||||
timers: state.timers,
|
||||
metadata: metadata,
|
||||
matchResult: {
|
||||
finished: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function matchResultDefaults(state, options = {}) {
|
||||
const {
|
||||
w1Id = "",
|
||||
w2Id = "",
|
||||
currentPhase = {},
|
||||
accumulationSeconds = 0
|
||||
} = options
|
||||
|
||||
const w1Score = state.participantScores.w1
|
||||
const w2Score = state.participantScores.w2
|
||||
let winnerId = ""
|
||||
let winnerScore = w1Score
|
||||
let loserScore = w2Score
|
||||
|
||||
if (w1Score > w2Score) {
|
||||
winnerId = w1Id || ""
|
||||
winnerScore = w1Score
|
||||
loserScore = w2Score
|
||||
} else if (w2Score > w1Score) {
|
||||
winnerId = w2Id || ""
|
||||
winnerScore = w2Score
|
||||
loserScore = w1Score
|
||||
}
|
||||
|
||||
return {
|
||||
winnerId,
|
||||
overtimeType: currentPhase.overtimeType || "",
|
||||
winnerScore,
|
||||
loserScore,
|
||||
pinMinutes: Math.floor(accumulationSeconds / 60),
|
||||
pinSeconds: accumulationSeconds % 60
|
||||
}
|
||||
}
|
||||
|
||||
function statTextForEvent(config, eventRecord) {
|
||||
if (eventRecord.actionKey.startsWith("timer_used_")) {
|
||||
const timerKey = eventRecord.actionKey.replace("timer_used_", "")
|
||||
const timerConfig = config.timers[timerKey]
|
||||
if (!timerConfig || typeof eventRecord.elapsedSeconds !== "number") return null
|
||||
return `${timerConfig.statCode || timerConfig.label}: ${formatClock(eventRecord.elapsedSeconds)}`
|
||||
}
|
||||
|
||||
const action = config.actionsByKey[eventRecord.actionKey]
|
||||
return action?.statCode || null
|
||||
}
|
||||
|
||||
function formatStatsByPhase(groupedPhases, participantKey) {
|
||||
return groupedPhases
|
||||
.map((phase) => {
|
||||
const items = phase[participantKey]
|
||||
if (!items || items.length === 0) return null
|
||||
return `${phase.label}: ${items.join(" ")}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function formatClock(totalSeconds) {
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function reassignTimerUsage(config, state, eventRecord, newParticipantKey) {
|
||||
const timerKey = eventRecord.actionKey.replace("timer_used_", "")
|
||||
const originalParticipant = eventRecord.participantKey
|
||||
const originalTimer = state.timers[originalParticipant]?.[timerKey]
|
||||
const newTimer = state.timers[newParticipantKey]?.[timerKey]
|
||||
const maxSeconds = config.timers[timerKey]?.maxSeconds || 0
|
||||
|
||||
if (!originalTimer || !newTimer || typeof eventRecord.elapsedSeconds !== "number") return
|
||||
|
||||
originalTimer.remainingSeconds = Math.min(maxSeconds, originalTimer.remainingSeconds + eventRecord.elapsedSeconds)
|
||||
newTimer.remainingSeconds = Math.max(0, newTimer.remainingSeconds - eventRecord.elapsedSeconds)
|
||||
}
|
||||
|
||||
function swapLinkedProgressiveAward(events, eventRecord, offendingParticipant) {
|
||||
const linkedAward = findLinkedProgressiveAward(events, eventRecord)
|
||||
if (linkedAward) {
|
||||
linkedAward.participantKey = opponentParticipant(offendingParticipant)
|
||||
}
|
||||
}
|
||||
|
||||
function findLinkedProgressiveAward(events, eventRecord) {
|
||||
return events.find((candidateEvent) =>
|
||||
candidateEvent.id !== eventRecord.id &&
|
||||
candidateEvent.actionGroupId &&
|
||||
candidateEvent.actionGroupId === eventRecord.actionGroupId &&
|
||||
candidateEvent.actionKey.startsWith("plus_")
|
||||
)
|
||||
}
|
||||
94
app/assets/javascripts/lib/match_state/presenters.js
Normal file
94
app/assets/javascripts/lib/match_state/presenters.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { orderedEvents } from "match-state-engine"
|
||||
|
||||
export function displayLabelForParticipant(assignment, participantKey) {
|
||||
return assignment[participantKey] === "green" ? "Green" : "Red"
|
||||
}
|
||||
|
||||
export function buttonClassForParticipant(assignment, participantKey) {
|
||||
return assignment[participantKey] === "green" ? "btn-success" : "btn-danger"
|
||||
}
|
||||
|
||||
export function humanizeChoice(choiceKey) {
|
||||
if (choiceKey === "top") return "Top"
|
||||
if (choiceKey === "bottom") return "Bottom"
|
||||
if (choiceKey === "neutral") return "Neutral"
|
||||
if (choiceKey === "defer") return "Defer"
|
||||
return choiceKey
|
||||
}
|
||||
|
||||
export function choiceLabelForPhase(phase) {
|
||||
if (phase.chooser === "other") return "Other wrestler chooses"
|
||||
return "Choose wrestler and position"
|
||||
}
|
||||
|
||||
export function eventLogSections(config, state, formatClock) {
|
||||
const eventsByPhase = orderedEvents(config, state.events).reduce((accumulator, eventRecord) => {
|
||||
if (!accumulator[eventRecord.phaseKey]) {
|
||||
accumulator[eventRecord.phaseKey] = []
|
||||
}
|
||||
accumulator[eventRecord.phaseKey].push(eventRecord)
|
||||
return accumulator
|
||||
}, {})
|
||||
|
||||
return config.phaseSequence.map((phase) => {
|
||||
const phaseEvents = eventsByPhase[phase.key]
|
||||
if (!phaseEvents || phaseEvents.length === 0) return null
|
||||
|
||||
return {
|
||||
key: phase.key,
|
||||
label: phase.label,
|
||||
items: [...phaseEvents].reverse().map((eventRecord) => ({
|
||||
id: eventRecord.id,
|
||||
participantKey: eventRecord.participantKey,
|
||||
colorLabel: displayLabelForParticipant(state.assignment, eventRecord.participantKey),
|
||||
actionLabel: eventActionLabel(config, eventRecord, formatClock),
|
||||
clockLabel: formatClock(eventRecord.clockSeconds)
|
||||
}))
|
||||
}
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
export function choiceViewModel(config, state, phase, participantMeta) {
|
||||
if (phase.type !== "choice") return null
|
||||
|
||||
const phaseEvents = state.events.filter((eventRecord) => eventRecord.phaseKey === phase.key)
|
||||
const deferredParticipants = phaseEvents
|
||||
.filter((eventRecord) => eventRecord.actionKey === "choice_defer")
|
||||
.map((eventRecord) => eventRecord.participantKey)
|
||||
const selection = state.selections[phase.key]
|
||||
|
||||
const selectionText = selection
|
||||
? `Selected: ${displayLabelForParticipant(state.assignment, selection.participantKey)} ${humanizeChoice(selection.choiceKey)}`
|
||||
: deferredParticipants.length > 0
|
||||
? `${deferredParticipants.map((participantKey) => displayLabelForParticipant(state.assignment, participantKey)).join(", ")} deferred. Waiting for the other wrestler to choose.`
|
||||
: "No choice selected."
|
||||
|
||||
const availableParticipants = deferredParticipants.length > 0
|
||||
? ["w1", "w2"].filter((participantKey) => !deferredParticipants.includes(participantKey))
|
||||
: ["w1", "w2"]
|
||||
|
||||
const buttons = availableParticipants.flatMap((participantKey) =>
|
||||
phase.options
|
||||
.filter((choiceKey) => !(deferredParticipants.length > 0 && choiceKey === "defer"))
|
||||
.map((choiceKey) => ({
|
||||
participantKey,
|
||||
choiceKey,
|
||||
buttonClass: buttonClassForParticipant(state.assignment, participantKey),
|
||||
text: `${participantMeta[participantKey].name} (${displayLabelForParticipant(state.assignment, participantKey)}) ${humanizeChoice(choiceKey)}`
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
label: choiceLabelForPhase(phase),
|
||||
selectionText,
|
||||
buttons
|
||||
}
|
||||
}
|
||||
|
||||
function eventActionLabel(config, eventRecord, formatClock) {
|
||||
let actionLabel = config.actionLabels[eventRecord.actionKey] || eventRecord.actionKey
|
||||
if (eventRecord.actionKey.startsWith("timer_used_") && typeof eventRecord.elapsedSeconds === "number") {
|
||||
actionLabel = `${actionLabel}: ${formatClock(eventRecord.elapsedSeconds)}`
|
||||
}
|
||||
return actionLabel
|
||||
}
|
||||
288
app/assets/javascripts/lib/match_state/scoreboard_presenters.js
Normal file
288
app/assets/javascripts/lib/match_state/scoreboard_presenters.js
Normal file
@@ -0,0 +1,288 @@
|
||||
export function participantForColor(state, color) {
|
||||
if (!state?.assignment) {
|
||||
return color === "red" ? "w2" : "w1"
|
||||
}
|
||||
|
||||
const match = Object.entries(state.assignment).find(([, assignedColor]) => assignedColor === color)
|
||||
return match ? match[0] : (color === "red" ? "w2" : "w1")
|
||||
}
|
||||
|
||||
export function participantColor(state, participantKey) {
|
||||
return state?.assignment?.[participantKey] || (participantKey === "w1" ? "green" : "red")
|
||||
}
|
||||
|
||||
export function participantName(state, participantKey) {
|
||||
return participantKey === "w1" ? state?.metadata?.w1Name : state?.metadata?.w2Name
|
||||
}
|
||||
|
||||
export function participantSchool(state, participantKey) {
|
||||
return participantKey === "w1" ? state?.metadata?.w1School : state?.metadata?.w2School
|
||||
}
|
||||
|
||||
export function participantScore(state, participantKey) {
|
||||
return state?.participantScores?.[participantKey] || 0
|
||||
}
|
||||
|
||||
export function currentPhaseLabel(config, state) {
|
||||
const phaseIndex = state?.phaseIndex || 0
|
||||
return config?.phaseSequence?.[phaseIndex]?.label || "Period 1"
|
||||
}
|
||||
|
||||
export function currentClockText(config, state, formatClock, now = Date.now()) {
|
||||
const phaseIndex = state?.phaseIndex || 0
|
||||
const phase = config?.phaseSequence?.[phaseIndex]
|
||||
if (!phase || phase.type !== "period") return "-"
|
||||
|
||||
const clockState = state?.clocksByPhase?.[phase.key]
|
||||
if (!clockState) return formatClock(phase.clockSeconds)
|
||||
|
||||
let remainingSeconds = clockState.remainingSeconds
|
||||
if (clockState.running && clockState.startedAt) {
|
||||
const elapsedSeconds = Math.floor((now - clockState.startedAt) / 1000)
|
||||
remainingSeconds = Math.max(0, clockState.remainingSeconds - elapsedSeconds)
|
||||
}
|
||||
|
||||
return formatClock(remainingSeconds)
|
||||
}
|
||||
|
||||
export function currentAuxiliaryTimerSeconds(state, participantKey, timerKey, now = Date.now()) {
|
||||
const timer = state?.timers?.[participantKey]?.[timerKey]
|
||||
if (!timer) return 0
|
||||
if (!timer.running || !timer.startedAt) {
|
||||
return timer.remainingSeconds
|
||||
}
|
||||
|
||||
const elapsedSeconds = Math.floor((now - timer.startedAt) / 1000)
|
||||
return Math.max(0, timer.remainingSeconds - elapsedSeconds)
|
||||
}
|
||||
|
||||
export function runningTimerForParticipant(state, participantKey) {
|
||||
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
|
||||
if (state.timers[participantKey][timerKey]?.running) {
|
||||
return timerKey
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function participantDisplayLabel(state, participantKey) {
|
||||
return `${participantForColor(state, "red") === participantKey ? "Red" : "Green"} ${participantName(state, participantKey)}`
|
||||
}
|
||||
|
||||
export function timerIndicatorLabel(config, state, participantKey, formatClock, now = Date.now()) {
|
||||
const runningTimer = runningTimerForParticipant(state, participantKey)
|
||||
if (!runningTimer) return ""
|
||||
|
||||
const timerConfig = config?.timers?.[runningTimer]
|
||||
if (!timerConfig) return ""
|
||||
|
||||
const remainingSeconds = currentAuxiliaryTimerSeconds(state, participantKey, runningTimer, now)
|
||||
const usedSeconds = Math.max(0, timerConfig.maxSeconds - remainingSeconds)
|
||||
return `${timerConfig.label}: ${formatClock(usedSeconds)}`
|
||||
}
|
||||
|
||||
export function buildRunningTimerSnapshot(state) {
|
||||
const snapshot = {}
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
|
||||
const timer = state.timers[participantKey][timerKey]
|
||||
snapshot[`${participantKey}:${timerKey}`] = Boolean(timer?.running)
|
||||
}
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
export function detectRecentlyStoppedTimer(state, previousTimerSnapshot) {
|
||||
previousTimerSnapshot ||= {}
|
||||
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
|
||||
const snapshotKey = `${participantKey}:${timerKey}`
|
||||
const wasRunning = previousTimerSnapshot[snapshotKey]
|
||||
const isRunning = Boolean(state.timers[participantKey][timerKey]?.running)
|
||||
if (wasRunning && !isRunning) {
|
||||
return { participantKey, timerKey }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function runningAuxiliaryTimer(state) {
|
||||
for (const participantKey of ["w1", "w2"]) {
|
||||
for (const timerKey of Object.keys(state?.timers?.[participantKey] || {})) {
|
||||
const timer = state.timers[participantKey][timerKey]
|
||||
if (timer?.running) {
|
||||
return { participantKey, timerKey }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function mainClockRunning(config, state) {
|
||||
const phaseIndex = state?.phaseIndex || 0
|
||||
const phase = config?.phaseSequence?.[phaseIndex]
|
||||
if (!phase || phase.type !== "period") return false
|
||||
return Boolean(state?.clocksByPhase?.[phase.key]?.running)
|
||||
}
|
||||
|
||||
export function timerBannerViewModel(config, state, timerBannerState, formatClock, now = Date.now()) {
|
||||
if (!timerBannerState) return null
|
||||
|
||||
const { participantKey, timerKey, expiresAt } = timerBannerState
|
||||
if (expiresAt && now > expiresAt) return null
|
||||
|
||||
const timer = state?.timers?.[participantKey]?.[timerKey]
|
||||
const timerConfig = config?.timers?.[timerKey]
|
||||
if (!timer || !timerConfig) return null
|
||||
|
||||
const runningSeconds = currentAuxiliaryTimerSeconds(state, participantKey, timerKey, now)
|
||||
const usedSeconds = Math.max(0, timerConfig.maxSeconds - runningSeconds)
|
||||
const color = participantColor(state, participantKey)
|
||||
const label = `${participantDisplayLabel(state, participantKey)} ${timerConfig.label}`
|
||||
|
||||
return {
|
||||
color,
|
||||
label: timer.running ? `${label} Running` : `${label} Used`,
|
||||
clockText: formatClock(usedSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
export function populatedBoardViewModel(config, state, liveMatchData, currentBoutNumber, formatClock, now = Date.now()) {
|
||||
const redParticipant = participantForColor(state, "red")
|
||||
const greenParticipant = participantForColor(state, "green")
|
||||
|
||||
return {
|
||||
isEmpty: false,
|
||||
redName: participantName(state, redParticipant),
|
||||
redSchool: participantSchool(state, redParticipant),
|
||||
redScore: participantScore(state, redParticipant).toString(),
|
||||
redTimerIndicator: timerIndicatorLabel(config, state, redParticipant, formatClock, now),
|
||||
greenName: participantName(state, greenParticipant),
|
||||
greenSchool: participantSchool(state, greenParticipant),
|
||||
greenScore: participantScore(state, greenParticipant).toString(),
|
||||
greenTimerIndicator: timerIndicatorLabel(config, state, greenParticipant, formatClock, now),
|
||||
clockText: currentClockText(config, state, formatClock, now),
|
||||
phaseLabel: currentPhaseLabel(config, state),
|
||||
weightLabel: state?.metadata?.weightLabel ? `Weight ${state.metadata.weightLabel}` : "Weight -",
|
||||
boutLabel: currentBoutNumber ? `Bout ${currentBoutNumber}` : "No Bout",
|
||||
redStats: redParticipant === "w1" ? (liveMatchData?.w1_stat || "") : (liveMatchData?.w2_stat || ""),
|
||||
greenStats: greenParticipant === "w1" ? (liveMatchData?.w1_stat || "") : (liveMatchData?.w2_stat || "")
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyBoardViewModel(currentBoutNumber, lastMatchResult) {
|
||||
return {
|
||||
isEmpty: true,
|
||||
redName: "NO MATCH",
|
||||
redSchool: "",
|
||||
redScore: "0",
|
||||
redTimerIndicator: "",
|
||||
greenName: "NO MATCH",
|
||||
greenSchool: "",
|
||||
greenScore: "0",
|
||||
greenTimerIndicator: "",
|
||||
clockText: "-",
|
||||
phaseLabel: "No Match",
|
||||
weightLabel: "Weight -",
|
||||
boutLabel: currentBoutNumber ? `Bout ${currentBoutNumber}` : "No Bout",
|
||||
redStats: "",
|
||||
greenStats: "",
|
||||
lastMatchResult: lastMatchResult || "-"
|
||||
}
|
||||
}
|
||||
|
||||
export function nextTimerBannerState(state, previousTimerSnapshot, now = Date.now()) {
|
||||
if (!state?.timers) {
|
||||
return { timerBannerState: null, previousTimerSnapshot: {} }
|
||||
}
|
||||
|
||||
const activeTimer = runningAuxiliaryTimer(state)
|
||||
const nextSnapshot = buildRunningTimerSnapshot(state)
|
||||
|
||||
if (activeTimer) {
|
||||
return {
|
||||
timerBannerState: {
|
||||
participantKey: activeTimer.participantKey,
|
||||
timerKey: activeTimer.timerKey,
|
||||
expiresAt: null
|
||||
},
|
||||
previousTimerSnapshot: nextSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
const stoppedTimer = detectRecentlyStoppedTimer(state, previousTimerSnapshot)
|
||||
if (stoppedTimer) {
|
||||
return {
|
||||
timerBannerState: {
|
||||
participantKey: stoppedTimer.participantKey,
|
||||
timerKey: stoppedTimer.timerKey,
|
||||
expiresAt: now + 10000
|
||||
},
|
||||
previousTimerSnapshot: nextSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timerBannerState: null,
|
||||
previousTimerSnapshot: nextSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
export function boardColors(isEmpty) {
|
||||
if (isEmpty) {
|
||||
return {
|
||||
red: "#000",
|
||||
center: "#000",
|
||||
green: "#000"
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
red: "#c91f1f",
|
||||
center: "#050505",
|
||||
green: "#1cab2d"
|
||||
}
|
||||
}
|
||||
|
||||
export function timerBannerRenderState(config, state, timerBannerState, formatClock, now = Date.now()) {
|
||||
if (mainClockRunning(config, state)) {
|
||||
return {
|
||||
timerBannerState: timerBannerState?.expiresAt ? null : timerBannerState,
|
||||
visible: false,
|
||||
viewModel: null
|
||||
}
|
||||
}
|
||||
|
||||
if (!timerBannerState) {
|
||||
return {
|
||||
timerBannerState: null,
|
||||
visible: false,
|
||||
viewModel: null
|
||||
}
|
||||
}
|
||||
|
||||
if (timerBannerState.expiresAt && now > timerBannerState.expiresAt) {
|
||||
return {
|
||||
timerBannerState: null,
|
||||
visible: false,
|
||||
viewModel: null
|
||||
}
|
||||
}
|
||||
|
||||
const viewModel = timerBannerViewModel(config, state, timerBannerState, formatClock, now)
|
||||
if (!viewModel) {
|
||||
return {
|
||||
timerBannerState,
|
||||
visible: false,
|
||||
viewModel: null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timerBannerState,
|
||||
visible: true,
|
||||
viewModel
|
||||
}
|
||||
}
|
||||
158
app/assets/javascripts/lib/match_state/scoreboard_state.js
Normal file
158
app/assets/javascripts/lib/match_state/scoreboard_state.js
Normal file
@@ -0,0 +1,158 @@
|
||||
import { buildStorageKey } from "match-state-serializers"
|
||||
|
||||
export function buildScoreboardContext({ initialBoutNumber, matchId }) {
|
||||
const currentQueueBoutNumber = initialBoutNumber > 0 ? initialBoutNumber : null
|
||||
|
||||
return {
|
||||
currentQueueBoutNumber,
|
||||
currentBoutNumber: currentQueueBoutNumber,
|
||||
currentMatchId: matchId || null,
|
||||
liveMatchData: {},
|
||||
lastMatchResult: "",
|
||||
state: null,
|
||||
finished: false,
|
||||
timerBannerState: null,
|
||||
previousTimerSnapshot: {}
|
||||
}
|
||||
}
|
||||
|
||||
export function selectedBoutStorageKey(tournamentId, matId) {
|
||||
return `mat-selected-bout:${tournamentId}:${matId}`
|
||||
}
|
||||
|
||||
export function matchStorageKey(tournamentId, boutNumber) {
|
||||
if (!boutNumber) return null
|
||||
return buildStorageKey(tournamentId, boutNumber)
|
||||
}
|
||||
|
||||
export function extractLiveMatchData(data) {
|
||||
const extracted = {}
|
||||
if (data.w1_stat !== undefined) extracted.w1_stat = data.w1_stat
|
||||
if (data.w2_stat !== undefined) extracted.w2_stat = data.w2_stat
|
||||
if (data.score !== undefined) extracted.score = data.score
|
||||
if (data.win_type !== undefined) extracted.win_type = data.win_type
|
||||
if (data.winner_name !== undefined) extracted.winner_name = data.winner_name
|
||||
if (data.finished !== undefined) extracted.finished = data.finished
|
||||
return extracted
|
||||
}
|
||||
|
||||
export function applyStatePayloadContext(currentContext, payload) {
|
||||
return {
|
||||
...currentContext,
|
||||
state: payload,
|
||||
finished: Boolean(payload?.matchResult?.finished),
|
||||
currentBoutNumber: payload?.metadata?.boutNumber || currentContext.currentBoutNumber
|
||||
}
|
||||
}
|
||||
|
||||
export function applyMatchPayloadContext(currentContext, data) {
|
||||
const nextContext = { ...currentContext }
|
||||
|
||||
if (data.scoreboard_state) {
|
||||
Object.assign(nextContext, applyStatePayloadContext(nextContext, data.scoreboard_state))
|
||||
}
|
||||
|
||||
nextContext.liveMatchData = {
|
||||
...currentContext.liveMatchData,
|
||||
...extractLiveMatchData(data)
|
||||
}
|
||||
|
||||
if (data.finished !== undefined) {
|
||||
nextContext.finished = Boolean(data.finished)
|
||||
}
|
||||
|
||||
return nextContext
|
||||
}
|
||||
|
||||
export function applyMatPayloadContext(currentContext, data) {
|
||||
const currentQueueBoutNumber = data.queue1_bout_number || null
|
||||
const lastMatchResult = data.last_match_result || ""
|
||||
|
||||
if (currentContext.sourceMode === "localstorage") {
|
||||
return {
|
||||
...currentContext,
|
||||
currentQueueBoutNumber: data.selected_bout_number || currentQueueBoutNumber,
|
||||
lastMatchResult,
|
||||
loadSelectedBout: true,
|
||||
loadLocalState: true,
|
||||
unsubscribeMatch: false,
|
||||
subscribeMatchId: null,
|
||||
renderNow: true
|
||||
}
|
||||
}
|
||||
|
||||
const nextMatchId = data.selected_match_id || data.queue1_match_id || null
|
||||
const nextBoutNumber = data.selected_bout_number || data.queue1_bout_number || null
|
||||
const matchChanged = nextMatchId !== currentContext.currentMatchId
|
||||
|
||||
if (!nextMatchId) {
|
||||
return {
|
||||
...currentContext,
|
||||
currentQueueBoutNumber,
|
||||
lastMatchResult,
|
||||
currentMatchId: null,
|
||||
currentBoutNumber: nextBoutNumber,
|
||||
state: null,
|
||||
liveMatchData: {},
|
||||
resetTimerBanner: true,
|
||||
unsubscribeMatch: true,
|
||||
subscribeMatchId: null,
|
||||
renderNow: true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...currentContext,
|
||||
currentQueueBoutNumber,
|
||||
lastMatchResult,
|
||||
currentMatchId: nextMatchId,
|
||||
currentBoutNumber: nextBoutNumber,
|
||||
state: matchChanged ? null : currentContext.state,
|
||||
liveMatchData: matchChanged ? {} : currentContext.liveMatchData,
|
||||
resetTimerBanner: matchChanged,
|
||||
unsubscribeMatch: false,
|
||||
subscribeMatchId: matchChanged ? nextMatchId : null,
|
||||
renderNow: matchChanged
|
||||
}
|
||||
}
|
||||
|
||||
export function connectionPlan(sourceMode, currentMatchId) {
|
||||
return {
|
||||
useStorageListener: sourceMode === "localstorage",
|
||||
subscribeMat: sourceMode === "localstorage" || sourceMode === "mat_websocket",
|
||||
subscribeMatch: sourceMode === "mat_websocket" || sourceMode === "websocket",
|
||||
matchId: sourceMode === "mat_websocket" || sourceMode === "websocket" ? currentMatchId : null,
|
||||
loadSelectedBout: sourceMode === "localstorage",
|
||||
loadLocalState: sourceMode === "localstorage"
|
||||
}
|
||||
}
|
||||
|
||||
export function storageChangePlan(currentContext, eventKey, tournamentId, matId) {
|
||||
const selectedKey = selectedBoutStorageKey(tournamentId, matId)
|
||||
if (eventKey === selectedKey) {
|
||||
return {
|
||||
loadSelectedBout: true,
|
||||
loadLocalState: true,
|
||||
renderNow: true
|
||||
}
|
||||
}
|
||||
|
||||
const storageKey = matchStorageKey(tournamentId, currentContext.currentBoutNumber)
|
||||
if (!storageKey || eventKey !== storageKey) {
|
||||
return {
|
||||
loadSelectedBout: false,
|
||||
loadLocalState: false,
|
||||
renderNow: false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loadSelectedBout: false,
|
||||
loadLocalState: true,
|
||||
renderNow: true
|
||||
}
|
||||
}
|
||||
|
||||
export function selectedBoutNumber(selection, currentQueueBoutNumber) {
|
||||
return selection?.boutNumber || currentQueueBoutNumber
|
||||
}
|
||||
66
app/assets/javascripts/lib/match_state/serializers.js
Normal file
66
app/assets/javascripts/lib/match_state/serializers.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { buildInitialState } from "match-state-engine"
|
||||
|
||||
export function buildMatchMetadata(values) {
|
||||
return {
|
||||
tournamentId: values.tournamentId,
|
||||
boutNumber: values.boutNumber,
|
||||
weightLabel: values.weightLabel,
|
||||
ruleset: values.ruleset,
|
||||
bracketPosition: values.bracketPosition,
|
||||
w1Name: values.w1Name,
|
||||
w2Name: values.w2Name,
|
||||
w1School: values.w1School,
|
||||
w2School: values.w2School
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStorageKey(tournamentId, boutNumber) {
|
||||
return `match-state:${tournamentId}:${boutNumber}`
|
||||
}
|
||||
|
||||
export function buildPersistedState(state, metadata) {
|
||||
return {
|
||||
...state,
|
||||
metadata
|
||||
}
|
||||
}
|
||||
|
||||
export function restorePersistedState(config, parsedState) {
|
||||
const initialState = buildInitialState(config)
|
||||
|
||||
return {
|
||||
...initialState,
|
||||
...parsedState,
|
||||
participantScores: {
|
||||
...initialState.participantScores,
|
||||
...(parsedState.participantScores || {})
|
||||
},
|
||||
assignment: {
|
||||
...initialState.assignment,
|
||||
...(parsedState.assignment || {})
|
||||
},
|
||||
clock: {
|
||||
...initialState.clock,
|
||||
...(parsedState.clock || {})
|
||||
},
|
||||
timers: {
|
||||
w1: {
|
||||
...initialState.timers.w1,
|
||||
...(parsedState.timers?.w1 || {})
|
||||
},
|
||||
w2: {
|
||||
...initialState.timers.w2,
|
||||
...(parsedState.timers?.w2 || {})
|
||||
}
|
||||
},
|
||||
clocksByPhase: Object.fromEntries(
|
||||
Object.entries(initialState.clocksByPhase).map(([phaseKey, defaultClock]) => [
|
||||
phaseKey,
|
||||
{
|
||||
...defaultClock,
|
||||
...(parsedState.clocksByPhase?.[phaseKey] || {})
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
116
app/assets/javascripts/lib/match_state/transport.js
Normal file
116
app/assets/javascripts/lib/match_state/transport.js
Normal file
@@ -0,0 +1,116 @@
|
||||
export const MATCH_DATA_TTL_MS = 48 * 60 * 60 * 1000
|
||||
export const SHORT_LIVED_TTL_MS = 4 * 60 * 60 * 1000
|
||||
|
||||
const STORAGE_MARKER = "__wrestlingAppStorage"
|
||||
|
||||
export function loadJson(storage, key) {
|
||||
try {
|
||||
const rawValue = storage.getItem(key)
|
||||
if (!rawValue) return null
|
||||
const parsed = JSON.parse(rawValue)
|
||||
if (!isExpiringStorageValue(parsed)) return parsed
|
||||
if (isExpired(parsed)) {
|
||||
storage.removeItem(key)
|
||||
return null
|
||||
}
|
||||
return parsed.value
|
||||
} catch (_error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveJson(storage, key, value, options = {}) {
|
||||
try {
|
||||
const valueToStore = options.ttlMs
|
||||
? expiringStorageValue(value, options.ttlMs)
|
||||
: value
|
||||
storage.setItem(key, JSON.stringify(valueToStore))
|
||||
return true
|
||||
} catch (_error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function removeKey(storage, key) {
|
||||
try {
|
||||
storage.removeItem(key)
|
||||
return true
|
||||
} catch (_error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function performIfChanged(subscription, action, payload, lastSerializedPayload) {
|
||||
if (!subscription) return lastSerializedPayload
|
||||
|
||||
const serializedPayload = JSON.stringify(payload)
|
||||
if (serializedPayload === lastSerializedPayload) {
|
||||
return lastSerializedPayload
|
||||
}
|
||||
|
||||
subscription.perform(action, payload)
|
||||
return serializedPayload
|
||||
}
|
||||
|
||||
export function cleanupExpiredLocalStorage(storage, now = Date.now()) {
|
||||
try {
|
||||
const keys = []
|
||||
for (let index = 0; index < storage.length; index += 1) {
|
||||
const key = storage.key(index)
|
||||
if (key && ttlForStorageKey(key)) keys.push(key)
|
||||
}
|
||||
|
||||
keys.forEach((key) => cleanupStorageKey(storage, key, now))
|
||||
} catch (_error) {
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupStorageKey(storage, key, now) {
|
||||
const ttlMs = ttlForStorageKey(key)
|
||||
if (!ttlMs) return
|
||||
|
||||
const rawValue = storage.getItem(key)
|
||||
if (!rawValue) return
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue)
|
||||
if (isExpiringStorageValue(parsed)) {
|
||||
if (isExpired(parsed, now)) storage.removeItem(key)
|
||||
return
|
||||
}
|
||||
|
||||
const legacyUpdatedAt = Date.parse(parsed?.updated_at)
|
||||
if (legacyUpdatedAt && now - legacyUpdatedAt > ttlMs) {
|
||||
storage.removeItem(key)
|
||||
return
|
||||
}
|
||||
|
||||
storage.setItem(key, JSON.stringify(expiringStorageValue(parsed, ttlMs, legacyUpdatedAt || now)))
|
||||
} catch (_error) {
|
||||
storage.removeItem(key)
|
||||
}
|
||||
}
|
||||
|
||||
function expiringStorageValue(value, ttlMs, storedAt = Date.now()) {
|
||||
return {
|
||||
[STORAGE_MARKER]: true,
|
||||
expiresAt: storedAt + ttlMs,
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
function isExpiringStorageValue(value) {
|
||||
return value && typeof value === "object" && value[STORAGE_MARKER] === true
|
||||
}
|
||||
|
||||
function isExpired(value, now = Date.now()) {
|
||||
return Number(value.expiresAt) <= now
|
||||
}
|
||||
|
||||
function ttlForStorageKey(key) {
|
||||
if (key.startsWith("match-state:")) return MATCH_DATA_TTL_MS
|
||||
if (/^w[12]-\d+-\d+$/.test(key)) return MATCH_DATA_TTL_MS
|
||||
if (key.startsWith("mat-selected-bout:")) return SHORT_LIVED_TTL_MS
|
||||
if (key.startsWith("mat-last-match-result:")) return SHORT_LIVED_TTL_MS
|
||||
return null
|
||||
}
|
||||
15
app/channels/mat_scoreboard_channel.rb
Normal file
15
app/channels/mat_scoreboard_channel.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
class MatScoreboardChannel < ApplicationCable::Channel
|
||||
def subscribed
|
||||
@mat = Mat.find_by(id: params[:mat_id])
|
||||
return reject unless @mat
|
||||
|
||||
stream_for @mat
|
||||
transmit(scoreboard_payload(@mat))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scoreboard_payload(mat)
|
||||
mat.scoreboard_payload
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class MatchChannel < ApplicationCable::Channel
|
||||
SCOREBOARD_CACHE_TTL = 1.hours
|
||||
|
||||
def subscribed
|
||||
@match = Match.find_by(id: params[:match_id])
|
||||
Rails.logger.info "[MatchChannel] Client subscribed with match_id: #{params[:match_id]}. Match found: #{@match.present?}"
|
||||
@@ -11,6 +13,19 @@ class MatchChannel < ApplicationCable::Channel
|
||||
end
|
||||
end
|
||||
|
||||
def send_scoreboard(data)
|
||||
unless @match
|
||||
Rails.logger.error "[MatchChannel] Error: send_scoreboard called but @match is nil. Client params on sub: #{params[:match_id]}"
|
||||
return
|
||||
end
|
||||
|
||||
scoreboard_state = data["scoreboard_state"]
|
||||
return if scoreboard_state.blank?
|
||||
|
||||
Rails.cache.write(scoreboard_cache_key, scoreboard_state, expires_in: SCOREBOARD_CACHE_TTL)
|
||||
MatchChannel.broadcast_to(@match, { scoreboard_state: scoreboard_state })
|
||||
end
|
||||
|
||||
def unsubscribed
|
||||
Rails.logger.info "[MatchChannel] Client unsubscribed for match #{@match&.id}"
|
||||
end
|
||||
@@ -75,7 +90,8 @@ class MatchChannel < ApplicationCable::Channel
|
||||
win_type: @match.win_type,
|
||||
winner_name: @match.winner&.name,
|
||||
winner_id: @match.winner_id,
|
||||
finished: @match.finished
|
||||
finished: @match.finished,
|
||||
scoreboard_state: Rails.cache.read(scoreboard_cache_key)
|
||||
}.compact
|
||||
|
||||
if payload.present?
|
||||
@@ -85,4 +101,10 @@ class MatchChannel < ApplicationCable::Channel
|
||||
Rails.logger.info "[MatchChannel] request_sync payload empty for match #{@match.id}, not transmitting."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scoreboard_cache_key
|
||||
"tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,7 +4,7 @@ class MatAssignmentRulesController < ApplicationController
|
||||
before_action :set_mat_assignment_rule, only: [:edit, :update, :destroy]
|
||||
|
||||
def index
|
||||
@mat_assignment_rules = @tournament.mat_assignment_rules
|
||||
@mat_assignment_rules = @tournament.mat_assignment_rules.includes(:mat)
|
||||
@weights_by_id = @tournament.weights.index_by(&:id) # For quick lookup
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class MatchesController < ApplicationController
|
||||
before_action :set_match, only: [:show, :edit, :update, :stat, :spectate, :edit_assignment, :update_assignment]
|
||||
before_action :check_access, only: [:edit, :update, :stat, :edit_assignment, :update_assignment]
|
||||
before_action :set_match, only: [:show, :edit, :update, :stat, :state, :spectate, :edit_assignment, :update_assignment]
|
||||
before_action :check_access, only: [:edit, :update, :stat, :state, :edit_assignment, :update_assignment]
|
||||
before_action :check_read_access, only: [:spectate]
|
||||
|
||||
# GET /matches/1
|
||||
# GET /matches/1.json
|
||||
@@ -22,49 +23,12 @@ class MatchesController < ApplicationController
|
||||
end
|
||||
|
||||
def stat
|
||||
# @show_next_bout_button = false
|
||||
if params[:match]
|
||||
@match = Match.where(:id => params[:match]).includes(:wrestlers).first
|
||||
end
|
||||
@wrestlers = []
|
||||
if @match
|
||||
if @match.w1
|
||||
@wrestler1_name = @match.wrestler1.name
|
||||
@wrestler1_school_name = @match.wrestler1.school.name
|
||||
@wrestler1_last_match = @match.wrestler1.last_match
|
||||
@wrestlers.push(@match.wrestler1)
|
||||
else
|
||||
@wrestler1_name = "Not assigned"
|
||||
@wrestler1_school_name = "N/A"
|
||||
@wrestler1_last_match = nil
|
||||
end
|
||||
if @match.w2
|
||||
@wrestler2_name = @match.wrestler2.name
|
||||
@wrestler2_school_name = @match.wrestler2.school.name
|
||||
@wrestler2_last_match = @match.wrestler2.last_match
|
||||
@wrestlers.push(@match.wrestler2)
|
||||
else
|
||||
@wrestler2_name = "Not assigned"
|
||||
@wrestler2_school_name = "N/A"
|
||||
@wrestler2_last_match = nil
|
||||
end
|
||||
@tournament = @match.tournament
|
||||
end
|
||||
if @match&.mat
|
||||
@mat = @match.mat
|
||||
queue_position = @mat.queue_position_for_match(@match)
|
||||
@next_match = queue_position == 1 ? @mat.queue2_match : nil
|
||||
@show_next_bout_button = queue_position == 1
|
||||
if request.referer&.include?("/tournaments/#{@tournament.id}/matches")
|
||||
session[:return_path] = "/tournaments/#{@tournament.id}/matches"
|
||||
else
|
||||
session[:return_path] = mat_path(@mat)
|
||||
end
|
||||
session[:error_return_path] = "/matches/#{@match.id}/stat"
|
||||
else
|
||||
session[:return_path] = "/tournaments/#{@tournament.id}/matches"
|
||||
session[:error_return_path] = "/matches/#{@match.id}/stat"
|
||||
load_match_stat_context
|
||||
end
|
||||
|
||||
def state
|
||||
load_match_stat_context
|
||||
@match_state_ruleset = "folkstyle_usa"
|
||||
end
|
||||
|
||||
# GET /matches/:id/spectate
|
||||
@@ -142,26 +106,19 @@ class MatchesController < ApplicationController
|
||||
win_type: @match.win_type,
|
||||
winner_id: @match.winner_id,
|
||||
winner_name: @match.winner&.name,
|
||||
finished: @match.finished
|
||||
finished: @match.finished,
|
||||
scoreboard_state: Rails.cache.read("tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state")
|
||||
}
|
||||
)
|
||||
|
||||
if session[:return_path]
|
||||
sanitized_return_path = sanitize_return_path(session[:return_path])
|
||||
format.html { redirect_to sanitized_return_path, notice: 'Match was successfully updated.' }
|
||||
session.delete(:return_path) # Remove the session variable
|
||||
else
|
||||
format.html { redirect_to "/tournaments/#{@match.tournament.id}", notice: 'Match was successfully updated.' }
|
||||
end
|
||||
redirect_path = resolve_match_redirect_path(session[:return_path]) || "/tournaments/#{@match.tournament.id}"
|
||||
format.html { redirect_to redirect_path, notice: 'Match was successfully updated.' }
|
||||
session.delete(:return_path)
|
||||
format.json { head :no_content }
|
||||
else
|
||||
if session[:error_return_path]
|
||||
format.html { redirect_to session.delete(:error_return_path), alert: "Match did not save because: #{@match.errors.full_messages.to_s}" }
|
||||
error_path = resolve_match_redirect_path(session[:error_return_path]) || "/tournaments/#{@match.tournament.id}"
|
||||
format.html { redirect_to error_path, alert: "Match did not save because: #{@match.errors.full_messages.to_s}" }
|
||||
format.json { render json: @match.errors, status: :unprocessable_entity }
|
||||
else
|
||||
format.html { redirect_to "/tournaments/#{@match.tournament.id}", alert: "Match did not save because: #{@match.errors.full_messages.to_s}" }
|
||||
format.json { render json: @match.errors, status: :unprocessable_entity }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -182,11 +139,66 @@ class MatchesController < ApplicationController
|
||||
authorize! :manage, @match.tournament
|
||||
end
|
||||
|
||||
def sanitize_return_path(path)
|
||||
def check_read_access
|
||||
authorize! :read, @match.tournament
|
||||
end
|
||||
|
||||
def sanitize_redirect_path(path)
|
||||
return nil if path.blank?
|
||||
|
||||
uri = URI.parse(path)
|
||||
params = Rack::Utils.parse_nested_query(uri.query)
|
||||
params.delete("bout_number") # Remove the bout_number param
|
||||
uri.query = params.to_query.presence # Rebuild the query string or set it to nil if empty
|
||||
uri.to_s # Return the full path as a string
|
||||
return nil if uri.scheme.present? || uri.host.present?
|
||||
|
||||
uri.to_s
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def resolve_match_redirect_path(fallback_path)
|
||||
sanitize_redirect_path(params[:redirect_to].presence) || sanitize_redirect_path(fallback_path)
|
||||
end
|
||||
|
||||
def load_match_stat_context
|
||||
if params[:match]
|
||||
@match = Match.where(:id => params[:match]).includes(:wrestlers).first
|
||||
end
|
||||
|
||||
@wrestlers = []
|
||||
if @match
|
||||
if @match.w1
|
||||
@wrestler1_name = @match.wrestler1.name
|
||||
@wrestler1_school_name = @match.wrestler1.school.name
|
||||
@wrestler1_last_match = @match.wrestler1.last_match
|
||||
@wrestlers.push(@match.wrestler1)
|
||||
else
|
||||
@wrestler1_name = "Not assigned"
|
||||
@wrestler1_school_name = "N/A"
|
||||
@wrestler1_last_match = nil
|
||||
end
|
||||
|
||||
if @match.w2
|
||||
@wrestler2_name = @match.wrestler2.name
|
||||
@wrestler2_school_name = @match.wrestler2.school.name
|
||||
@wrestler2_last_match = @match.wrestler2.last_match
|
||||
@wrestlers.push(@match.wrestler2)
|
||||
else
|
||||
@wrestler2_name = "Not assigned"
|
||||
@wrestler2_school_name = "N/A"
|
||||
@wrestler2_last_match = nil
|
||||
end
|
||||
|
||||
@tournament = @match.tournament
|
||||
end
|
||||
|
||||
if @match&.mat
|
||||
@mat = @match.mat
|
||||
queue_position = @mat.queue_position_for_match(@match)
|
||||
@next_match = queue_position == 1 ? @mat.queue2_match : nil
|
||||
@show_next_bout_button = queue_position == 1
|
||||
end
|
||||
|
||||
@match_results_redirect_path = sanitize_redirect_path(params[:redirect_to].presence) || "/tournaments/#{@tournament.id}/matches"
|
||||
session[:return_path] = @match_results_redirect_path
|
||||
session[:error_return_path] = request.original_fullpath
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
class MatsController < ApplicationController
|
||||
before_action :set_mat, only: [:show, :edit, :update, :destroy, :assign_next_match]
|
||||
before_action :check_access, only: [:new,:create,:update,:destroy,:edit,:show, :assign_next_match]
|
||||
before_action :check_for_matches, only: [:show]
|
||||
before_action :set_mat, only: [:show, :state, :scoreboard, :edit, :update, :destroy, :assign_next_match, :select_match]
|
||||
before_action :check_access, only: [:new,:create,:update,:destroy,:edit,:show, :state, :scoreboard, :assign_next_match, :select_match]
|
||||
|
||||
# GET /mats/1
|
||||
# GET /mats/1.json
|
||||
def show
|
||||
bout_number_param = params[:bout_number] # Read the bout_number from the URL params
|
||||
|
||||
if bout_number_param
|
||||
@show_next_bout_button = false
|
||||
@match = @mat.queue_matches.compact.find { |m| m.bout_number == bout_number_param.to_i }
|
||||
bout_number_param = params[:bout_number]
|
||||
@queue_matches = @mat.queue_matches
|
||||
@match = if bout_number_param
|
||||
@queue_matches.compact.find { |m| m.bout_number == bout_number_param.to_i }
|
||||
else
|
||||
@show_next_bout_button = true
|
||||
@match = @mat.queue1_match
|
||||
@queue_matches[0]
|
||||
end
|
||||
|
||||
@next_match = @mat.queue2_match # Second match on the mat
|
||||
# If a requested bout is no longer queued, fall back to queue1.
|
||||
@match ||= @queue_matches[0]
|
||||
@next_match = @queue_matches[1]
|
||||
@show_next_bout_button = false
|
||||
|
||||
@wrestlers = []
|
||||
if @match
|
||||
@@ -45,10 +44,33 @@ class MatsController < ApplicationController
|
||||
@tournament = @match.tournament
|
||||
end
|
||||
|
||||
session[:return_path] = request.original_fullpath
|
||||
@match_results_redirect_path = sanitize_mat_redirect_path(params[:redirect_to].presence || request.original_fullpath)
|
||||
session[:return_path] = @match_results_redirect_path
|
||||
session[:error_return_path] = request.original_fullpath
|
||||
end
|
||||
|
||||
def scoreboard
|
||||
@match = @mat.selected_scoreboard_match || @mat.queue1_match
|
||||
@tournament = @mat.tournament
|
||||
end
|
||||
|
||||
def state
|
||||
load_mat_match_context
|
||||
@match_state_ruleset = "folkstyle_usa"
|
||||
end
|
||||
|
||||
def select_match
|
||||
selected_match = @mat.queue_matches.compact.find do |match|
|
||||
match.id == params[:match_id].to_i || match.bout_number == params[:bout_number].to_i
|
||||
end
|
||||
|
||||
return head :unprocessable_entity unless selected_match || params[:last_match_result].present?
|
||||
|
||||
@mat.set_selected_scoreboard_match!(selected_match) if selected_match
|
||||
@mat.set_last_match_result!(params[:last_match_result]) if params.key?(:last_match_result)
|
||||
head :no_content
|
||||
end
|
||||
|
||||
# GET /mats/new
|
||||
def new
|
||||
@mat = Mat.new
|
||||
@@ -141,12 +163,65 @@ class MatsController < ApplicationController
|
||||
authorize! :manage, @tournament
|
||||
end
|
||||
|
||||
def sanitize_mat_redirect_path(path)
|
||||
return nil if path.blank?
|
||||
|
||||
def check_for_matches
|
||||
if @mat
|
||||
if @mat.tournament.matches.empty?
|
||||
redirect_to "/tournaments/#{@tournament.id}/no_matches"
|
||||
uri = URI.parse(path)
|
||||
return nil if uri.scheme.present? || uri.host.present?
|
||||
|
||||
params = Rack::Utils.parse_nested_query(uri.query)
|
||||
params.delete("bout_number")
|
||||
uri.query = params.to_query.presence
|
||||
uri.to_s
|
||||
rescue URI::InvalidURIError
|
||||
nil
|
||||
end
|
||||
|
||||
def load_mat_match_context
|
||||
bout_number_param = params[:bout_number]
|
||||
@queue_matches = @mat.queue_matches
|
||||
@match = if bout_number_param
|
||||
@queue_matches.compact.find { |match| match.bout_number == bout_number_param.to_i }
|
||||
else
|
||||
@queue_matches[0]
|
||||
end
|
||||
@match ||= @queue_matches[0]
|
||||
@next_match = @queue_matches[1]
|
||||
@show_next_bout_button = false
|
||||
|
||||
@wrestlers = []
|
||||
if @match
|
||||
if @match.w1
|
||||
@wrestler1_name = @match.wrestler1.name
|
||||
@wrestler1_school_name = @match.wrestler1.school.name
|
||||
@wrestler1_last_match = @match.wrestler1.last_match
|
||||
@wrestlers.push(@match.wrestler1)
|
||||
else
|
||||
@wrestler1_name = "Not assigned"
|
||||
@wrestler1_school_name = "N/A"
|
||||
@wrestler1_last_match = nil
|
||||
end
|
||||
|
||||
if @match.w2
|
||||
@wrestler2_name = @match.wrestler2.name
|
||||
@wrestler2_school_name = @match.wrestler2.school.name
|
||||
@wrestler2_last_match = @match.wrestler2.last_match
|
||||
@wrestlers.push(@match.wrestler2)
|
||||
else
|
||||
@wrestler2_name = "Not assigned"
|
||||
@wrestler2_school_name = "N/A"
|
||||
@wrestler2_last_match = nil
|
||||
end
|
||||
|
||||
@tournament = @match.tournament
|
||||
else
|
||||
@tournament = @mat.tournament
|
||||
end
|
||||
|
||||
@match_results_redirect_path = sanitize_mat_redirect_path(params[:redirect_to].presence || request.original_fullpath)
|
||||
session[:return_path] = @match_results_redirect_path
|
||||
session[:error_return_path] = request.original_fullpath
|
||||
end
|
||||
|
||||
|
||||
end
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
class StaticPagesController < ApplicationController
|
||||
|
||||
def my_tournaments
|
||||
tournaments_created = current_user.tournaments
|
||||
tournaments_delegated = current_user.delegated_tournaments
|
||||
tournaments_created = current_user.tournaments.to_a
|
||||
tournaments_delegated = current_user.delegated_tournaments.to_a
|
||||
all_tournaments = tournaments_created + tournaments_delegated
|
||||
@tournaments = all_tournaments.sort_by{|t| t.days_until_start}
|
||||
@schools = current_user.delegated_schools
|
||||
@schools = current_user.delegated_schools.includes(:tournament)
|
||||
end
|
||||
|
||||
def not_allowed
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
class TournamentsController < ApplicationController
|
||||
before_action :set_tournament, only: [:all_results, :delete_school_keys, :generate_school_keys,:reset_bout_board,:calculate_team_scores,:bout_sheets,:swap,:weigh_in_sheet,:error,:teampointadjust,:remove_teampointadjust,:remove_school_delegate,:remove_delegate,:school_delegate,:delegate,:matches,:weigh_in,:weigh_in_weight,:create_custom_weights,:show,:edit,:update,:destroy,:up_matches,:no_matches,:team_scores,:generate_matches,:bracket,:all_brackets]
|
||||
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]
|
||||
before_action :set_tournament, only: [:all_results, :delete_school_keys, :generate_school_keys,:reset_bout_board,:calculate_team_scores,:bout_sheets,:swap,:weigh_in_sheet,:error,:teampointadjust,:remove_teampointadjust,:remove_school_delegate,:remove_delegate,:school_delegate,:delegate,:matches,:weigh_in,:weigh_in_weight,:create_custom_weights,:show,:edit,:update,:destroy,:up_matches,:no_matches,:team_scores,:generate_matches,:bracket,:all_brackets,:qrcode,:live_scores]
|
||||
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_access_read, 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,:live_scores]
|
||||
|
||||
def weigh_in_sheet
|
||||
|
||||
@schools = @tournament.schools.includes(wrestlers: :weight)
|
||||
end
|
||||
|
||||
def calculate_team_scores
|
||||
@@ -92,12 +92,9 @@ class TournamentsController < ApplicationController
|
||||
end
|
||||
end
|
||||
end
|
||||
@users_delegates = []
|
||||
@tournament.schools.each do |s|
|
||||
s.delegates.each do |d|
|
||||
@users_delegates << d
|
||||
end
|
||||
end
|
||||
@users_delegates = SchoolDelegate.includes(:user, :school)
|
||||
.joins(:school)
|
||||
.where(schools: { tournament_id: @tournament.id })
|
||||
end
|
||||
|
||||
def delegate
|
||||
@@ -115,11 +112,63 @@ class TournamentsController < ApplicationController
|
||||
end
|
||||
end
|
||||
end
|
||||
@users_delegates = @tournament.delegates
|
||||
@users_delegates = @tournament.delegates.includes(:user)
|
||||
end
|
||||
|
||||
def matches
|
||||
@matches = @tournament.matches.includes(:wrestlers,:schools).sort_by{|m| m.bout_number}
|
||||
per_page = 50
|
||||
@page = params[:page].to_i > 0 ? params[:page].to_i : 1
|
||||
offset = (@page - 1) * per_page
|
||||
matches_table = Match.arel_table
|
||||
|
||||
matches_scope = @tournament.matches.order(:bout_number)
|
||||
|
||||
if params[:search].present?
|
||||
wrestlers_table = Wrestler.arel_table
|
||||
schools_table = School.arel_table
|
||||
search_terms = params[:search].downcase.split
|
||||
|
||||
search_terms.each do |term|
|
||||
escaped_term = ActiveRecord::Base.sanitize_sql_like(term)
|
||||
pattern = "%#{escaped_term}%"
|
||||
|
||||
matching_wrestler_ids = Wrestler
|
||||
.joins(:weight)
|
||||
.left_outer_joins(:school)
|
||||
.where(weights: { tournament_id: @tournament.id })
|
||||
.where(
|
||||
wrestlers_table[:name].matches(pattern)
|
||||
.or(schools_table[:name].matches(pattern))
|
||||
)
|
||||
.distinct
|
||||
.select(:id)
|
||||
|
||||
term_scope = @tournament.matches.where(w1: matching_wrestler_ids)
|
||||
.or(@tournament.matches.where(w2: matching_wrestler_ids))
|
||||
|
||||
if term.match?(/\A\d+\z/)
|
||||
term_scope = term_scope.or(@tournament.matches.where(bout_number: term.to_i))
|
||||
end
|
||||
|
||||
matches_scope = matches_scope.where(id: term_scope.select(:id))
|
||||
end
|
||||
end
|
||||
|
||||
@total_count = matches_scope.count
|
||||
@total_pages = (@total_count / per_page.to_f).ceil
|
||||
@per_page = per_page
|
||||
|
||||
loser1_not_bye = matches_table[:loser1_name].not_eq("BYE").or(matches_table[:loser1_name].eq(nil))
|
||||
loser2_not_bye = matches_table[:loser2_name].not_eq("BYE").or(matches_table[:loser2_name].eq(nil))
|
||||
|
||||
non_bye_scope = matches_scope.where(loser1_not_bye).where(loser2_not_bye)
|
||||
@matches_without_byes_count = non_bye_scope.count
|
||||
@unfinished_matches_without_byes_count = non_bye_scope.where(finished: [nil, 0]).count
|
||||
|
||||
@matches = matches_scope
|
||||
.includes({ wrestler1: :school }, { wrestler2: :school }, { weight: :matches })
|
||||
.offset(offset)
|
||||
.limit(per_page)
|
||||
if @match
|
||||
@w1 = @match.wrestler1
|
||||
@w2 = @match.wrestler2
|
||||
@@ -129,10 +178,18 @@ class TournamentsController < ApplicationController
|
||||
|
||||
def weigh_in_weight
|
||||
if params[:wrestler]
|
||||
Wrestler.update(params[:wrestler].keys, params[:wrestler].values)
|
||||
sanitized_wrestlers = params.require(:wrestler).to_unsafe_h.each_with_object({}) do |(wrestler_id, attributes), result|
|
||||
permitted = ActionController::Parameters.new(attributes).permit(:offical_weight)
|
||||
result[wrestler_id] = permitted
|
||||
end
|
||||
Wrestler.update(sanitized_wrestlers.keys, sanitized_wrestlers.values) if sanitized_wrestlers.present?
|
||||
redirect_to "/tournaments/#{@tournament.id}/weigh_in/#{params[:weight]}", notice: "Weights were successfully recorded."
|
||||
return
|
||||
end
|
||||
if params[:weight]
|
||||
@weight = Weight.where(:id => params[:weight]).includes(:wrestlers).first
|
||||
@weight = Weight.where(id: params[:weight])
|
||||
.includes(wrestlers: [:school, :weight])
|
||||
.first
|
||||
@tournament_id = @tournament.id
|
||||
@tournament_name = @tournament.name
|
||||
@weights = @tournament.weights
|
||||
@@ -159,8 +216,11 @@ class TournamentsController < ApplicationController
|
||||
def all_brackets
|
||||
@schools = @tournament.schools
|
||||
@schools = @schools.sort_by{|s| s.page_score_string}.reverse!
|
||||
@matches = @tournament.matches.includes(:wrestlers,:schools)
|
||||
@weights = @tournament.weights.includes(:matches,:wrestlers)
|
||||
@weights = @tournament.weights.includes(:matches, wrestlers: :school)
|
||||
all_matches = @tournament.matches.includes(:weight, { wrestler1: :school }, { wrestler2: :school })
|
||||
all_wrestlers = @tournament.wrestlers.includes(:school, :weight)
|
||||
@matches_by_weight_id = all_matches.group_by(&:weight_id)
|
||||
@wrestlers_by_weight_id = all_wrestlers.group_by(&:weight_id)
|
||||
end
|
||||
|
||||
def bracket
|
||||
@@ -182,6 +242,10 @@ class TournamentsController < ApplicationController
|
||||
@bracket_position = nil
|
||||
end
|
||||
|
||||
def live_scores
|
||||
@mats = @tournament.mats.sort_by(&:name)
|
||||
end
|
||||
|
||||
def generate_matches
|
||||
GenerateTournamentMatches.new(@tournament).generate
|
||||
end
|
||||
@@ -196,27 +260,37 @@ class TournamentsController < ApplicationController
|
||||
|
||||
end
|
||||
|
||||
def qrcode
|
||||
@tournament_url = tournament_url(@tournament)
|
||||
@qrcode = RQRCode::QRCode.new(@tournament_url)
|
||||
end
|
||||
|
||||
|
||||
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(:wrestlers)
|
||||
@mats = @tournament.mats.includes(:matches)
|
||||
@matches = @tournament.up_matches_unassigned_matches
|
||||
@mats = @tournament.up_matches_mats
|
||||
end
|
||||
|
||||
def bout_sheets
|
||||
matches_scope = @tournament.matches
|
||||
.where("loser1_name != ? OR loser1_name IS NULL", "BYE")
|
||||
.where("loser2_name != ? OR loser2_name IS NULL", "BYE")
|
||||
|
||||
if params[:round]
|
||||
round = params[:round]
|
||||
if round != "All"
|
||||
@matches = @tournament.matches.where("round = ?",round).sort_by{|match| match.bout_number}
|
||||
@matches = matches_scope
|
||||
.where(round: round)
|
||||
.includes(:weight)
|
||||
.order(:bout_number)
|
||||
else
|
||||
@matches = @tournament.matches.sort_by{|match| match.bout_number}
|
||||
@matches = matches_scope
|
||||
.includes(:weight)
|
||||
.order(:bout_number)
|
||||
end
|
||||
|
||||
wrestler_ids = @matches.flat_map { |match| [match.w1, match.w2] }.compact.uniq
|
||||
@wrestlers_by_id = Wrestler.includes(:school).where(id: wrestler_ids).index_by(&:id)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
module ApplicationHelper
|
||||
def hide_ads?
|
||||
return false unless controller_name == "schools"
|
||||
return false unless %w[show edit new].include?(action_name)
|
||||
|
||||
user_signed_in? || school_permission_key_present?
|
||||
case controller_name
|
||||
when "schools"
|
||||
action_name == "show" && (user_signed_in? || school_permission_key_present?)
|
||||
when "wrestlers"
|
||||
%w[new edit].include?(action_name) && (user_signed_in? || school_permission_key_present?)
|
||||
when "mats"
|
||||
action_name == "show" && user_signed_in?
|
||||
else
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def school_permission_key_present?
|
||||
|
||||
@@ -7,6 +7,11 @@ class Mat < ApplicationRecord
|
||||
validates :name, presence: true
|
||||
|
||||
QUEUE_SLOTS = %w[queue1 queue2 queue3 queue4].freeze
|
||||
SCOREBOARD_SELECTION_CACHE_TTL = 1.hours
|
||||
LAST_MATCH_RESULT_CACHE_TTL = 1.hours
|
||||
|
||||
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
|
||||
@@ -86,8 +91,22 @@ class Mat < ApplicationRecord
|
||||
QUEUE_SLOTS.map { |slot| public_send(slot) }
|
||||
end
|
||||
|
||||
# used to prevent N+1 query on each mat
|
||||
def queue_matches
|
||||
queue_match_ids.map { |match_id| match_id ? Match.find_by(id: match_id) : nil }
|
||||
slot_ids = queue_match_ids
|
||||
if @queue_matches.nil? || @queue_match_slot_ids != slot_ids
|
||||
ids = slot_ids.compact
|
||||
@queue_matches = if ids.empty?
|
||||
[nil, nil, nil, nil]
|
||||
else
|
||||
matches_by_id = Match.where(id: ids)
|
||||
.includes({ wrestler1: :school }, { wrestler2: :school }, { weight: :matches })
|
||||
.index_by(&:id)
|
||||
slot_ids.map { |match_id| match_id ? matches_by_id[match_id] : nil }
|
||||
end
|
||||
@queue_match_slot_ids = slot_ids
|
||||
end
|
||||
@queue_matches
|
||||
end
|
||||
|
||||
def queue1_match
|
||||
@@ -167,17 +186,72 @@ class Mat < ApplicationRecord
|
||||
|
||||
def clear_queue!
|
||||
update!(queue1: nil, queue2: nil, queue3: nil, queue4: nil)
|
||||
broadcast_current_match
|
||||
end
|
||||
|
||||
def unfinished_matches
|
||||
matches.select{|m| m.finished != 1}.sort_by{|m| m.bout_number}
|
||||
end
|
||||
|
||||
def scoreboard_payload
|
||||
selected_match = selected_scoreboard_match
|
||||
{
|
||||
mat_id: id,
|
||||
queue1_bout_number: queue1_match&.bout_number,
|
||||
queue1_match_id: queue1_match&.id,
|
||||
selected_bout_number: selected_match&.bout_number,
|
||||
selected_match_id: selected_match&.id,
|
||||
last_match_result: last_match_result_text
|
||||
}
|
||||
end
|
||||
|
||||
def set_selected_scoreboard_match!(match)
|
||||
if match
|
||||
Rails.cache.write(
|
||||
scoreboard_selection_cache_key,
|
||||
{ match_id: match.id, bout_number: match.bout_number },
|
||||
expires_in: SCOREBOARD_SELECTION_CACHE_TTL
|
||||
)
|
||||
else
|
||||
Rails.cache.delete(scoreboard_selection_cache_key)
|
||||
end
|
||||
broadcast_current_match
|
||||
end
|
||||
|
||||
def selected_scoreboard_match
|
||||
selection = Rails.cache.read(scoreboard_selection_cache_key)
|
||||
return nil unless selection
|
||||
|
||||
match_id = selection[:match_id] || selection["match_id"]
|
||||
selected_match = queue_matches.compact.find { |match| match.id == match_id }
|
||||
return selected_match if selected_match
|
||||
|
||||
Rails.cache.delete(scoreboard_selection_cache_key)
|
||||
nil
|
||||
end
|
||||
|
||||
def set_last_match_result!(text)
|
||||
if text.present?
|
||||
Rails.cache.write(last_match_result_cache_key, text, expires_in: LAST_MATCH_RESULT_CACHE_TTL)
|
||||
else
|
||||
Rails.cache.delete(last_match_result_cache_key)
|
||||
end
|
||||
broadcast_current_match
|
||||
end
|
||||
|
||||
def last_match_result_text
|
||||
Rails.cache.read(last_match_result_cache_key)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def clear_queue_matches_cache
|
||||
@queue_matches = nil
|
||||
@queue_match_slot_ids = nil
|
||||
end
|
||||
|
||||
def queue_match_at(position)
|
||||
match_id = public_send("queue#{position}")
|
||||
match_id ? Match.find_by(id: match_id) : nil
|
||||
queue_matches[position - 1]
|
||||
end
|
||||
|
||||
def first_empty_queue_slot
|
||||
@@ -254,6 +328,23 @@ class Mat < ApplicationRecord
|
||||
show_next_bout_button: true
|
||||
}
|
||||
)
|
||||
MatScoreboardChannel.broadcast_to(self, scoreboard_payload)
|
||||
end
|
||||
|
||||
def scoreboard_selection_cache_key
|
||||
"tournament:#{tournament_id}:mat:#{id}:scoreboard_selection"
|
||||
end
|
||||
|
||||
def last_match_result_cache_key
|
||||
"tournament:#{tournament_id}:mat:#{id}:last_match_result"
|
||||
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
|
||||
|
||||
@@ -5,6 +5,8 @@ class Match < ApplicationRecord
|
||||
belongs_to :weight, touch: true
|
||||
belongs_to :mat, touch: true, optional: true
|
||||
belongs_to :winner, class_name: 'Wrestler', foreign_key: 'winner_id', optional: true
|
||||
belongs_to :wrestler1, class_name: 'Wrestler', foreign_key: 'w1', optional: true
|
||||
belongs_to :wrestler2, class_name: 'Wrestler', foreign_key: 'w2', optional: true
|
||||
has_many :wrestlers, :through => :weight
|
||||
has_many :schools, :through => :wrestlers
|
||||
validate :score_validation, :win_type_validation, :bracket_position_validation, :overtime_type_validation
|
||||
@@ -15,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
|
||||
@@ -178,14 +181,6 @@ class Match < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def wrestler1
|
||||
wrestlers.select{|w| w.id == self.w1}.first
|
||||
end
|
||||
|
||||
def wrestler2
|
||||
wrestlers.select{|w| w.id == self.w2}.first
|
||||
end
|
||||
|
||||
def w1_name
|
||||
if self.w1 != nil
|
||||
wrestler1.name
|
||||
@@ -203,7 +198,7 @@ class Match < ApplicationRecord
|
||||
end
|
||||
|
||||
def w1_bracket_name
|
||||
first_round = self.weight.matches.sort_by{|m| m.round}.first.round
|
||||
first_round = first_round_for_weight
|
||||
return_string = ""
|
||||
return_string_ending = ""
|
||||
if self.w1 and self.winner_id == self.w1
|
||||
@@ -223,7 +218,7 @@ class Match < ApplicationRecord
|
||||
end
|
||||
|
||||
def w2_bracket_name
|
||||
first_round = self.weight.matches.sort_by{|m| m.round}.first.round
|
||||
first_round = first_round_for_weight
|
||||
return_string = ""
|
||||
return_string_ending = ""
|
||||
if self.w2 and self.winner_id == self.w2
|
||||
@@ -289,6 +284,17 @@ class Match < ApplicationRecord
|
||||
self.weight.max
|
||||
end
|
||||
|
||||
def first_round_for_weight
|
||||
return @first_round_for_weight if defined?(@first_round_for_weight)
|
||||
|
||||
@first_round_for_weight =
|
||||
if association(:weight).loaded? && self.weight&.association(:matches)&.loaded?
|
||||
self.weight.matches.map(&:round).compact.min
|
||||
else
|
||||
Match.where(weight_id: self.weight_id).minimum(:round)
|
||||
end
|
||||
end
|
||||
|
||||
def replace_loser_name_with_wrestler(w,loser_name)
|
||||
if self.loser1_name == loser_name
|
||||
self.w1 = w.id
|
||||
@@ -366,4 +372,8 @@ class Match < ApplicationRecord
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def broadcast_up_matches_board
|
||||
Tournament.broadcast_up_matches_board(tournament_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,8 +69,35 @@ 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
|
||||
mats.each(&:clear_queue!)
|
||||
end
|
||||
|
||||
def matches_by_round(round)
|
||||
@@ -97,18 +124,11 @@ class Tournament < ApplicationRecord
|
||||
end
|
||||
|
||||
def pointAdjustments
|
||||
point_adjustments = []
|
||||
self.schools.each do |s|
|
||||
s.deductedPoints.each do |d|
|
||||
point_adjustments << d
|
||||
end
|
||||
end
|
||||
self.wrestlers.each do |w|
|
||||
w.deductedPoints.each do |d|
|
||||
point_adjustments << d
|
||||
end
|
||||
end
|
||||
point_adjustments
|
||||
school_scope = Teampointadjust.where(school_id: schools.select(:id))
|
||||
wrestler_scope = Teampointadjust.where(wrestler_id: wrestlers.select(:id))
|
||||
|
||||
Teampointadjust.includes(:school, :wrestler)
|
||||
.merge(school_scope.or(wrestler_scope))
|
||||
end
|
||||
|
||||
def remove_school_delegations
|
||||
|
||||
@@ -53,19 +53,16 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
def delegated_tournaments
|
||||
tournaments_delegated = []
|
||||
delegated_tournament_permissions.each do |t|
|
||||
tournaments_delegated << t.tournament
|
||||
end
|
||||
tournaments_delegated
|
||||
Tournament.joins(:delegates)
|
||||
.where(tournament_delegates: { user_id: id })
|
||||
.distinct
|
||||
end
|
||||
|
||||
def delegated_schools
|
||||
schools_delegated = []
|
||||
delegated_school_permissions.each do |t|
|
||||
schools_delegated << t.school
|
||||
end
|
||||
schools_delegated
|
||||
School.joins(:delegates)
|
||||
.where(school_delegates: { user_id: id })
|
||||
.includes(:tournament)
|
||||
.distinct
|
||||
end
|
||||
|
||||
def self.search(search)
|
||||
|
||||
@@ -156,7 +156,7 @@ class Weight < ApplicationRecord
|
||||
end
|
||||
|
||||
def calculate_bracket_size
|
||||
num_wrestlers = wrestlers.reload.size
|
||||
num_wrestlers = wrestlers.size
|
||||
return nil if num_wrestlers <= 0 # Handle invalid input
|
||||
|
||||
# Find the smallest power of 2 greater than or equal to num_wrestlers
|
||||
|
||||
@@ -12,21 +12,97 @@ class AdvanceWrestler
|
||||
end
|
||||
|
||||
def advance_raw
|
||||
@last_match.reload
|
||||
@wrestler.reload
|
||||
if @last_match && @last_match.finished?
|
||||
pool_to_bracket_advancement if @tournament.tournament_type == "Pool to bracket"
|
||||
ModifiedDoubleEliminationAdvance.new(@wrestler, @last_match).bracket_advancement if @tournament.tournament_type.include? "Modified 16 Man Double Elimination"
|
||||
DoubleEliminationAdvance.new(@wrestler, @last_match).bracket_advancement if @tournament.tournament_type.include? "Regular Double Elimination"
|
||||
@last_match = Match.find_by(id: @last_match&.id)
|
||||
@wrestler = Wrestler.includes(:school, :weight).find_by(id: @wrestler.id)
|
||||
return unless @last_match && @wrestler && @last_match.finished?
|
||||
|
||||
context = preload_advancement_context
|
||||
matches_to_advance = []
|
||||
|
||||
if @tournament.tournament_type == "Pool to bracket"
|
||||
matches_to_advance.concat(pool_to_bracket_advancement(context))
|
||||
elsif @tournament.tournament_type.include?("Modified 16 Man Double Elimination")
|
||||
service = ModifiedDoubleEliminationAdvance.new(@wrestler, @last_match, matches: context[:matches])
|
||||
service.bracket_advancement
|
||||
matches_to_advance.concat(service.matches_to_advance)
|
||||
elsif @tournament.tournament_type.include?("Regular Double Elimination")
|
||||
service = DoubleEliminationAdvance.new(@wrestler, @last_match, matches: context[:matches])
|
||||
service.bracket_advancement
|
||||
matches_to_advance.concat(service.matches_to_advance)
|
||||
end
|
||||
|
||||
persist_advancement_changes(context)
|
||||
advance_pending_matches(matches_to_advance)
|
||||
@wrestler.school.calculate_score
|
||||
end
|
||||
|
||||
def pool_to_bracket_advancement
|
||||
if @wrestler.weight.all_pool_matches_finished(@wrestler.pool) and (@wrestler.finished_bracket_matches.size < 1)
|
||||
PoolOrder.new(@wrestler.weight.wrestlers_in_pool(@wrestler.pool)).getPoolOrder
|
||||
def preload_advancement_context
|
||||
weight = Weight.includes(:matches, :wrestlers).find(@wrestler.weight_id)
|
||||
{
|
||||
weight: weight,
|
||||
matches: weight.matches.to_a,
|
||||
wrestlers: weight.wrestlers.to_a
|
||||
}
|
||||
end
|
||||
PoolAdvance.new(@wrestler).advanceWrestler
|
||||
|
||||
def persist_advancement_changes(context)
|
||||
persist_matches(context[:matches])
|
||||
persist_wrestlers(context[:wrestlers])
|
||||
end
|
||||
|
||||
def persist_matches(matches)
|
||||
timestamp = Time.current
|
||||
updates = matches.filter_map do |m|
|
||||
next unless m.changed?
|
||||
|
||||
{
|
||||
id: m.id,
|
||||
w1: m.w1,
|
||||
w2: m.w2,
|
||||
winner_id: m.winner_id,
|
||||
win_type: m.win_type,
|
||||
score: m.score,
|
||||
finished: m.finished,
|
||||
loser1_name: m.loser1_name,
|
||||
loser2_name: m.loser2_name,
|
||||
finished_at: m.finished_at,
|
||||
updated_at: timestamp
|
||||
}
|
||||
end
|
||||
Match.upsert_all(updates) if updates.any?
|
||||
end
|
||||
|
||||
def persist_wrestlers(wrestlers)
|
||||
timestamp = Time.current
|
||||
updates = wrestlers.filter_map do |w|
|
||||
next unless w.changed?
|
||||
|
||||
{
|
||||
id: w.id,
|
||||
pool_placement: w.pool_placement,
|
||||
pool_placement_tiebreaker: w.pool_placement_tiebreaker,
|
||||
updated_at: timestamp
|
||||
}
|
||||
end
|
||||
Wrestler.upsert_all(updates) if updates.any?
|
||||
end
|
||||
|
||||
def advance_pending_matches(matches_to_advance)
|
||||
matches_to_advance.uniq(&:id).each do |match|
|
||||
match.advance_wrestlers
|
||||
end
|
||||
end
|
||||
|
||||
def pool_to_bracket_advancement(context)
|
||||
matches_to_advance = []
|
||||
wrestlers_in_pool = context[:wrestlers].select { |w| w.pool == @wrestler.pool }
|
||||
if @wrestler.weight.all_pool_matches_finished(@wrestler.pool) && (@wrestler.finished_bracket_matches.size < 1)
|
||||
PoolOrder.new(wrestlers_in_pool).getPoolOrder
|
||||
end
|
||||
service = PoolAdvance.new(@wrestler, @last_match, matches: context[:matches], wrestlers: context[:wrestlers])
|
||||
service.advanceWrestler
|
||||
matches_to_advance.concat(service.matches_to_advance)
|
||||
matches_to_advance
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
class DoubleEliminationAdvance
|
||||
|
||||
def initialize(wrestler,last_match)
|
||||
attr_reader :matches_to_advance
|
||||
|
||||
def initialize(wrestler,last_match, matches: nil)
|
||||
@wrestler = wrestler
|
||||
@last_match = last_match
|
||||
@matches = matches || @wrestler.weight.matches.to_a
|
||||
@matches_to_advance = []
|
||||
@next_match_position_number = (@last_match.bracket_position_number / 2.0)
|
||||
end
|
||||
|
||||
@@ -48,7 +52,7 @@ class DoubleEliminationAdvance
|
||||
end
|
||||
|
||||
if next_match_bracket_position
|
||||
next_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?",next_match_bracket_position,next_match_position_number.ceil,@wrestler.weight_id).first
|
||||
next_match = @matches.find { |m| m.bracket_position == next_match_bracket_position && m.bracket_position_number == next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
|
||||
end
|
||||
|
||||
if next_match
|
||||
@@ -59,18 +63,16 @@ class DoubleEliminationAdvance
|
||||
def update_new_match(match, wrestler_number)
|
||||
if wrestler_number == 2 or (match.loser1_name and match.loser1_name.include? "Loser of")
|
||||
match.w2 = @wrestler.id
|
||||
match.save
|
||||
elsif wrestler_number == 1
|
||||
match.w1 = @wrestler.id
|
||||
match.save
|
||||
end
|
||||
end
|
||||
|
||||
def update_consolation_bye
|
||||
bout = @wrestler.last_match.bout_number
|
||||
next_match = Match.where("(loser1_name = ? OR loser2_name = ?) AND weight_id = ?","Loser of #{bout}","Loser of #{bout}",@wrestler.weight_id)
|
||||
if next_match.size > 0
|
||||
next_match.first.replace_loser_name_with_bye("Loser of #{bout}")
|
||||
next_match = @matches.find { |m| m.weight_id == @wrestler.weight_id && (m.loser1_name == "Loser of #{bout}" || m.loser2_name == "Loser of #{bout}") }
|
||||
if next_match
|
||||
replace_loser_name_with_bye(next_match, "Loser of #{bout}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -84,27 +86,18 @@ class DoubleEliminationAdvance
|
||||
|
||||
def losers_bracket_advancement
|
||||
bout = @last_match.bout_number
|
||||
next_match = Match.where("(loser1_name = ? OR loser2_name = ?) AND weight_id = ?", "Loser of #{bout}", "Loser of #{bout}", @wrestler.weight_id).first
|
||||
next_match = @matches.find { |m| m.weight_id == @wrestler.weight_id && (m.loser1_name == "Loser of #{bout}" || m.loser2_name == "Loser of #{bout}") }
|
||||
|
||||
if next_match.present?
|
||||
next_match.replace_loser_name_with_wrestler(@wrestler, "Loser of #{bout}")
|
||||
next_match.reload
|
||||
replace_loser_name_with_wrestler(next_match, @wrestler, "Loser of #{bout}")
|
||||
|
||||
if next_match.loser1_name == "BYE" || next_match.loser2_name == "BYE"
|
||||
next_match.winner_id = @wrestler.id
|
||||
next_match.win_type = "BYE"
|
||||
next_match.score = ""
|
||||
next_match.finished = 1
|
||||
# puts "Before save: winner_id=#{next_match.winner_id}"
|
||||
|
||||
# if next_match.save
|
||||
# puts "Save successful: winner_id=#{next_match.reload.winner_id}"
|
||||
# else
|
||||
# puts "Save failed: #{next_match.errors.full_messages}"
|
||||
# end
|
||||
next_match.save
|
||||
|
||||
next_match.advance_wrestlers
|
||||
next_match.finished_at = Time.current
|
||||
@matches_to_advance << next_match
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -112,51 +105,69 @@ class DoubleEliminationAdvance
|
||||
|
||||
def advance_double_byes
|
||||
weight = @wrestler.weight
|
||||
weight.matches.select{|m| m.loser1_name == "BYE" and m.loser2_name == "BYE" and m.finished != 1}.each do |match|
|
||||
@matches.select{|m| m.weight_id == weight.id && m.loser1_name == "BYE" and m.loser2_name == "BYE" and m.finished != 1}.each do |match|
|
||||
match.finished = 1
|
||||
match.finished_at = Time.current
|
||||
match.score = ""
|
||||
match.win_type = "BYE"
|
||||
next_match_position_number = (match.bracket_position_number / 2.0).ceil
|
||||
after_matches = weight.matches.select{|m| m.round > match.round and m.is_consolation_match == match.is_consolation_match }.sort_by{|m| m.round}
|
||||
next_matches = weight.matches.select{|m| m.round == after_matches.first.round and m.is_consolation_match == match.is_consolation_match }
|
||||
this_round_matches = weight.matches.select{|m| m.round == match.round and m.is_consolation_match == match.is_consolation_match }
|
||||
after_matches = @matches.select{|m| m.weight_id == weight.id && m.round > match.round and m.is_consolation_match == match.is_consolation_match }.sort_by{|m| m.round}
|
||||
next if after_matches.empty?
|
||||
next_matches = @matches.select{|m| m.weight_id == weight.id && m.round == after_matches.first.round and m.is_consolation_match == match.is_consolation_match }
|
||||
this_round_matches = @matches.select{|m| m.weight_id == weight.id && m.round == match.round and m.is_consolation_match == match.is_consolation_match }
|
||||
next_match = nil
|
||||
|
||||
if next_matches.size == this_round_matches.size
|
||||
next_match = next_matches.select{|m| m.bracket_position_number == match.bracket_position_number}.first
|
||||
next_match.loser2_name = "BYE"
|
||||
next_match.save
|
||||
next_match.loser2_name = "BYE" if next_match
|
||||
elsif next_matches.size < this_round_matches.size and next_matches.size > 0
|
||||
next_match = next_matches.select{|m| m.bracket_position_number == next_match_position_number}.first
|
||||
if next_match.bracket_position_number == next_match_position_number
|
||||
if next_match && next_match.bracket_position_number == next_match_position_number
|
||||
next_match.loser2_name = "BYE"
|
||||
else
|
||||
elsif next_match
|
||||
next_match.loser1_name = "BYE"
|
||||
end
|
||||
end
|
||||
next_match.save
|
||||
match.save
|
||||
end
|
||||
end
|
||||
|
||||
def set_bye_for_placement
|
||||
weight = @wrestler.weight
|
||||
fifth_finals = weight.matches.select{|match| match.bracket_position == '5/6'}.first
|
||||
seventh_finals = weight.matches.select{|match| match.bracket_position == '7/8'}.first
|
||||
fifth_finals = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == '5/6'}.first
|
||||
seventh_finals = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == '7/8'}.first
|
||||
if seventh_finals
|
||||
conso_quarter = weight.matches.select{|match| match.bracket_position == 'Conso Quarter'}
|
||||
conso_quarter = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == 'Conso Quarter'}
|
||||
conso_quarter.each do |match|
|
||||
if match.loser1_name == "BYE" or match.loser2_name == "BYE"
|
||||
seventh_finals.replace_loser_name_with_bye("Loser of #{match.bout_number}")
|
||||
replace_loser_name_with_bye(seventh_finals, "Loser of #{match.bout_number}")
|
||||
end
|
||||
end
|
||||
end
|
||||
if fifth_finals
|
||||
conso_semis = weight.matches.select{|match| match.bracket_position == 'Conso Semis'}
|
||||
conso_semis = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == 'Conso Semis'}
|
||||
conso_semis.each do |match|
|
||||
if match.loser1_name == "BYE" or match.loser2_name == "BYE"
|
||||
fifth_finals.replace_loser_name_with_bye("Loser of #{match.bout_number}")
|
||||
replace_loser_name_with_bye(fifth_finals, "Loser of #{match.bout_number}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def replace_loser_name_with_wrestler(match, wrestler, loser_name)
|
||||
if match.loser1_name == loser_name
|
||||
match.w1 = wrestler.id
|
||||
end
|
||||
if match.loser2_name == loser_name
|
||||
match.w2 = wrestler.id
|
||||
end
|
||||
end
|
||||
|
||||
def replace_loser_name_with_bye(match, loser_name)
|
||||
if match.loser1_name == loser_name
|
||||
match.loser1_name = "BYE"
|
||||
end
|
||||
if match.loser2_name == loser_name
|
||||
match.loser2_name = "BYE"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
class ModifiedDoubleEliminationAdvance
|
||||
|
||||
def initialize(wrestler,last_match)
|
||||
attr_reader :matches_to_advance
|
||||
|
||||
def initialize(wrestler,last_match, matches: nil)
|
||||
@wrestler = wrestler
|
||||
@last_match = last_match
|
||||
@matches = matches || @wrestler.weight.matches.to_a
|
||||
@matches_to_advance = []
|
||||
@next_match_position_number = (@last_match.bracket_position_number / 2.0)
|
||||
end
|
||||
|
||||
@@ -25,42 +29,41 @@ class ModifiedDoubleEliminationAdvance
|
||||
update_consolation_bye
|
||||
end
|
||||
if @last_match.bracket_position == "Quarter"
|
||||
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","Semis",@next_match_position_number.ceil,@wrestler.weight_id).first
|
||||
new_match = @matches.find { |m| m.bracket_position == "Semis" && m.bracket_position_number == @next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
|
||||
update_new_match(new_match, get_wrestler_number)
|
||||
elsif @last_match.bracket_position == "Semis"
|
||||
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","1/2",@next_match_position_number.ceil,@wrestler.weight_id).first
|
||||
new_match = @matches.find { |m| m.bracket_position == "1/2" && m.bracket_position_number == @next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
|
||||
update_new_match(new_match, get_wrestler_number)
|
||||
elsif @last_match.bracket_position == "Conso Semis"
|
||||
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","5/6",@next_match_position_number.ceil,@wrestler.weight_id).first
|
||||
new_match = @matches.find { |m| m.bracket_position == "5/6" && m.bracket_position_number == @next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
|
||||
update_new_match(new_match, get_wrestler_number)
|
||||
elsif @last_match.bracket_position == "Conso Quarter"
|
||||
# it's a special bracket where a semi loser is not dropping down
|
||||
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","Conso Semis",@next_match_position_number.ceil,@wrestler.weight_id).first
|
||||
new_match = @matches.find { |m| m.bracket_position == "Conso Semis" && m.bracket_position_number == @next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
|
||||
update_new_match(new_match, get_wrestler_number)
|
||||
elsif @last_match.bracket_position == "Bracket Round of 16"
|
||||
new_match = Match.where("bracket_position_number = ? and weight_id = ? and round > ? and bracket_position = ?", @next_match_position_number.ceil,@wrestler.weight_id, @last_match.round , "Quarter").sort_by{|m| m.round}.first
|
||||
new_match = @matches.select { |m| m.bracket_position_number == @next_match_position_number.ceil && m.weight_id == @wrestler.weight_id && m.round > @last_match.round && m.bracket_position == "Quarter" }.sort_by(&:round).first
|
||||
update_new_match(new_match, get_wrestler_number)
|
||||
elsif @last_match.bracket_position == "Conso Round of 8"
|
||||
new_match = Match.where("bracket_position_number = ? and weight_id = ? and round > ? and bracket_position = ?", @last_match.bracket_position_number,@wrestler.weight_id, @last_match.round, "Conso Quarter").sort_by{|m| m.round}.first
|
||||
new_match = @matches.select { |m| m.bracket_position_number == @last_match.bracket_position_number && m.weight_id == @wrestler.weight_id && m.round > @last_match.round && m.bracket_position == "Conso Quarter" }.sort_by(&:round).first
|
||||
update_new_match(new_match, get_wrestler_number)
|
||||
end
|
||||
end
|
||||
|
||||
def update_new_match(match, wrestler_number)
|
||||
return unless match
|
||||
if wrestler_number == 2 or (match.loser1_name and match.loser1_name.include? "Loser of")
|
||||
match.w2 = @wrestler.id
|
||||
match.save
|
||||
elsif wrestler_number == 1
|
||||
match.w1 = @wrestler.id
|
||||
match.save
|
||||
end
|
||||
end
|
||||
|
||||
def update_consolation_bye
|
||||
bout = @wrestler.last_match.bout_number
|
||||
next_match = Match.where("(loser1_name = ? OR loser2_name = ?) AND weight_id = ?","Loser of #{bout}","Loser of #{bout}",@wrestler.weight_id)
|
||||
if next_match.size > 0
|
||||
next_match.first.replace_loser_name_with_bye("Loser of #{bout}")
|
||||
next_match = @matches.find { |m| m.weight_id == @wrestler.weight_id && (m.loser1_name == "Loser of #{bout}" || m.loser2_name == "Loser of #{bout}") }
|
||||
if next_match
|
||||
replace_loser_name_with_bye(next_match, "Loser of #{bout}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -74,27 +77,18 @@ class ModifiedDoubleEliminationAdvance
|
||||
|
||||
def losers_bracket_advancement
|
||||
bout = @last_match.bout_number
|
||||
next_match = Match.where("(loser1_name = ? OR loser2_name = ?) AND weight_id = ?", "Loser of #{bout}", "Loser of #{bout}", @wrestler.weight_id).first
|
||||
next_match = @matches.find { |m| m.weight_id == @wrestler.weight_id && (m.loser1_name == "Loser of #{bout}" || m.loser2_name == "Loser of #{bout}") }
|
||||
|
||||
if next_match.present?
|
||||
next_match.replace_loser_name_with_wrestler(@wrestler, "Loser of #{bout}")
|
||||
next_match.reload
|
||||
replace_loser_name_with_wrestler(next_match, @wrestler, "Loser of #{bout}")
|
||||
|
||||
if next_match.loser1_name == "BYE" || next_match.loser2_name == "BYE"
|
||||
next_match.winner_id = @wrestler.id
|
||||
next_match.win_type = "BYE"
|
||||
next_match.score = ""
|
||||
next_match.finished = 1
|
||||
# puts "Before save: winner_id=#{next_match.winner_id}"
|
||||
|
||||
# if next_match.save
|
||||
# puts "Save successful: winner_id=#{next_match.reload.winner_id}"
|
||||
# else
|
||||
# puts "Save failed: #{next_match.errors.full_messages}"
|
||||
# end
|
||||
next_match.save
|
||||
|
||||
next_match.advance_wrestlers
|
||||
next_match.finished_at = Time.current
|
||||
@matches_to_advance << next_match
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -102,43 +96,53 @@ class ModifiedDoubleEliminationAdvance
|
||||
|
||||
def advance_double_byes
|
||||
weight = @wrestler.weight
|
||||
weight.matches.select{|m| m.loser1_name == "BYE" and m.loser2_name == "BYE" and m.finished != 1}.each do |match|
|
||||
@matches.select{|m| m.weight_id == weight.id && m.loser1_name == "BYE" and m.loser2_name == "BYE" and m.finished != 1}.each do |match|
|
||||
match.finished = 1
|
||||
match.finished_at = Time.current
|
||||
match.score = ""
|
||||
match.win_type = "BYE"
|
||||
next_match_position_number = (match.bracket_position_number / 2.0).ceil
|
||||
after_matches = weight.matches.select{|m| m.round > match.round and m.is_consolation_match == match.is_consolation_match }.sort_by{|m| m.round}
|
||||
next_matches = weight.matches.select{|m| m.round == after_matches.first.round and m.is_consolation_match == match.is_consolation_match }
|
||||
this_round_matches = weight.matches.select{|m| m.round == match.round and m.is_consolation_match == match.is_consolation_match }
|
||||
after_matches = @matches.select{|m| m.weight_id == weight.id && m.round > match.round and m.is_consolation_match == match.is_consolation_match }.sort_by{|m| m.round}
|
||||
next if after_matches.empty?
|
||||
next_matches = @matches.select{|m| m.weight_id == weight.id && m.round == after_matches.first.round and m.is_consolation_match == match.is_consolation_match }
|
||||
this_round_matches = @matches.select{|m| m.weight_id == weight.id && m.round == match.round and m.is_consolation_match == match.is_consolation_match }
|
||||
next_match = nil
|
||||
|
||||
if next_matches.size == this_round_matches.size
|
||||
next_match = next_matches.select{|m| m.bracket_position_number == match.bracket_position_number}.first
|
||||
next_match.loser2_name = "BYE"
|
||||
next_match.save
|
||||
next_match.loser2_name = "BYE" if next_match
|
||||
elsif next_matches.size < this_round_matches.size and next_matches.size > 0
|
||||
next_match = next_matches.select{|m| m.bracket_position_number == next_match_position_number}.first
|
||||
if next_match.bracket_position_number == next_match_position_number
|
||||
if next_match && next_match.bracket_position_number == next_match_position_number
|
||||
next_match.loser2_name = "BYE"
|
||||
else
|
||||
elsif next_match
|
||||
next_match.loser1_name = "BYE"
|
||||
end
|
||||
end
|
||||
next_match.save
|
||||
match.save
|
||||
end
|
||||
end
|
||||
|
||||
def set_bye_for_placement
|
||||
weight = @wrestler.weight
|
||||
seventh_finals = weight.matches.select{|match| match.bracket_position == '7/8'}.first
|
||||
seventh_finals = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == '7/8'}.first
|
||||
if seventh_finals
|
||||
conso_quarter = weight.matches.select{|match| match.bracket_position == 'Conso Semis'}
|
||||
conso_quarter = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == 'Conso Semis'}
|
||||
conso_quarter.each do |match|
|
||||
if match.loser1_name == "BYE" or match.loser2_name == "BYE"
|
||||
seventh_finals.replace_loser_name_with_bye("Loser of #{match.bout_number}")
|
||||
end
|
||||
replace_loser_name_with_bye(seventh_finals, "Loser of #{match.bout_number}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def replace_loser_name_with_wrestler(match, wrestler, loser_name)
|
||||
match.w1 = wrestler.id if match.loser1_name == loser_name
|
||||
match.w2 = wrestler.id if match.loser2_name == loser_name
|
||||
end
|
||||
|
||||
def replace_loser_name_with_bye(match, loser_name)
|
||||
match.loser1_name = "BYE" if match.loser1_name == loser_name
|
||||
match.loser2_name = "BYE" if match.loser2_name == loser_name
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
class PoolAdvance
|
||||
|
||||
def initialize(wrestler)
|
||||
attr_reader :matches_to_advance
|
||||
|
||||
def initialize(wrestler, last_match, matches: nil, wrestlers: nil)
|
||||
@wrestler = wrestler
|
||||
@last_match = @wrestler.last_match
|
||||
@last_match = last_match
|
||||
@matches = matches || @wrestler.weight.matches.to_a
|
||||
@wrestlers = wrestlers || @wrestler.weight.wrestlers.to_a
|
||||
@matches_to_advance = []
|
||||
end
|
||||
|
||||
def advanceWrestler
|
||||
@@ -17,15 +22,15 @@ class PoolAdvance
|
||||
def poolToBracketAdvancment
|
||||
pool = @wrestler.pool
|
||||
# This has to always run because the last match in a pool might not be a pool winner or runner up
|
||||
winner = Wrestler.where("weight_id = ? and pool_placement = 1 and pool = ?",@wrestler.weight.id, pool).first
|
||||
runner_up = Wrestler.where("weight_id = ? and pool_placement = 2 and pool = ?",@wrestler.weight.id, pool).first
|
||||
winner = @wrestlers.find { |w| w.weight_id == @wrestler.weight.id && w.pool_placement == 1 && w.pool == pool }
|
||||
runner_up = @wrestlers.find { |w| w.weight_id == @wrestler.weight.id && w.pool_placement == 2 && w.pool == pool }
|
||||
if runner_up
|
||||
runner_up_match = Match.where("weight_id = ? and (loser1_name = ? or loser2_name = ?)",@wrestler.weight.id, "Runner Up Pool #{pool}", "Runner Up Pool #{pool}").first
|
||||
runner_up_match.replace_loser_name_with_wrestler(runner_up,"Runner Up Pool #{pool}")
|
||||
runner_up_match = @matches.find { |m| m.weight_id == @wrestler.weight.id && (m.loser1_name == "Runner Up Pool #{pool}" || m.loser2_name == "Runner Up Pool #{pool}") }
|
||||
replace_loser_name_with_wrestler(runner_up_match, runner_up, "Runner Up Pool #{pool}") if runner_up_match
|
||||
end
|
||||
if winner
|
||||
winner_match = Match.where("weight_id = ? and (loser1_name = ? or loser2_name = ?)",@wrestler.weight.id, "Winner Pool #{pool}", "Winner Pool #{pool}").first
|
||||
winner_match.replace_loser_name_with_wrestler(winner,"Winner Pool #{pool}")
|
||||
winner_match = @matches.find { |m| m.weight_id == @wrestler.weight.id && (m.loser1_name == "Winner Pool #{pool}" || m.loser2_name == "Winner Pool #{pool}") }
|
||||
replace_loser_name_with_wrestler(winner_match, winner, "Winner Pool #{pool}") if winner_match
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,36 +50,40 @@ class PoolAdvance
|
||||
|
||||
def winner_advance
|
||||
if @wrestler.last_match.bracket_position == "Quarter"
|
||||
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","Semis",@wrestler.next_match_position_number.ceil,@wrestler.weight_id).first
|
||||
new_match = @matches.find { |m| m.bracket_position == "Semis" && m.bracket_position_number == @wrestler.next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
|
||||
updateNewMatch(new_match)
|
||||
end
|
||||
if @wrestler.last_match.bracket_position == "Semis"
|
||||
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","1/2",@wrestler.next_match_position_number.ceil,@wrestler.weight_id).first
|
||||
new_match = @matches.find { |m| m.bracket_position == "1/2" && m.bracket_position_number == @wrestler.next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
|
||||
updateNewMatch(new_match)
|
||||
end
|
||||
if @wrestler.last_match.bracket_position == "Conso Semis"
|
||||
new_match = Match.where("bracket_position = ? AND bracket_position_number = ? AND weight_id = ?","5/6",@wrestler.next_match_position_number.ceil,@wrestler.weight_id).first
|
||||
new_match = @matches.find { |m| m.bracket_position == "5/6" && m.bracket_position_number == @wrestler.next_match_position_number.ceil && m.weight_id == @wrestler.weight_id }
|
||||
updateNewMatch(new_match)
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
def updateNewMatch(match)
|
||||
return unless match
|
||||
if @wrestler.next_match_position_number == @wrestler.next_match_position_number.ceil
|
||||
match.w2 = @wrestler.id
|
||||
match.save
|
||||
end
|
||||
if @wrestler.next_match_position_number != @wrestler.next_match_position_number.ceil
|
||||
match.w1 = @wrestler.id
|
||||
match.save
|
||||
end
|
||||
end
|
||||
|
||||
def loser_advance
|
||||
bout = @wrestler.last_match.bout_number
|
||||
next_match = Match.where("(loser1_name = ? OR loser2_name = ?) AND weight_id = ?","Loser of #{bout}","Loser of #{bout}",@wrestler.weight_id)
|
||||
if next_match.size > 0
|
||||
next_match.first.replace_loser_name_with_wrestler(@wrestler,"Loser of #{bout}")
|
||||
next_match = @matches.find { |m| m.weight_id == @wrestler.weight_id && (m.loser1_name == "Loser of #{bout}" || m.loser2_name == "Loser of #{bout}") }
|
||||
if next_match
|
||||
replace_loser_name_with_wrestler(next_match, @wrestler, "Loser of #{bout}")
|
||||
end
|
||||
end
|
||||
|
||||
def replace_loser_name_with_wrestler(match, wrestler, loser_name)
|
||||
match.w1 = wrestler.id if match.loser1_name == loser_name
|
||||
match.w2 = wrestler.id if match.loser2_name == loser_name
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,8 +4,6 @@ class PoolOrder
|
||||
end
|
||||
|
||||
def getPoolOrder
|
||||
# clear caching for weight for bracket page
|
||||
@wrestlers.first.weight.touch
|
||||
setOriginalPoints
|
||||
while checkForTies(@wrestlers) == true
|
||||
getWrestlersOrderByPoolAdvancePoints.each do |wrestler|
|
||||
@@ -18,7 +16,6 @@ class PoolOrder
|
||||
getWrestlersOrderByPoolAdvancePoints.each_with_index do |wrestler, index|
|
||||
placement = index + 1
|
||||
wrestler.pool_placement = placement
|
||||
wrestler.save
|
||||
end
|
||||
@wrestlers.sort_by{|w| w.poolAdvancePoints}.reverse!
|
||||
end
|
||||
@@ -29,7 +26,6 @@ class PoolOrder
|
||||
|
||||
def setOriginalPoints
|
||||
@wrestlers.each do |w|
|
||||
matches = w.reload.all_matches
|
||||
w.pool_placement_tiebreaker = nil
|
||||
w.pool_placement = nil
|
||||
w.poolAdvancePoints = w.pool_wins.size
|
||||
|
||||
55
app/services/school_services/calculate_school_score.rb
Normal file
55
app/services/school_services/calculate_school_score.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
class CalculateSchoolScore
|
||||
def initialize(school)
|
||||
@school = school
|
||||
end
|
||||
|
||||
def calculate
|
||||
school = preload_school_context
|
||||
score = calculate_score_value(school)
|
||||
persist_school_score(school.id, score)
|
||||
score
|
||||
end
|
||||
|
||||
def calculate_value
|
||||
school = preload_school_context
|
||||
calculate_score_value(school)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def preload_school_context
|
||||
School
|
||||
.includes(
|
||||
:deductedPoints,
|
||||
wrestlers: [
|
||||
:deductedPoints,
|
||||
{ matches_as_w1: :winner },
|
||||
{ matches_as_w2: :winner },
|
||||
{ weight: [:matches, { tournament: { weights: :wrestlers } }] }
|
||||
]
|
||||
)
|
||||
.find(@school.id)
|
||||
end
|
||||
|
||||
def calculate_score_value(school)
|
||||
total_points_scored_by_wrestlers(school) - total_points_deducted(school)
|
||||
end
|
||||
|
||||
def total_points_scored_by_wrestlers(school)
|
||||
school.wrestlers.sum { |wrestler| CalculateWrestlerTeamScore.new(wrestler).totalScore }
|
||||
end
|
||||
|
||||
def total_points_deducted(school)
|
||||
school.deductedPoints.sum(&:points)
|
||||
end
|
||||
|
||||
def persist_school_score(school_id, score)
|
||||
School.upsert_all([
|
||||
{
|
||||
id: school_id,
|
||||
score: score,
|
||||
updated_at: Time.current
|
||||
}
|
||||
])
|
||||
end
|
||||
end
|
||||
@@ -3,33 +3,30 @@ class DoubleEliminationGenerateLoserNames
|
||||
@tournament = tournament
|
||||
end
|
||||
|
||||
# Entry point: assign loser placeholders and advance any byes
|
||||
def assign_loser_names
|
||||
# Compatibility wrapper. Returns transformed rows and does not persist.
|
||||
def assign_loser_names(match_rows = nil)
|
||||
rows = match_rows || @tournament.matches.where(tournament_id: @tournament.id).map { |m| m.attributes.symbolize_keys }
|
||||
@tournament.weights.each do |weight|
|
||||
# only assign loser names if there's conso matches to be had
|
||||
if weight.calculate_bracket_size > 2
|
||||
assign_loser_names_for_weight(weight)
|
||||
advance_bye_matches_championship(weight)
|
||||
advance_bye_matches_consolation(weight)
|
||||
end
|
||||
next unless weight.calculate_bracket_size > 2
|
||||
|
||||
assign_loser_names_in_memory(weight, rows)
|
||||
assign_bye_outcomes_in_memory(weight, rows)
|
||||
end
|
||||
rows
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Assign loser names for a single weight bracket
|
||||
def assign_loser_names_for_weight(weight)
|
||||
def assign_loser_names_in_memory(weight, match_rows)
|
||||
bracket_size = weight.calculate_bracket_size
|
||||
matches = weight.matches.reload
|
||||
return if bracket_size <= 2
|
||||
|
||||
rows = match_rows.select { |row| row[:weight_id] == weight.id }
|
||||
num_placers = @tournament.number_of_placers
|
||||
|
||||
# Build dynamic round definitions
|
||||
champ_rounds = dynamic_championship_rounds(bracket_size)
|
||||
conso_rounds = dynamic_consolation_rounds(bracket_size)
|
||||
first_round = { bracket_position: first_round_label(bracket_size) }
|
||||
champ_full = [first_round] + champ_rounds
|
||||
|
||||
# Map championship losers into consolation slots
|
||||
mappings = []
|
||||
champ_full[0...-1].each_with_index do |champ_info, i|
|
||||
map_idx = i.zero? ? 0 : (2 * i - 1)
|
||||
@@ -43,121 +40,102 @@ class DoubleEliminationGenerateLoserNames
|
||||
}
|
||||
end
|
||||
|
||||
# Apply loser-name mappings
|
||||
mappings.each do |map|
|
||||
champ = matches.select { |m| m.bracket_position == map[:championship_bracket_position] }
|
||||
.sort_by(&:bracket_position_number)
|
||||
conso = matches.select { |m| m.bracket_position == map[:consolation_bracket_position] }
|
||||
.sort_by(&:bracket_position_number)
|
||||
|
||||
current_champ_round_index = map[:champ_round_index]
|
||||
if current_champ_round_index.odd?
|
||||
conso.reverse!
|
||||
end
|
||||
champ = rows.select { |r| r[:bracket_position] == map[:championship_bracket_position] }
|
||||
.sort_by { |r| r[:bracket_position_number] }
|
||||
conso = rows.select { |r| r[:bracket_position] == map[:consolation_bracket_position] }
|
||||
.sort_by { |r| r[:bracket_position_number] }
|
||||
conso.reverse! if map[:champ_round_index].odd?
|
||||
|
||||
idx = 0
|
||||
# Determine if this mapping is for losers from the first championship round
|
||||
is_first_champ_round_feed = map[:champ_round_index].zero?
|
||||
|
||||
is_first_feed = map[:champ_round_index].zero?
|
||||
conso.each do |cm|
|
||||
champ_match1 = champ[idx]
|
||||
if champ_match1
|
||||
if is_first_champ_round_feed && ((champ_match1.w1 && champ_match1.w2.nil?) || (champ_match1.w1.nil? && champ_match1.w2))
|
||||
cm.loser1_name = "BYE"
|
||||
if is_first_feed && single_competitor_match_row?(champ_match1)
|
||||
cm[:loser1_name] = "BYE"
|
||||
else
|
||||
cm.loser1_name = "Loser of #{champ_match1.bout_number}"
|
||||
cm[:loser1_name] = "Loser of #{champ_match1[:bout_number]}"
|
||||
end
|
||||
else
|
||||
cm.loser1_name = nil # Should not happen if bracket generation is correct
|
||||
cm[:loser1_name] = nil
|
||||
end
|
||||
|
||||
if map[:both_wrestlers] # This is true only if is_first_champ_round_feed
|
||||
idx += 1 # Increment for the second championship match
|
||||
if map[:both_wrestlers]
|
||||
idx += 1
|
||||
champ_match2 = champ[idx]
|
||||
if champ_match2
|
||||
# BYE check is only relevant for the first championship round feed
|
||||
if is_first_champ_round_feed && ((champ_match2.w1 && champ_match2.w2.nil?) || (champ_match2.w1.nil? && champ_match2.w2))
|
||||
cm.loser2_name = "BYE"
|
||||
if is_first_feed && single_competitor_match_row?(champ_match2)
|
||||
cm[:loser2_name] = "BYE"
|
||||
else
|
||||
cm.loser2_name = "Loser of #{champ_match2.bout_number}"
|
||||
cm[:loser2_name] = "Loser of #{champ_match2[:bout_number]}"
|
||||
end
|
||||
else
|
||||
cm.loser2_name = nil # Should not happen
|
||||
cm[:loser2_name] = nil
|
||||
end
|
||||
end
|
||||
idx += 1 # Increment for the next consolation match or next pair from championship
|
||||
idx += 1
|
||||
end
|
||||
end
|
||||
|
||||
# 5th/6th place
|
||||
if bracket_size >= 5 && num_placers >= 6 && weight.wrestlers.size > 4
|
||||
conso_semis = matches.select { |m| m.bracket_position == "Conso Semis" }
|
||||
.sort_by(&:bracket_position_number)
|
||||
if conso_semis.size >= 2
|
||||
m56 = matches.find { |m| m.bracket_position == "5/6" }
|
||||
m56.loser1_name = "Loser of #{conso_semis[0].bout_number}"
|
||||
m56.loser2_name = "Loser of #{conso_semis[1].bout_number}" if m56
|
||||
conso_semis = rows.select { |r| r[:bracket_position] == "Conso Semis" }.sort_by { |r| r[:bracket_position_number] }
|
||||
m56 = rows.find { |r| r[:bracket_position] == "5/6" }
|
||||
if conso_semis.size >= 2 && m56
|
||||
m56[:loser1_name] = "Loser of #{conso_semis[0][:bout_number]}"
|
||||
m56[:loser2_name] = "Loser of #{conso_semis[1][:bout_number]}"
|
||||
end
|
||||
end
|
||||
|
||||
# 7th/8th place
|
||||
if bracket_size >= 7 && num_placers >= 8 && weight.wrestlers.size > 6
|
||||
conso_quarters = matches.select { |m| m.bracket_position == "Conso Quarter" }
|
||||
.sort_by(&:bracket_position_number)
|
||||
if conso_quarters.size >= 2
|
||||
m78 = matches.find { |m| m.bracket_position == "7/8" }
|
||||
m78.loser1_name = "Loser of #{conso_quarters[0].bout_number}"
|
||||
m78.loser2_name = "Loser of #{conso_quarters[1].bout_number}" if m78
|
||||
conso_quarters = rows.select { |r| r[:bracket_position] == "Conso Quarter" }.sort_by { |r| r[:bracket_position_number] }
|
||||
m78 = rows.find { |r| r[:bracket_position] == "7/8" }
|
||||
if conso_quarters.size >= 2 && m78
|
||||
m78[:loser1_name] = "Loser of #{conso_quarters[0][:bout_number]}"
|
||||
m78[:loser2_name] = "Loser of #{conso_quarters[1][:bout_number]}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
matches.each(&:save!)
|
||||
end
|
||||
|
||||
# Advance first-round byes in championship bracket
|
||||
def advance_bye_matches_championship(weight)
|
||||
matches = weight.matches.reload
|
||||
first_round = matches.map(&:round).min
|
||||
matches.select { |m| m.round == first_round }
|
||||
.sort_by(&:bracket_position_number)
|
||||
.each { |m| handle_bye(m) }
|
||||
end
|
||||
|
||||
# Advance first-round byes in consolation bracket
|
||||
def advance_bye_matches_consolation(weight)
|
||||
matches = weight.matches.reload
|
||||
def assign_bye_outcomes_in_memory(weight, match_rows)
|
||||
bracket_size = weight.calculate_bracket_size
|
||||
return if bracket_size <= 2
|
||||
|
||||
rows = match_rows.select { |r| r[:weight_id] == weight.id }
|
||||
first_round = rows.map { |r| r[:round] }.compact.min
|
||||
rows.select { |r| r[:round] == first_round }.each { |row| apply_bye_to_row(row) }
|
||||
|
||||
first_conso = dynamic_consolation_rounds(bracket_size).first
|
||||
|
||||
matches.select { |m| m.round == first_conso[:round] && m.bracket_position == first_conso[:bracket_position] }
|
||||
.sort_by(&:bracket_position_number)
|
||||
.each { |m| handle_bye(m) }
|
||||
if first_conso
|
||||
rows.select { |r| r[:round] == first_conso[:round] && r[:bracket_position] == first_conso[:bracket_position] }
|
||||
.each { |row| apply_bye_to_row(row) }
|
||||
end
|
||||
end
|
||||
|
||||
# Mark bye match, set finished, and advance
|
||||
def handle_bye(match)
|
||||
if [match.w1, match.w2].compact.size == 1
|
||||
match.finished = 1
|
||||
match.win_type = 'BYE'
|
||||
if match.w1
|
||||
match.winner_id = match.w1
|
||||
match.loser2_name = 'BYE'
|
||||
def apply_bye_to_row(row)
|
||||
return unless single_competitor_match_row?(row)
|
||||
|
||||
row[:finished] = 1
|
||||
row[:win_type] = "BYE"
|
||||
if row[:w1]
|
||||
row[:winner_id] = row[:w1]
|
||||
row[:loser2_name] = "BYE"
|
||||
else
|
||||
match.winner_id = match.w2
|
||||
match.loser1_name = 'BYE'
|
||||
end
|
||||
match.score = ''
|
||||
match.save!
|
||||
match.advance_wrestlers
|
||||
row[:winner_id] = row[:w2]
|
||||
row[:loser1_name] = "BYE"
|
||||
end
|
||||
row[:score] = ""
|
||||
end
|
||||
|
||||
def single_competitor_match_row?(row)
|
||||
[row[:w1], row[:w2]].compact.size == 1
|
||||
end
|
||||
|
||||
# Helpers for dynamic bracket labels
|
||||
def first_round_label(size)
|
||||
case size
|
||||
when 2 then 'Final'
|
||||
when 4 then 'Semis'
|
||||
when 8 then 'Quarter'
|
||||
when 2 then "Final"
|
||||
when 4 then "Semis"
|
||||
when 8 then "Quarter"
|
||||
else "Bracket Round of #{size}"
|
||||
end
|
||||
end
|
||||
@@ -186,9 +164,9 @@ class DoubleEliminationGenerateLoserNames
|
||||
|
||||
def bracket_label(participants)
|
||||
case participants
|
||||
when 2 then '1/2'
|
||||
when 4 then 'Semis'
|
||||
when 8 then 'Quarter'
|
||||
when 2 then "1/2"
|
||||
when 4 then "Semis"
|
||||
when 8 then "Quarter"
|
||||
else "Bracket Round of #{participants}"
|
||||
end
|
||||
end
|
||||
@@ -197,12 +175,12 @@ class DoubleEliminationGenerateLoserNames
|
||||
max_j_for_bracket = (2 * (Math.log2(bracket_size).to_i - 1) - 1)
|
||||
|
||||
if participants == 2 && j == max_j_for_bracket
|
||||
return '3/4'
|
||||
"3/4"
|
||||
elsif participants == 4
|
||||
return j.odd? ? 'Conso Quarter' : 'Conso Semis'
|
||||
j.odd? ? "Conso Quarter" : "Conso Semis"
|
||||
else
|
||||
suffix = j.odd? ? ".1" : ".2"
|
||||
return "Conso Round of #{participants}#{suffix}"
|
||||
"Conso Round of #{participants}#{suffix}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
class DoubleEliminationMatchGeneration
|
||||
def initialize(tournament)
|
||||
def initialize(tournament, weights: nil)
|
||||
@tournament = tournament
|
||||
@weights = weights
|
||||
end
|
||||
|
||||
def generate_matches
|
||||
#
|
||||
# PHASE 1: Generate matches (with local round definitions).
|
||||
#
|
||||
@tournament.weights.each do |weight|
|
||||
generate_matches_for_weight(weight)
|
||||
build_match_rows
|
||||
end
|
||||
|
||||
#
|
||||
# PHASE 2: Align all rounds to match the largest bracket’s definitions.
|
||||
#
|
||||
align_all_rounds_to_largest_bracket
|
||||
def build_match_rows
|
||||
rows_by_weight_id = {}
|
||||
|
||||
generation_weights.each do |weight|
|
||||
rows_by_weight_id[weight.id] = generate_match_rows_for_weight(weight)
|
||||
end
|
||||
|
||||
align_rows_to_largest_bracket(rows_by_weight_id)
|
||||
rows_by_weight_id.values.flatten
|
||||
end
|
||||
|
||||
###########################################################################
|
||||
# PHASE 1: Generate all matches for each bracket, using a single definition.
|
||||
###########################################################################
|
||||
def generate_matches_for_weight(weight)
|
||||
def generate_match_rows_for_weight(weight)
|
||||
bracket_size = weight.calculate_bracket_size
|
||||
bracket_info = define_bracket_matches(bracket_size)
|
||||
return unless bracket_info
|
||||
return [] unless bracket_info
|
||||
|
||||
rows = []
|
||||
|
||||
# 1) Round one matchups
|
||||
bracket_info[:round_one_matchups].each_with_index do |matchup, idx|
|
||||
@@ -32,7 +36,7 @@ class DoubleEliminationMatchGeneration
|
||||
bracket_pos_number = idx + 1
|
||||
round_number = matchup[:round]
|
||||
|
||||
create_matchup_from_seed(
|
||||
rows << create_matchup_from_seed(
|
||||
seed1,
|
||||
seed2,
|
||||
bracket_position,
|
||||
@@ -49,7 +53,7 @@ class DoubleEliminationMatchGeneration
|
||||
round_number = round_info[:round]
|
||||
|
||||
matches_this_round.times do |i|
|
||||
create_matchup(
|
||||
rows << create_matchup(
|
||||
nil,
|
||||
nil,
|
||||
bracket_position,
|
||||
@@ -67,7 +71,7 @@ class DoubleEliminationMatchGeneration
|
||||
round_number = round_info[:round]
|
||||
|
||||
matches_this_round.times do |i|
|
||||
create_matchup(
|
||||
rows << create_matchup(
|
||||
nil,
|
||||
nil,
|
||||
bracket_position,
|
||||
@@ -79,12 +83,14 @@ class DoubleEliminationMatchGeneration
|
||||
|
||||
# 5/6, 7/8 placing logic
|
||||
if weight.wrestlers.size >= 5 && @tournament.number_of_placers >= 6 && matches_this_round == 1
|
||||
create_matchup(nil, nil, "5/6", 1, round_number, weight)
|
||||
rows << create_matchup(nil, nil, "5/6", 1, round_number, weight)
|
||||
end
|
||||
if weight.wrestlers.size >= 7 && @tournament.number_of_placers >= 8 && matches_this_round == 1
|
||||
create_matchup(nil, nil, "7/8", 1, round_number, weight)
|
||||
rows << create_matchup(nil, nil, "7/8", 1, round_number, weight)
|
||||
end
|
||||
end
|
||||
|
||||
rows
|
||||
end
|
||||
|
||||
# Single bracket definition dynamically generated for any power-of-two bracket size.
|
||||
@@ -173,18 +179,18 @@ class DoubleEliminationMatchGeneration
|
||||
###########################################################################
|
||||
# PHASE 2: Overwrite rounds in all smaller brackets to match the largest one.
|
||||
###########################################################################
|
||||
def align_all_rounds_to_largest_bracket
|
||||
largest_weight = @tournament.weights.max_by { |w| w.calculate_bracket_size }
|
||||
def align_rows_to_largest_bracket(rows_by_weight_id)
|
||||
largest_weight = generation_weights.max_by { |w| w.calculate_bracket_size }
|
||||
return unless largest_weight
|
||||
|
||||
position_to_round = {}
|
||||
largest_weight.tournament.matches.where(weight_id: largest_weight.id).each do |m|
|
||||
position_to_round[m.bracket_position] ||= m.round
|
||||
rows_by_weight_id.fetch(largest_weight.id, []).each do |row|
|
||||
position_to_round[row[:bracket_position]] ||= row[:round]
|
||||
end
|
||||
|
||||
@tournament.matches.find_each do |match|
|
||||
if position_to_round.key?(match.bracket_position)
|
||||
match.update(round: position_to_round[match.bracket_position])
|
||||
rows_by_weight_id.each_value do |rows|
|
||||
rows.each do |row|
|
||||
row[:round] = position_to_round[row[:bracket_position]] if position_to_round.key?(row[:bracket_position])
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -192,8 +198,12 @@ class DoubleEliminationMatchGeneration
|
||||
###########################################################################
|
||||
# Helper methods
|
||||
###########################################################################
|
||||
def generation_weights
|
||||
@weights || @tournament.weights.to_a
|
||||
end
|
||||
|
||||
def wrestler_with_seed(seed, weight)
|
||||
Wrestler.where("weight_id = ? AND bracket_line = ?", weight.id, seed).first&.id
|
||||
weight.wrestlers.find { |w| w.bracket_line == seed }&.id
|
||||
end
|
||||
|
||||
def create_matchup_from_seed(w1_seed, w2_seed, bracket_position, bracket_position_number, round, weight)
|
||||
@@ -208,14 +218,15 @@ class DoubleEliminationMatchGeneration
|
||||
end
|
||||
|
||||
def create_matchup(w1, w2, bracket_position, bracket_position_number, round, weight)
|
||||
weight.tournament.matches.create!(
|
||||
{
|
||||
w1: w1,
|
||||
w2: w2,
|
||||
tournament_id: weight.tournament_id,
|
||||
weight_id: weight.id,
|
||||
round: round,
|
||||
bracket_position: bracket_position,
|
||||
bracket_position_number: bracket_position_number
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
# Calculates the sequence of seeds for the first round of a power-of-two bracket.
|
||||
|
||||
@@ -10,62 +10,183 @@ class GenerateTournamentMatches
|
||||
|
||||
def generate_raw
|
||||
standardStartingActions
|
||||
PoolToBracketMatchGeneration.new(@tournament).generatePoolToBracketMatches if @tournament.tournament_type == "Pool to bracket"
|
||||
ModifiedSixteenManMatchGeneration.new(@tournament).generate_matches if @tournament.tournament_type.include? "Modified 16 Man Double Elimination"
|
||||
DoubleEliminationMatchGeneration.new(@tournament).generate_matches if @tournament.tournament_type.include? "Regular Double Elimination"
|
||||
generation_context = preload_generation_context
|
||||
seed_wrestlers_in_memory(generation_context)
|
||||
match_rows = build_match_rows(generation_context)
|
||||
post_process_match_rows_in_memory(generation_context, match_rows)
|
||||
persist_generation_rows(generation_context, match_rows)
|
||||
postMatchCreationActions
|
||||
PoolToBracketMatchGeneration.new(@tournament).assignLoserNames if @tournament.tournament_type == "Pool to bracket"
|
||||
ModifiedSixteenManGenerateLoserNames.new(@tournament).assign_loser_names if @tournament.tournament_type.include? "Modified 16 Man Double Elimination"
|
||||
DoubleEliminationGenerateLoserNames.new(@tournament).assign_loser_names if @tournament.tournament_type.include? "Regular Double Elimination"
|
||||
advance_bye_matches_after_insert
|
||||
end
|
||||
|
||||
def standardStartingActions
|
||||
@tournament.curently_generating_matches = 1
|
||||
@tournament.save
|
||||
WipeTournamentMatches.new(@tournament).setUpMatchGeneration
|
||||
TournamentSeeding.new(@tournament).set_seeds
|
||||
end
|
||||
|
||||
def preload_generation_context
|
||||
weights = @tournament.weights.includes(:wrestlers).order(:max).to_a
|
||||
wrestlers = weights.flat_map(&:wrestlers)
|
||||
{
|
||||
weights: weights,
|
||||
wrestlers: wrestlers,
|
||||
wrestlers_by_weight_id: wrestlers.group_by(&:weight_id)
|
||||
}
|
||||
end
|
||||
|
||||
def seed_wrestlers_in_memory(generation_context)
|
||||
TournamentSeeding.new(@tournament).set_seeds(weights: generation_context[:weights], persist: false)
|
||||
end
|
||||
|
||||
def build_match_rows(generation_context)
|
||||
return PoolToBracketMatchGeneration.new(
|
||||
@tournament,
|
||||
weights: generation_context[:weights],
|
||||
wrestlers_by_weight_id: generation_context[:wrestlers_by_weight_id]
|
||||
).generatePoolToBracketMatches if @tournament.tournament_type == "Pool to bracket"
|
||||
|
||||
return ModifiedSixteenManMatchGeneration.new(@tournament, weights: generation_context[:weights]).generate_matches if @tournament.tournament_type.include? "Modified 16 Man Double Elimination"
|
||||
|
||||
return DoubleEliminationMatchGeneration.new(@tournament, weights: generation_context[:weights]).generate_matches if @tournament.tournament_type.include? "Regular Double Elimination"
|
||||
|
||||
[]
|
||||
end
|
||||
|
||||
def persist_generation_rows(generation_context, match_rows)
|
||||
persist_wrestlers(generation_context[:wrestlers])
|
||||
persist_matches(match_rows)
|
||||
end
|
||||
|
||||
def post_process_match_rows_in_memory(generation_context, match_rows)
|
||||
move_finals_rows_to_last_round(match_rows) unless @tournament.tournament_type.include?("Regular Double Elimination")
|
||||
assign_bouts_in_memory(match_rows, generation_context[:weights])
|
||||
assign_loser_names_in_memory(generation_context, match_rows)
|
||||
assign_bye_outcomes_in_memory(generation_context, match_rows)
|
||||
end
|
||||
|
||||
def persist_wrestlers(wrestlers)
|
||||
return if wrestlers.blank?
|
||||
|
||||
timestamp = Time.current
|
||||
rows = wrestlers.map do |w|
|
||||
{
|
||||
id: w.id,
|
||||
bracket_line: w.bracket_line,
|
||||
pool: w.pool,
|
||||
updated_at: timestamp
|
||||
}
|
||||
end
|
||||
Wrestler.upsert_all(rows)
|
||||
end
|
||||
|
||||
def persist_matches(match_rows)
|
||||
return if match_rows.blank?
|
||||
|
||||
timestamp = Time.current
|
||||
rows_with_timestamps = match_rows.map do |row|
|
||||
row.to_h.symbolize_keys.merge(created_at: timestamp, updated_at: timestamp)
|
||||
end
|
||||
|
||||
all_keys = rows_with_timestamps.flat_map(&:keys).uniq
|
||||
normalized_rows = rows_with_timestamps.map do |row|
|
||||
all_keys.each_with_object({}) { |key, normalized| normalized[key] = row[key] }
|
||||
end
|
||||
|
||||
Match.insert_all!(normalized_rows)
|
||||
end
|
||||
|
||||
def postMatchCreationActions
|
||||
moveFinalsMatchesToLastRound if ! @tournament.tournament_type.include? "Regular Double Elimination"
|
||||
assignBouts
|
||||
@tournament.reset_and_fill_bout_board
|
||||
@tournament.curently_generating_matches = nil
|
||||
@tournament.save!
|
||||
Tournament.broadcast_up_matches_board(@tournament.id)
|
||||
end
|
||||
|
||||
def move_finals_rows_to_last_round(match_rows)
|
||||
finals_round = match_rows.map { |row| row[:round] }.compact.max
|
||||
return unless finals_round
|
||||
|
||||
match_rows.each do |row|
|
||||
row[:round] = finals_round if ["1/2", "3/4", "5/6", "7/8"].include?(row[:bracket_position])
|
||||
end
|
||||
end
|
||||
|
||||
def assign_bouts_in_memory(match_rows, weights)
|
||||
bout_counts = Hash.new(0)
|
||||
weight_max_by_id = weights.each_with_object({}) { |w, memo| memo[w.id] = w.max }
|
||||
|
||||
match_rows
|
||||
.sort_by { |row| [row[:round].to_i, weight_max_by_id[row[:weight_id]].to_f, row[:weight_id].to_i, row[:bracket_position_number].to_i] }
|
||||
.each do |row|
|
||||
round = row[:round].to_i
|
||||
row[:bout_number] = round * 1000 + bout_counts[round]
|
||||
bout_counts[round] += 1
|
||||
end
|
||||
end
|
||||
|
||||
def assign_loser_names_in_memory(generation_context, match_rows)
|
||||
if @tournament.tournament_type == "Pool to bracket"
|
||||
service = PoolToBracketGenerateLoserNames.new(@tournament)
|
||||
generation_context[:weights].each { |weight| service.assign_loser_names_in_memory(weight, match_rows) }
|
||||
elsif @tournament.tournament_type.include?("Modified 16 Man Double Elimination")
|
||||
service = ModifiedSixteenManGenerateLoserNames.new(@tournament)
|
||||
generation_context[:weights].each { |weight| service.assign_loser_names_in_memory(weight, match_rows) }
|
||||
elsif @tournament.tournament_type.include?("Regular Double Elimination")
|
||||
service = DoubleEliminationGenerateLoserNames.new(@tournament)
|
||||
generation_context[:weights].each { |weight| service.assign_loser_names_in_memory(weight, match_rows) }
|
||||
end
|
||||
end
|
||||
|
||||
def assign_bye_outcomes_in_memory(generation_context, match_rows)
|
||||
if @tournament.tournament_type.include?("Modified 16 Man Double Elimination")
|
||||
service = ModifiedSixteenManGenerateLoserNames.new(@tournament)
|
||||
generation_context[:weights].each { |weight| service.assign_bye_outcomes_in_memory(weight, match_rows) }
|
||||
elsif @tournament.tournament_type.include?("Regular Double Elimination")
|
||||
service = DoubleEliminationGenerateLoserNames.new(@tournament)
|
||||
generation_context[:weights].each { |weight| service.assign_bye_outcomes_in_memory(weight, match_rows) }
|
||||
end
|
||||
end
|
||||
|
||||
def advance_bye_matches_after_insert
|
||||
Match.where(tournament_id: @tournament.id, finished: 1, win_type: "BYE")
|
||||
.where.not(winner_id: nil)
|
||||
.find_each(&:advance_wrestlers)
|
||||
end
|
||||
|
||||
def assignBouts
|
||||
bout_counts = Hash.new(0)
|
||||
@tournament.matches.reload
|
||||
@tournament.matches.sort_by{|m| [m.round, m.weight_max]}.each do |m|
|
||||
m.bout_number = m.round * 1000 + bout_counts[m.round]
|
||||
bout_counts[m.round] += 1
|
||||
m.save!
|
||||
timestamp = Time.current
|
||||
ordered_matches = Match.joins(:weight)
|
||||
.where(tournament_id: @tournament.id)
|
||||
.order("matches.round ASC, weights.max ASC, matches.id ASC")
|
||||
.pluck("matches.id", "matches.round")
|
||||
|
||||
updates = []
|
||||
ordered_matches.each do |match_id, round|
|
||||
updates << {
|
||||
id: match_id,
|
||||
bout_number: round * 1000 + bout_counts[round],
|
||||
updated_at: timestamp
|
||||
}
|
||||
bout_counts[round] += 1
|
||||
end
|
||||
|
||||
Match.upsert_all(updates) if updates.any?
|
||||
end
|
||||
|
||||
def moveFinalsMatchesToLastRound
|
||||
finalsRound = @tournament.reload.total_rounds
|
||||
finalsMatches = @tournament.matches.reload.select{|m| m.bracket_position == "1/2" || m.bracket_position == "3/4" || m.bracket_position == "5/6" || m.bracket_position == "7/8"}
|
||||
finalsMatches. each do |m|
|
||||
m.round = finalsRound
|
||||
m.save
|
||||
end
|
||||
@tournament.matches
|
||||
.where(bracket_position: ["1/2", "3/4", "5/6", "7/8"])
|
||||
.update_all(round: finalsRound, updated_at: Time.current)
|
||||
end
|
||||
|
||||
def unAssignMats
|
||||
matches = @tournament.matches.reload
|
||||
matches.each do |m|
|
||||
m.mat_id = nil
|
||||
m.save!
|
||||
end
|
||||
@tournament.matches.update_all(mat_id: nil, updated_at: Time.current)
|
||||
end
|
||||
|
||||
def unAssignBouts
|
||||
bout_counts = Hash.new(0)
|
||||
@tournament.matches.each do |m|
|
||||
m.bout_number = nil
|
||||
m.save!
|
||||
end
|
||||
@tournament.matches.update_all(bout_number: nil, updated_at: Time.current)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,93 +3,89 @@ class ModifiedSixteenManGenerateLoserNames
|
||||
@tournament = tournament
|
||||
end
|
||||
|
||||
def assign_loser_names
|
||||
matches_by_weight = nil
|
||||
@tournament.weights.each do |w|
|
||||
matches_by_weight = @tournament.matches.where(weight_id: w.id)
|
||||
conso_round_2(matches_by_weight)
|
||||
conso_round_3(matches_by_weight)
|
||||
third_fourth(matches_by_weight)
|
||||
seventh_eighth(matches_by_weight)
|
||||
save_matches(matches_by_weight)
|
||||
matches_by_weight = @tournament.matches.where(weight_id: w.id).reload
|
||||
advance_bye_matches_championship(matches_by_weight)
|
||||
save_matches(matches_by_weight)
|
||||
# Compatibility wrapper. Returns transformed rows and does not persist.
|
||||
def assign_loser_names(match_rows = nil)
|
||||
rows = match_rows || @tournament.matches.where(tournament_id: @tournament.id).map { |m| m.attributes.symbolize_keys }
|
||||
@tournament.weights.each do |weight|
|
||||
assign_loser_names_in_memory(weight, rows)
|
||||
assign_bye_outcomes_in_memory(weight, rows)
|
||||
end
|
||||
rows
|
||||
end
|
||||
|
||||
def assign_loser_names_in_memory(weight, match_rows)
|
||||
rows = match_rows.select { |row| row[:weight_id] == weight.id }
|
||||
round_16 = rows.select { |r| r[:bracket_position] == "Bracket Round of 16" }
|
||||
conso_8 = rows.select { |r| r[:bracket_position] == "Conso Round of 8" }.sort_by { |r| r[:bracket_position_number] }
|
||||
|
||||
conso_8.each do |row|
|
||||
if row[:bracket_position_number] == 1
|
||||
m1 = round_16.find { |m| m[:bracket_position_number] == 1 }
|
||||
m2 = round_16.find { |m| m[:bracket_position_number] == 2 }
|
||||
row[:loser1_name] = "Loser of #{m1[:bout_number]}" if m1
|
||||
row[:loser2_name] = "Loser of #{m2[:bout_number]}" if m2
|
||||
elsif row[:bracket_position_number] == 2
|
||||
m3 = round_16.find { |m| m[:bracket_position_number] == 3 }
|
||||
m4 = round_16.find { |m| m[:bracket_position_number] == 4 }
|
||||
row[:loser1_name] = "Loser of #{m3[:bout_number]}" if m3
|
||||
row[:loser2_name] = "Loser of #{m4[:bout_number]}" if m4
|
||||
elsif row[:bracket_position_number] == 3
|
||||
m5 = round_16.find { |m| m[:bracket_position_number] == 5 }
|
||||
m6 = round_16.find { |m| m[:bracket_position_number] == 6 }
|
||||
row[:loser1_name] = "Loser of #{m5[:bout_number]}" if m5
|
||||
row[:loser2_name] = "Loser of #{m6[:bout_number]}" if m6
|
||||
elsif row[:bracket_position_number] == 4
|
||||
m7 = round_16.find { |m| m[:bracket_position_number] == 7 }
|
||||
m8 = round_16.find { |m| m[:bracket_position_number] == 8 }
|
||||
row[:loser1_name] = "Loser of #{m7[:bout_number]}" if m7
|
||||
row[:loser2_name] = "Loser of #{m8[:bout_number]}" if m8
|
||||
end
|
||||
end
|
||||
|
||||
def conso_round_2(matches)
|
||||
matches.select{|m| m.bracket_position == "Conso Round of 8"}.sort_by{|m| m.bracket_position_number}.each do |match|
|
||||
if match.bracket_position_number == 1
|
||||
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 1 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
|
||||
match.loser2_name = "Loser of #{matches.select{|m| m.bracket_position_number == 2 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
|
||||
elsif match.bracket_position_number == 2
|
||||
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 3 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
|
||||
match.loser2_name = "Loser of #{matches.select{|m| m.bracket_position_number == 4 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
|
||||
elsif match.bracket_position_number == 3
|
||||
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 5 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
|
||||
match.loser2_name = "Loser of #{matches.select{|m| m.bracket_position_number == 6 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
|
||||
elsif match.bracket_position_number == 4
|
||||
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 7 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
|
||||
match.loser2_name = "Loser of #{matches.select{|m| m.bracket_position_number == 8 and m.bracket_position == "Bracket Round of 16"}.first.bout_number}"
|
||||
quarters = rows.select { |r| r[:bracket_position] == "Quarter" }
|
||||
conso_quarters = rows.select { |r| r[:bracket_position] == "Conso Quarter" }.sort_by { |r| r[:bracket_position_number] }
|
||||
conso_quarters.each do |row|
|
||||
source = case row[:bracket_position_number]
|
||||
when 1 then quarters.find { |q| q[:bracket_position_number] == 4 }
|
||||
when 2 then quarters.find { |q| q[:bracket_position_number] == 3 }
|
||||
when 3 then quarters.find { |q| q[:bracket_position_number] == 2 }
|
||||
when 4 then quarters.find { |q| q[:bracket_position_number] == 1 }
|
||||
end
|
||||
row[:loser1_name] = "Loser of #{source[:bout_number]}" if source
|
||||
end
|
||||
|
||||
semis = rows.select { |r| r[:bracket_position] == "Semis" }
|
||||
third_fourth = rows.find { |r| r[:bracket_position] == "3/4" }
|
||||
if third_fourth
|
||||
third_fourth[:loser1_name] = "Loser of #{semis.first[:bout_number]}" if semis.first
|
||||
third_fourth[:loser2_name] = "Loser of #{semis.second[:bout_number]}" if semis.second
|
||||
end
|
||||
|
||||
conso_semis = rows.select { |r| r[:bracket_position] == "Conso Semis" }
|
||||
seventh_eighth = rows.find { |r| r[:bracket_position] == "7/8" }
|
||||
if seventh_eighth
|
||||
seventh_eighth[:loser1_name] = "Loser of #{conso_semis.first[:bout_number]}" if conso_semis.first
|
||||
seventh_eighth[:loser2_name] = "Loser of #{conso_semis.second[:bout_number]}" if conso_semis.second
|
||||
end
|
||||
end
|
||||
|
||||
def conso_round_3(matches)
|
||||
matches.select{|m| m.bracket_position == "Conso Quarter"}.sort_by{|m| m.bracket_position_number}.each do |match|
|
||||
if match.bracket_position_number == 1
|
||||
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 4 and m.bracket_position == "Quarter"}.first.bout_number}"
|
||||
elsif match.bracket_position_number == 2
|
||||
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 3 and m.bracket_position == "Quarter"}.first.bout_number}"
|
||||
elsif match.bracket_position_number == 3
|
||||
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 2 and m.bracket_position == "Quarter"}.first.bout_number}"
|
||||
elsif match.bracket_position_number == 4
|
||||
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position_number == 1 and m.bracket_position == "Quarter"}.first.bout_number}"
|
||||
end
|
||||
end
|
||||
def assign_bye_outcomes_in_memory(weight, match_rows)
|
||||
rows = match_rows.select { |r| r[:weight_id] == weight.id && r[:bracket_position] == "Bracket Round of 16" }
|
||||
rows.each { |row| apply_bye_to_row(row) }
|
||||
end
|
||||
|
||||
def third_fourth(matches)
|
||||
matches.select{|m| m.bracket_position == "3/4"}.sort_by{|m| m.bracket_position_number}.each do |match|
|
||||
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position == "Semis"}.first.bout_number}"
|
||||
match.loser2_name = "Loser of #{matches.select{|m| m.bracket_position == "Semis"}.second.bout_number}"
|
||||
end
|
||||
end
|
||||
def apply_bye_to_row(row)
|
||||
return unless [row[:w1], row[:w2]].compact.size == 1
|
||||
|
||||
def seventh_eighth(matches)
|
||||
matches.select{|m| m.bracket_position == "7/8"}.sort_by{|m| m.bracket_position_number}.each do |match|
|
||||
match.loser1_name = "Loser of #{matches.select{|m| m.bracket_position == "Conso Semis"}.first.bout_number}"
|
||||
match.loser2_name = "Loser of #{matches.select{|m| m.bracket_position == "Conso Semis"}.second.bout_number}"
|
||||
row[:finished] = 1
|
||||
row[:win_type] = "BYE"
|
||||
if row[:w1]
|
||||
row[:winner_id] = row[:w1]
|
||||
row[:loser2_name] = "BYE"
|
||||
else
|
||||
row[:winner_id] = row[:w2]
|
||||
row[:loser1_name] = "BYE"
|
||||
end
|
||||
row[:score] = ""
|
||||
end
|
||||
end
|
||||
|
||||
def advance_bye_matches_championship(matches)
|
||||
matches.select{|m| m.bracket_position == "Bracket Round of 16"}.sort_by{|m| m.bracket_position_number}.each do |match|
|
||||
if match.w1 == nil or match.w2 == nil
|
||||
match.finished = 1
|
||||
match.win_type = "BYE"
|
||||
if match.w1 != nil
|
||||
match.winner_id = match.w1
|
||||
match.loser2_name = "BYE"
|
||||
match.score = ""
|
||||
match.save
|
||||
match.advance_wrestlers
|
||||
elsif match.w2 != nil
|
||||
match.winner_id = match.w2
|
||||
match.loser1_name = "BYE"
|
||||
match.score = ""
|
||||
match.save
|
||||
match.advance_wrestlers
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def save_matches(matches)
|
||||
matches.each do |m|
|
||||
m.save!
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
@@ -1,70 +1,75 @@
|
||||
class ModifiedSixteenManMatchGeneration
|
||||
def initialize( tournament )
|
||||
def initialize( tournament, weights: nil )
|
||||
@tournament = tournament
|
||||
@number_of_placers = @tournament.number_of_placers
|
||||
@weights = weights
|
||||
end
|
||||
|
||||
def generate_matches
|
||||
@tournament.weights.each do |weight|
|
||||
generate_matches_for_weight(weight)
|
||||
rows = []
|
||||
generation_weights.each do |weight|
|
||||
rows.concat(generate_match_rows_for_weight(weight))
|
||||
end
|
||||
rows
|
||||
end
|
||||
|
||||
def generate_matches_for_weight(weight)
|
||||
round_one(weight)
|
||||
round_two(weight)
|
||||
round_three(weight)
|
||||
round_four(weight)
|
||||
round_five(weight)
|
||||
def generate_match_rows_for_weight(weight)
|
||||
rows = []
|
||||
round_one(weight, rows)
|
||||
round_two(weight, rows)
|
||||
round_three(weight, rows)
|
||||
round_four(weight, rows)
|
||||
round_five(weight, rows)
|
||||
rows
|
||||
end
|
||||
|
||||
def round_one(weight)
|
||||
create_matchup_from_seed(1,16, "Bracket Round of 16", 1, 1,weight)
|
||||
create_matchup_from_seed(8,9, "Bracket Round of 16", 2, 1,weight)
|
||||
create_matchup_from_seed(5,12, "Bracket Round of 16", 3, 1,weight)
|
||||
create_matchup_from_seed(4,14, "Bracket Round of 16", 4, 1,weight)
|
||||
create_matchup_from_seed(3,13, "Bracket Round of 16", 5, 1,weight)
|
||||
create_matchup_from_seed(6,11, "Bracket Round of 16", 6, 1,weight)
|
||||
create_matchup_from_seed(7,10, "Bracket Round of 16", 7, 1,weight)
|
||||
create_matchup_from_seed(2,15, "Bracket Round of 16", 8, 1,weight)
|
||||
def round_one(weight, rows)
|
||||
rows << create_matchup_from_seed(1,16, "Bracket Round of 16", 1, 1,weight)
|
||||
rows << create_matchup_from_seed(8,9, "Bracket Round of 16", 2, 1,weight)
|
||||
rows << create_matchup_from_seed(5,12, "Bracket Round of 16", 3, 1,weight)
|
||||
rows << create_matchup_from_seed(4,14, "Bracket Round of 16", 4, 1,weight)
|
||||
rows << create_matchup_from_seed(3,13, "Bracket Round of 16", 5, 1,weight)
|
||||
rows << create_matchup_from_seed(6,11, "Bracket Round of 16", 6, 1,weight)
|
||||
rows << create_matchup_from_seed(7,10, "Bracket Round of 16", 7, 1,weight)
|
||||
rows << create_matchup_from_seed(2,15, "Bracket Round of 16", 8, 1,weight)
|
||||
end
|
||||
|
||||
def round_two(weight)
|
||||
create_matchup(nil,nil,"Quarter",1,2,weight)
|
||||
create_matchup(nil,nil,"Quarter",2,2,weight)
|
||||
create_matchup(nil,nil,"Quarter",3,2,weight)
|
||||
create_matchup(nil,nil,"Quarter",4,2,weight)
|
||||
create_matchup(nil,nil,"Conso Round of 8",1,2,weight)
|
||||
create_matchup(nil,nil,"Conso Round of 8",2,2,weight)
|
||||
create_matchup(nil,nil,"Conso Round of 8",3,2,weight)
|
||||
create_matchup(nil,nil,"Conso Round of 8",4,2,weight)
|
||||
def round_two(weight, rows)
|
||||
rows << create_matchup(nil,nil,"Quarter",1,2,weight)
|
||||
rows << create_matchup(nil,nil,"Quarter",2,2,weight)
|
||||
rows << create_matchup(nil,nil,"Quarter",3,2,weight)
|
||||
rows << create_matchup(nil,nil,"Quarter",4,2,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Round of 8",1,2,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Round of 8",2,2,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Round of 8",3,2,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Round of 8",4,2,weight)
|
||||
end
|
||||
|
||||
def round_three(weight)
|
||||
create_matchup(nil,nil,"Semis",1,3,weight)
|
||||
create_matchup(nil,nil,"Semis",2,3,weight)
|
||||
create_matchup(nil,nil,"Conso Quarter",1,3,weight)
|
||||
create_matchup(nil,nil,"Conso Quarter",2,3,weight)
|
||||
create_matchup(nil,nil,"Conso Quarter",3,3,weight)
|
||||
create_matchup(nil,nil,"Conso Quarter",4,3,weight)
|
||||
def round_three(weight, rows)
|
||||
rows << create_matchup(nil,nil,"Semis",1,3,weight)
|
||||
rows << create_matchup(nil,nil,"Semis",2,3,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Quarter",1,3,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Quarter",2,3,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Quarter",3,3,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Quarter",4,3,weight)
|
||||
end
|
||||
|
||||
def round_four(weight)
|
||||
create_matchup(nil,nil,"Conso Semis",1,4,weight)
|
||||
create_matchup(nil,nil,"Conso Semis",2,4,weight)
|
||||
def round_four(weight, rows)
|
||||
rows << create_matchup(nil,nil,"Conso Semis",1,4,weight)
|
||||
rows << create_matchup(nil,nil,"Conso Semis",2,4,weight)
|
||||
end
|
||||
|
||||
def round_five(weight)
|
||||
create_matchup(nil,nil,"1/2",1,5,weight)
|
||||
create_matchup(nil,nil,"3/4",1,5,weight)
|
||||
create_matchup(nil,nil,"5/6",1,5,weight)
|
||||
def round_five(weight, rows)
|
||||
rows << create_matchup(nil,nil,"1/2",1,5,weight)
|
||||
rows << create_matchup(nil,nil,"3/4",1,5,weight)
|
||||
rows << create_matchup(nil,nil,"5/6",1,5,weight)
|
||||
if @number_of_placers >= 8
|
||||
create_matchup(nil,nil,"7/8",1,5,weight)
|
||||
rows << create_matchup(nil,nil,"7/8",1,5,weight)
|
||||
end
|
||||
end
|
||||
|
||||
def wrestler_with_seed(seed,weight)
|
||||
wrestler = Wrestler.where("weight_id = ? and bracket_line = ?", weight.id, seed).first
|
||||
wrestler = weight.wrestlers.find { |w| w.bracket_line == seed }
|
||||
if wrestler
|
||||
return wrestler.id
|
||||
else
|
||||
@@ -79,13 +84,18 @@ class ModifiedSixteenManMatchGeneration
|
||||
end
|
||||
|
||||
def create_matchup(w1, w2, bracket_position, bracket_position_number,round,weight)
|
||||
@tournament.matches.create(
|
||||
{
|
||||
w1: w1,
|
||||
w2: w2,
|
||||
tournament_id: @tournament.id,
|
||||
weight_id: weight.id,
|
||||
round: round,
|
||||
bracket_position: bracket_position,
|
||||
bracket_position_number: bracket_position_number
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
def generation_weights
|
||||
@weights || @tournament.weights.to_a
|
||||
end
|
||||
end
|
||||
@@ -12,18 +12,19 @@ class PoolBracketGeneration
|
||||
end
|
||||
|
||||
def generateBracketMatches()
|
||||
@rows = []
|
||||
if @pool_bracket_type == "twoPoolsToSemi"
|
||||
return twoPoolsToSemi()
|
||||
twoPoolsToSemi()
|
||||
elsif @pool_bracket_type == "twoPoolsToFinal"
|
||||
return twoPoolsToFinal()
|
||||
twoPoolsToFinal()
|
||||
elsif @pool_bracket_type == "fourPoolsToQuarter"
|
||||
return fourPoolsToQuarter()
|
||||
fourPoolsToQuarter()
|
||||
elsif @pool_bracket_type == "fourPoolsToSemi"
|
||||
return fourPoolsToSemi()
|
||||
fourPoolsToSemi()
|
||||
elsif @pool_bracket_type == "eightPoolsToQuarter"
|
||||
return eightPoolsToQuarter()
|
||||
eightPoolsToQuarter()
|
||||
end
|
||||
return []
|
||||
@rows
|
||||
end
|
||||
|
||||
def twoPoolsToSemi()
|
||||
@@ -86,14 +87,15 @@ class PoolBracketGeneration
|
||||
end
|
||||
|
||||
def createMatchup(w1_name, w2_name, bracket_position, bracket_position_number)
|
||||
@tournament.matches.create(
|
||||
@rows << {
|
||||
loser1_name: w1_name,
|
||||
loser2_name: w2_name,
|
||||
tournament_id: @tournament.id,
|
||||
weight_id: @weight.id,
|
||||
round: @round,
|
||||
bracket_position: bracket_position,
|
||||
bracket_position_number: bracket_position_number
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -1,35 +1,46 @@
|
||||
class PoolGeneration
|
||||
def initialize(weight)
|
||||
def initialize(weight, wrestlers: nil)
|
||||
@weight = weight
|
||||
@tournament = @weight.tournament
|
||||
@pool = 1
|
||||
@wrestlers = wrestlers
|
||||
end
|
||||
|
||||
def generatePools
|
||||
GeneratePoolNumbers.new(@weight).savePoolNumbers
|
||||
GeneratePoolNumbers.new(@weight).savePoolNumbers(wrestlers: wrestlers_for_weight, persist: false)
|
||||
rows = []
|
||||
pools = @weight.pools
|
||||
while @pool <= pools
|
||||
roundRobin
|
||||
rows.concat(roundRobin)
|
||||
@pool += 1
|
||||
end
|
||||
rows
|
||||
end
|
||||
|
||||
def roundRobin
|
||||
wrestlers = @weight.wrestlers_in_pool(@pool)
|
||||
rows = []
|
||||
wrestlers = wrestlers_for_weight.select { |w| w.pool == @pool }
|
||||
pool_matches = RoundRobinTournament.schedule(wrestlers).reverse
|
||||
pool_matches.each_with_index do |b, index|
|
||||
round = index + 1
|
||||
bouts = b.map
|
||||
bouts.each do |bout|
|
||||
if bout[0] != nil and bout[1] != nil
|
||||
@tournament.matches.create(
|
||||
rows << {
|
||||
w1: bout[0].id,
|
||||
w2: bout[1].id,
|
||||
tournament_id: @tournament.id,
|
||||
weight_id: @weight.id,
|
||||
bracket_position: "Pool",
|
||||
round: round)
|
||||
round: round
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
rows
|
||||
end
|
||||
|
||||
def wrestlers_for_weight
|
||||
@wrestlers || @weight.wrestlers.to_a
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,78 +3,95 @@ class PoolToBracketGenerateLoserNames
|
||||
@tournament = tournament
|
||||
end
|
||||
|
||||
def assignLoserNamesWeight(weight)
|
||||
matches_by_weight = @tournament.matches.where(weight_id: weight.id)
|
||||
if weight.pool_bracket_type == "twoPoolsToSemi"
|
||||
twoPoolsToSemiLoser(matches_by_weight)
|
||||
elsif (weight.pool_bracket_type == "fourPoolsToQuarter") or (weight.pool_bracket_type == "eightPoolsToQuarter")
|
||||
fourPoolsToQuarterLoser(matches_by_weight)
|
||||
elsif weight.pool_bracket_type == "fourPoolsToSemi"
|
||||
fourPoolsToSemiLoser(matches_by_weight)
|
||||
end
|
||||
saveMatches(matches_by_weight)
|
||||
# Compatibility wrapper. Returns transformed rows and does not persist.
|
||||
def assignLoserNamesWeight(weight, match_rows = nil)
|
||||
rows = match_rows || @tournament.matches.where(weight_id: weight.id).map { |m| m.attributes.symbolize_keys }
|
||||
assign_loser_names_in_memory(weight, rows)
|
||||
rows
|
||||
end
|
||||
|
||||
# Compatibility wrapper. Returns transformed rows and does not persist.
|
||||
def assignLoserNames
|
||||
matches_by_weight = nil
|
||||
@tournament.weights.each do |w|
|
||||
matches_by_weight = @tournament.matches.where(weight_id: w.id)
|
||||
if w.pool_bracket_type == "twoPoolsToSemi"
|
||||
twoPoolsToSemiLoser(matches_by_weight)
|
||||
elsif (w.pool_bracket_type == "fourPoolsToQuarter") or (w.pool_bracket_type == "eightPoolsToQuarter")
|
||||
fourPoolsToQuarterLoser(matches_by_weight)
|
||||
elsif w.pool_bracket_type == "fourPoolsToSemi"
|
||||
fourPoolsToSemiLoser(matches_by_weight)
|
||||
end
|
||||
saveMatches(matches_by_weight)
|
||||
@tournament.weights.each_with_object([]) do |weight, all_rows|
|
||||
all_rows.concat(assignLoserNamesWeight(weight))
|
||||
end
|
||||
end
|
||||
|
||||
def twoPoolsToSemiLoser(matches_by_weight)
|
||||
match1 = matches_by_weight.select{|m| m.loser1_name == "Winner Pool 1"}.first
|
||||
match2 = matches_by_weight.select{|m| m.loser1_name == "Winner Pool 2"}.first
|
||||
matchChange = matches_by_weight.select{|m| m.bracket_position == "3/4"}.first
|
||||
matchChange.loser1_name = "Loser of #{match1.bout_number}"
|
||||
matchChange.loser2_name = "Loser of #{match2.bout_number}"
|
||||
end
|
||||
|
||||
def fourPoolsToQuarterLoser(matches_by_weight)
|
||||
quarters = matches_by_weight.select{|m| m.bracket_position == "Quarter"}
|
||||
consoSemis = matches_by_weight.select{|m| m.bracket_position == "Conso Semis"}
|
||||
semis = matches_by_weight.select{|m| m.bracket_position == "Semis"}
|
||||
thirdFourth = matches_by_weight.select{|m| m.bracket_position == "3/4"}.first
|
||||
seventhEighth = matches_by_weight.select{|m| m.bracket_position == "7/8"}.first
|
||||
consoSemis.each do |m|
|
||||
if m.bracket_position_number == 1
|
||||
m.loser1_name = "Loser of #{quarters.select{|m| m.bracket_position_number == 1}.first.bout_number}"
|
||||
m.loser2_name = "Loser of #{quarters.select{|m| m.bracket_position_number == 2}.first.bout_number}"
|
||||
elsif m.bracket_position_number == 2
|
||||
m.loser1_name = "Loser of #{quarters.select{|m| m.bracket_position_number == 3}.first.bout_number}"
|
||||
m.loser2_name = "Loser of #{quarters.select{|m| m.bracket_position_number == 4}.first.bout_number}"
|
||||
end
|
||||
end
|
||||
thirdFourth.loser1_name = "Loser of #{semis.select{|m| m.bracket_position_number == 1}.first.bout_number}"
|
||||
thirdFourth.loser2_name = "Loser of #{semis.select{|m| m.bracket_position_number == 2}.first.bout_number}"
|
||||
consoSemis = matches_by_weight.select{|m| m.bracket_position == "Conso Semis"}
|
||||
seventhEighth.loser1_name = "Loser of #{consoSemis.select{|m| m.bracket_position_number == 1}.first.bout_number}"
|
||||
seventhEighth.loser2_name = "Loser of #{consoSemis.select{|m| m.bracket_position_number == 2}.first.bout_number}"
|
||||
end
|
||||
|
||||
def fourPoolsToSemiLoser(matches_by_weight)
|
||||
semis = matches_by_weight.select{|m| m.bracket_position == "Semis"}
|
||||
thirdFourth = matches_by_weight.select{|m| m.bracket_position == "3/4"}.first
|
||||
consoSemis = matches_by_weight.select{|m| m.bracket_position == "Conso Semis"}
|
||||
seventhEighth = matches_by_weight.select{|m| m.bracket_position == "7/8"}.first
|
||||
thirdFourth.loser1_name = "Loser of #{semis.select{|m| m.bracket_position_number == 1}.first.bout_number}"
|
||||
thirdFourth.loser2_name = "Loser of #{semis.select{|m| m.bracket_position_number == 2}.first.bout_number}"
|
||||
seventhEighth.loser1_name = "Loser of #{consoSemis.select{|m| m.bracket_position_number == 1}.first.bout_number}"
|
||||
seventhEighth.loser2_name = "Loser of #{consoSemis.select{|m| m.bracket_position_number == 2}.first.bout_number}"
|
||||
end
|
||||
|
||||
def saveMatches(matches)
|
||||
matches.each do |m|
|
||||
m.save!
|
||||
def assign_loser_names_in_memory(weight, match_rows)
|
||||
rows = match_rows.select { |row| row[:weight_id] == weight.id }
|
||||
if weight.pool_bracket_type == "twoPoolsToSemi"
|
||||
two_pools_to_semi_loser_rows(rows)
|
||||
elsif (weight.pool_bracket_type == "fourPoolsToQuarter") || (weight.pool_bracket_type == "eightPoolsToQuarter")
|
||||
four_pools_to_quarter_loser_rows(rows)
|
||||
elsif weight.pool_bracket_type == "fourPoolsToSemi"
|
||||
four_pools_to_semi_loser_rows(rows)
|
||||
end
|
||||
end
|
||||
|
||||
def two_pools_to_semi_loser_rows(rows)
|
||||
match1 = rows.find { |m| m[:loser1_name] == "Winner Pool 1" }
|
||||
match2 = rows.find { |m| m[:loser1_name] == "Winner Pool 2" }
|
||||
match_change = rows.find { |m| m[:bracket_position] == "3/4" }
|
||||
return unless match1 && match2 && match_change
|
||||
|
||||
match_change[:loser1_name] = "Loser of #{match1[:bout_number]}"
|
||||
match_change[:loser2_name] = "Loser of #{match2[:bout_number]}"
|
||||
end
|
||||
|
||||
def four_pools_to_quarter_loser_rows(rows)
|
||||
quarters = rows.select { |m| m[:bracket_position] == "Quarter" }
|
||||
conso_semis = rows.select { |m| m[:bracket_position] == "Conso Semis" }
|
||||
semis = rows.select { |m| m[:bracket_position] == "Semis" }
|
||||
third_fourth = rows.find { |m| m[:bracket_position] == "3/4" }
|
||||
seventh_eighth = rows.find { |m| m[:bracket_position] == "7/8" }
|
||||
|
||||
conso_semis.each do |m|
|
||||
if m[:bracket_position_number] == 1
|
||||
q1 = quarters.find { |q| q[:bracket_position_number] == 1 }
|
||||
q2 = quarters.find { |q| q[:bracket_position_number] == 2 }
|
||||
m[:loser1_name] = "Loser of #{q1[:bout_number]}" if q1
|
||||
m[:loser2_name] = "Loser of #{q2[:bout_number]}" if q2
|
||||
elsif m[:bracket_position_number] == 2
|
||||
q3 = quarters.find { |q| q[:bracket_position_number] == 3 }
|
||||
q4 = quarters.find { |q| q[:bracket_position_number] == 4 }
|
||||
m[:loser1_name] = "Loser of #{q3[:bout_number]}" if q3
|
||||
m[:loser2_name] = "Loser of #{q4[:bout_number]}" if q4
|
||||
end
|
||||
end
|
||||
|
||||
if third_fourth
|
||||
s1 = semis.find { |s| s[:bracket_position_number] == 1 }
|
||||
s2 = semis.find { |s| s[:bracket_position_number] == 2 }
|
||||
third_fourth[:loser1_name] = "Loser of #{s1[:bout_number]}" if s1
|
||||
third_fourth[:loser2_name] = "Loser of #{s2[:bout_number]}" if s2
|
||||
end
|
||||
|
||||
if seventh_eighth
|
||||
c1 = conso_semis.find { |c| c[:bracket_position_number] == 1 }
|
||||
c2 = conso_semis.find { |c| c[:bracket_position_number] == 2 }
|
||||
seventh_eighth[:loser1_name] = "Loser of #{c1[:bout_number]}" if c1
|
||||
seventh_eighth[:loser2_name] = "Loser of #{c2[:bout_number]}" if c2
|
||||
end
|
||||
end
|
||||
|
||||
def four_pools_to_semi_loser_rows(rows)
|
||||
semis = rows.select { |m| m[:bracket_position] == "Semis" }
|
||||
conso_semis = rows.select { |m| m[:bracket_position] == "Conso Semis" }
|
||||
third_fourth = rows.find { |m| m[:bracket_position] == "3/4" }
|
||||
seventh_eighth = rows.find { |m| m[:bracket_position] == "7/8" }
|
||||
|
||||
if third_fourth
|
||||
s1 = semis.find { |s| s[:bracket_position_number] == 1 }
|
||||
s2 = semis.find { |s| s[:bracket_position_number] == 2 }
|
||||
third_fourth[:loser1_name] = "Loser of #{s1[:bout_number]}" if s1
|
||||
third_fourth[:loser2_name] = "Loser of #{s2[:bout_number]}" if s2
|
||||
end
|
||||
|
||||
if seventh_eighth
|
||||
c1 = conso_semis.find { |c| c[:bracket_position_number] == 1 }
|
||||
c2 = conso_semis.find { |c| c[:bracket_position_number] == 2 }
|
||||
seventh_eighth[:loser1_name] = "Loser of #{c1[:bout_number]}" if c1
|
||||
seventh_eighth[:loser2_name] = "Loser of #{c2[:bout_number]}" if c2
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,41 +1,89 @@
|
||||
class PoolToBracketMatchGeneration
|
||||
def initialize( tournament )
|
||||
def initialize(tournament, weights: nil, wrestlers_by_weight_id: nil)
|
||||
@tournament = tournament
|
||||
@weights = weights
|
||||
@wrestlers_by_weight_id = wrestlers_by_weight_id
|
||||
end
|
||||
|
||||
def generatePoolToBracketMatches
|
||||
@tournament.weights.order(:max).each do |weight|
|
||||
PoolGeneration.new(weight).generatePools()
|
||||
last_match = @tournament.matches.where(weight: weight).order(round: :desc).limit(1).first
|
||||
highest_round = last_match.round
|
||||
PoolBracketGeneration.new(weight, highest_round).generateBracketMatches()
|
||||
end
|
||||
movePoolSeedsToFinalPoolRound
|
||||
rows = []
|
||||
generation_weights.each do |weight|
|
||||
wrestlers = wrestlers_for_weight(weight)
|
||||
pool_rows = PoolGeneration.new(weight, wrestlers: wrestlers).generatePools
|
||||
rows.concat(pool_rows)
|
||||
|
||||
highest_round = pool_rows.map { |row| row[:round] }.max || 0
|
||||
bracket_rows = PoolBracketGeneration.new(weight, highest_round).generateBracketMatches
|
||||
rows.concat(bracket_rows)
|
||||
end
|
||||
|
||||
def movePoolSeedsToFinalPoolRound
|
||||
@tournament.weights.each do |w|
|
||||
setOriginalSeedsToWrestleLastPoolRound(w)
|
||||
movePoolSeedsToFinalPoolRound(rows)
|
||||
rows
|
||||
end
|
||||
|
||||
def movePoolSeedsToFinalPoolRound(match_rows)
|
||||
generation_weights.each do |w|
|
||||
setOriginalSeedsToWrestleLastPoolRound(w, match_rows)
|
||||
end
|
||||
end
|
||||
|
||||
def setOriginalSeedsToWrestleLastPoolRound(weight)
|
||||
def setOriginalSeedsToWrestleLastPoolRound(weight, match_rows)
|
||||
pool = 1
|
||||
until pool > weight.pools
|
||||
wrestler1 = weight.pool_wrestlers_sorted_by_bracket_line(pool).first
|
||||
wrestler2 = weight.pool_wrestlers_sorted_by_bracket_line(pool).second
|
||||
match = wrestler1.pool_matches.sort_by{|m| m.round}.last
|
||||
if match.w1 != wrestler2.id or match.w2 != wrestler2.id
|
||||
if match.w1 == wrestler1.id
|
||||
SwapWrestlers.new.swap_wrestlers_bracket_lines(match.w2,wrestler2.id)
|
||||
elsif match.w2 == wrestler1.id
|
||||
SwapWrestlers.new.swap_wrestlers_bracket_lines(match.w1,wrestler2.id)
|
||||
wrestlers = wrestlers_for_weight(weight)
|
||||
weight_pools = weight.pools
|
||||
until pool > weight_pools
|
||||
pool_wrestlers = wrestlers.select { |w| w.pool == pool }.sort_by(&:bracket_line)
|
||||
wrestler1 = pool_wrestlers.first
|
||||
wrestler2 = pool_wrestlers.second
|
||||
if wrestler1 && wrestler2
|
||||
pool_matches = match_rows.select { |row| row[:weight_id] == weight.id && row[:bracket_position] == "Pool" && (row[:w1] == wrestler1.id || row[:w2] == wrestler1.id) }
|
||||
match = pool_matches.max_by { |row| row[:round] }
|
||||
if match && (match[:w1] != wrestler2.id || match[:w2] != wrestler2.id)
|
||||
if match[:w1] == wrestler1.id
|
||||
swap_wrestlers_in_memory(match_rows, wrestlers, match[:w2], wrestler2.id)
|
||||
elsif match[:w2] == wrestler1.id
|
||||
swap_wrestlers_in_memory(match_rows, wrestlers, match[:w1], wrestler2.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
pool += 1
|
||||
end
|
||||
end
|
||||
|
||||
def swap_wrestlers_in_memory(match_rows, wrestlers, wrestler1_id, wrestler2_id)
|
||||
w1 = wrestlers.find { |w| w.id == wrestler1_id }
|
||||
w2 = wrestlers.find { |w| w.id == wrestler2_id }
|
||||
return unless w1 && w2
|
||||
|
||||
w1_bracket_line, w1_pool = w1.bracket_line, w1.pool
|
||||
w1.bracket_line, w1.pool = w2.bracket_line, w2.pool
|
||||
w2.bracket_line, w2.pool = w1_bracket_line, w1_pool
|
||||
|
||||
swap_match_rows(match_rows, wrestler1_id, wrestler2_id)
|
||||
end
|
||||
|
||||
def swap_match_rows(match_rows, wrestler1_id, wrestler2_id)
|
||||
match_rows.each do |row|
|
||||
row[:w1] = swap_id(row[:w1], wrestler1_id, wrestler2_id)
|
||||
row[:w2] = swap_id(row[:w2], wrestler1_id, wrestler2_id)
|
||||
row[:winner_id] = swap_id(row[:winner_id], wrestler1_id, wrestler2_id)
|
||||
end
|
||||
end
|
||||
|
||||
def swap_id(value, wrestler1_id, wrestler2_id)
|
||||
return wrestler2_id if value == wrestler1_id
|
||||
return wrestler1_id if value == wrestler2_id
|
||||
|
||||
value
|
||||
end
|
||||
|
||||
def generation_weights
|
||||
@weights || @tournament.weights.order(:max).to_a
|
||||
end
|
||||
|
||||
def wrestlers_for_weight(weight)
|
||||
@wrestlers_by_weight_id&.fetch(weight.id, nil) || weight.wrestlers.to_a
|
||||
end
|
||||
|
||||
def assignLoserNames
|
||||
PoolToBracketGenerateLoserNames.new(@tournament).assignLoserNames
|
||||
|
||||
@@ -3,16 +3,22 @@ class TournamentSeeding
|
||||
@tournament = tournament
|
||||
end
|
||||
|
||||
def set_seeds
|
||||
@tournament.weights.each do |weight|
|
||||
def set_seeds(weights: nil, persist: true)
|
||||
weights_to_seed = weights || @tournament.weights.includes(:wrestlers)
|
||||
updated_wrestlers = []
|
||||
|
||||
weights_to_seed.each do |weight|
|
||||
wrestlers = weight.wrestlers
|
||||
bracket_size = weight.calculate_bracket_size
|
||||
|
||||
wrestlers = reset_bracket_line_for_lines_higher_than_bracket_size(wrestlers, bracket_size)
|
||||
wrestlers = set_original_seed_to_bracket_line(wrestlers)
|
||||
wrestlers = random_seeding(wrestlers, bracket_size)
|
||||
wrestlers.each(&:save)
|
||||
updated_wrestlers.concat(wrestlers)
|
||||
end
|
||||
|
||||
persist_bracket_lines(updated_wrestlers) if persist
|
||||
updated_wrestlers
|
||||
end
|
||||
|
||||
def random_seeding(wrestlers, bracket_size)
|
||||
@@ -96,4 +102,19 @@ class TournamentSeeding
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def persist_bracket_lines(wrestlers)
|
||||
return if wrestlers.blank?
|
||||
|
||||
timestamp = Time.current
|
||||
updates = wrestlers.map do |wrestler|
|
||||
{
|
||||
id: wrestler.id,
|
||||
bracket_line: wrestler.bracket_line,
|
||||
updated_at: timestamp
|
||||
}
|
||||
end
|
||||
|
||||
Wrestler.upsert_all(updates)
|
||||
end
|
||||
end
|
||||
@@ -14,7 +14,7 @@ class WipeTournamentMatches
|
||||
end
|
||||
|
||||
def wipeMatches
|
||||
@tournament.matches.destroy_all
|
||||
@tournament.destroy_all_matches
|
||||
end
|
||||
|
||||
def resetSchoolScores
|
||||
|
||||
@@ -3,11 +3,13 @@ class GeneratePoolNumbers
|
||||
@weight = weight
|
||||
end
|
||||
|
||||
def savePoolNumbers
|
||||
@weight.wrestlers.each do |wrestler|
|
||||
def savePoolNumbers(wrestlers: nil, persist: true)
|
||||
wrestlers_to_update = wrestlers || @weight.wrestlers.to_a
|
||||
wrestlers_to_update.each do |wrestler|
|
||||
wrestler.pool = get_wrestler_pool_number(@weight.pools, wrestler.bracket_line)
|
||||
wrestler.save
|
||||
end
|
||||
persist_pool_numbers(wrestlers_to_update) if persist
|
||||
wrestlers_to_update
|
||||
end
|
||||
|
||||
def get_wrestler_pool_number(number_of_pools, wrestler_seed)
|
||||
@@ -36,4 +38,20 @@ class GeneratePoolNumbers
|
||||
|
||||
pool
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def persist_pool_numbers(wrestlers)
|
||||
return if wrestlers.blank?
|
||||
|
||||
timestamp = Time.current
|
||||
rows = wrestlers.map do |w|
|
||||
{
|
||||
id: w.id,
|
||||
pool: w.pool,
|
||||
updated_at: timestamp
|
||||
}
|
||||
end
|
||||
Wrestler.upsert_all(rows)
|
||||
end
|
||||
end
|
||||
@@ -54,29 +54,20 @@ class CalculateWrestlerTeamScore
|
||||
def byePoints
|
||||
points = 0
|
||||
if @tournament.tournament_type == "Pool to bracket"
|
||||
if @wrestler.pool_wins.size >= 1 and @wrestler.has_a_pool_bye == true
|
||||
if pool_bye_points_eligible?
|
||||
points += 2
|
||||
end
|
||||
end
|
||||
if @tournament.tournament_type.include? "Regular Double Elimination"
|
||||
if @wrestler.championship_advancement_wins.size > 0 or @wrestler.matches_won.select{|m| m.bracket_position == "1/2" and m.win_type != "BYE"}.size > 0
|
||||
# if they have a win in the championship round or if they got a bye all the way to finals and won the finals
|
||||
points += @wrestler.championship_byes.size * 2
|
||||
if @tournament.tournament_type.include? "Double Elimination"
|
||||
if @wrestler.championship_advancement_wins.any? &&
|
||||
@wrestler.championship_byes.any? &&
|
||||
any_bye_round_had_wrestled_match?(@wrestler.championship_byes)
|
||||
points += 2
|
||||
end
|
||||
if @wrestler.consolation_advancement_wins.size > 0 or @wrestler.matches_won.select{|m| m.bracket_position == "3/4" and m.win_type != "BYE"}.size > 0
|
||||
# if they have a win in the consolation round or if they got a bye all the way to 3rd/4th match and won
|
||||
points += @wrestler.consolation_byes.size * 1
|
||||
end
|
||||
end
|
||||
if @tournament.tournament_type.include? "Modified 16 Man Double Elimination"
|
||||
if @wrestler.championship_advancement_wins.size > 0 or @wrestler.matches_won.select{|m| m.bracket_position == "1/2" and m.win_type != "BYE"}.size > 0
|
||||
# if they have a win in the championship round or if they got a bye all the way to finals and won the finals
|
||||
points += @wrestler.championship_byes.size * 2
|
||||
end
|
||||
if @wrestler.consolation_advancement_wins.size > 0 or @wrestler.matches_won.select{|m| m.bracket_position == "5/6" and m.win_type != "BYE"}.size > 0
|
||||
# if they have a win in the consolation round or if they got a bye all the way to 5th/6th match and won
|
||||
# since the consolation bracket goes to 5/6 in a modified tournament
|
||||
points += @wrestler.consolation_byes.size * 1
|
||||
if @wrestler.consolation_advancement_wins.any? &&
|
||||
@wrestler.consolation_byes.any? &&
|
||||
any_bye_round_had_wrestled_match?(@wrestler.consolation_byes)
|
||||
points += 1
|
||||
end
|
||||
end
|
||||
return points
|
||||
@@ -86,4 +77,30 @@ class CalculateWrestlerTeamScore
|
||||
(@wrestler.pin_wins.size * 2) + (@wrestler.tech_wins.size * 1.5) + (@wrestler.major_wins.size * 1)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pool_bye_points_eligible?
|
||||
return false unless @wrestler.pool_wins.size >= 1
|
||||
return false unless @wrestler.weight.pools.to_i > 1
|
||||
|
||||
wrestler_pool_size = @wrestler.weight.wrestlers_in_pool(@wrestler.pool).size
|
||||
largest_pool_size = (1..@wrestler.weight.pools).map { |pool_number| @wrestler.weight.wrestlers_in_pool(pool_number).size }.max
|
||||
|
||||
wrestler_pool_size < largest_pool_size
|
||||
end
|
||||
|
||||
def any_bye_round_had_wrestled_match?(bye_matches)
|
||||
bye_matches.any? do |bye_match|
|
||||
next false if bye_match.round.nil?
|
||||
|
||||
@wrestler.weight.matches.any? do |match|
|
||||
next false if match.id == bye_match.id
|
||||
next false if match.round != bye_match.round
|
||||
next false if match.is_consolation_match != bye_match.is_consolation_match
|
||||
|
||||
match.finished == 1 && match.win_type.present? && match.win_type != "BYE"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
</ul>
|
||||
</li>
|
||||
<li><%= link_to " Bout Board" , "/tournaments/#{@tournament.id}/up_matches", class: "fas fa-list-alt" %></li>
|
||||
<li><%= link_to " Live Scores" , "/tournaments/#{@tournament.id}/live_scores", class: "fas fa-tv" %></li>
|
||||
<% end %>
|
||||
<% if can? :manage, @tournament %>
|
||||
<li class="dropdown">
|
||||
@@ -40,6 +41,7 @@
|
||||
<li><%= link_to "Weigh In Page" , "/tournaments/#{@tournament.id}/weigh_in" %></li>
|
||||
<li><%= link_to "All Matches" , "/tournaments/#{@tournament.id}/matches" %></li>
|
||||
<li><%= link_to "Full Screen Bout Board" , "/tournaments/#{@tournament.id}/up_matches?print=true" , target: :_blank %></li>
|
||||
<li><%= link_to "QR Code (Full Screen)" , "/tournaments/#{@tournament.id}/qrcode?print=true" , target: :_blank %></li>
|
||||
<li><%= link_to "Deduct Team Points" , "/tournaments/#{@tournament.id}/teampointadjust" %></li>
|
||||
<li><%= link_to "View All Mat Assignment Rules", tournament_mat_assignment_rules_path(@tournament) %></li>
|
||||
<li><%= link_to 'Manage Backups', tournament_tournament_backups_path(@tournament) %></li>
|
||||
|
||||
70
app/views/matches/_match_results_fields.html.erb
Normal file
70
app/views/matches/_match_results_fields.html.erb
Normal file
@@ -0,0 +1,70 @@
|
||||
<% submit_label = local_assigns.fetch(:submit_label, "Update Match") %>
|
||||
<% redirect_path = local_assigns[:redirect_path] %>
|
||||
|
||||
<h4>Match Results</h4>
|
||||
<br>
|
||||
<div data-controller="match-score" data-match-score-finished-value="<%= @match.finished == 1 %>">
|
||||
<div class="field">
|
||||
<%= f.label "Win type" %><br>
|
||||
<%= f.select :win_type, Match::WIN_TYPES, { include_blank: false }, {
|
||||
data: {
|
||||
match_score_target: "winType",
|
||||
action: "change->match-score#winTypeChanged"
|
||||
}
|
||||
} %>
|
||||
</div>
|
||||
<br>
|
||||
<div class="field">
|
||||
<%= f.label "Overtime Type" %> Leave blank if not overtime. For High School the 1st overtime is SV-1, second overtime is TB-1, third overtime is UTB.<br>
|
||||
<%= f.select(:overtime_type, Match::OVERTIME_TYPES, {}, {
|
||||
data: {
|
||||
match_score_target: "overtimeSelect"
|
||||
}
|
||||
}) %>
|
||||
</div>
|
||||
<br>
|
||||
<div class="field">
|
||||
<%= f.label "Winner" %> Please choose the winner<br>
|
||||
<%= f.collection_select :winner_id, @wrestlers, :id, :name_with_school,
|
||||
{ include_blank: true },
|
||||
{
|
||||
data: {
|
||||
match_score_target: "winnerSelect",
|
||||
action: "change->match-score#winnerChanged"
|
||||
}
|
||||
}
|
||||
%>
|
||||
</div>
|
||||
<br>
|
||||
<div class="field">
|
||||
<%= f.label "Final Score" %>
|
||||
<br>
|
||||
<% if @match.finished == 1 %>
|
||||
<%= f.text_field :score, id: "final-score-field", data: { match_score_target: "finalScoreField" }, class: "form-control", style: "max-width: 220px;" %>
|
||||
<% else %>
|
||||
<span id="score-help-text">
|
||||
The input will adjust based on the selected win type.
|
||||
</span>
|
||||
<br>
|
||||
<div id="dynamic-score-input" data-match-score-target="dynamicScoreInput"></div>
|
||||
<p id="pin-time-tip" class="text-muted mt-2" style="display: none;" data-match-score-target="pinTimeTip">
|
||||
<strong>Tip:</strong> Pin time is an accumulation over the match, not how much time was left in the current period.
|
||||
<br>For example, if all 3 periods are 2 minutes and a pin happened with 1:27 left in the second period,
|
||||
the pin time would be <strong>2:33</strong> (2 minutes for the first period + 33 seconds elapsed in the second period).
|
||||
</p>
|
||||
<%= f.hidden_field :score, id: "final-score-field", data: { match_score_target: "finalScoreField" } %>
|
||||
<% end %>
|
||||
<div id="validation-alerts" class="text-danger mt-2" data-match-score-target="validationAlerts"></div>
|
||||
<%= hidden_field_tag :redirect_to, redirect_path if redirect_path.present? %>
|
||||
<%= f.hidden_field :finished, value: 1 %>
|
||||
<%= f.hidden_field :round, value: @match.round %>
|
||||
|
||||
<br>
|
||||
<%= f.submit submit_label, id: "update-match-btn",
|
||||
data: {
|
||||
match_score_target: "submitButton",
|
||||
action: "click->match-score#confirmWinner"
|
||||
},
|
||||
class: "btn btn-success" %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -18,8 +18,19 @@
|
||||
|
||||
<div id="cable-status-indicator" data-match-data-target="statusIndicator" class="alert alert-secondary" style="padding: 5px; margin-bottom: 10px; border-radius: 4px;"></div>
|
||||
<h4>Bout <strong><%= @match.bout_number %></strong></h4>
|
||||
<% if @show_next_bout_button && @next_match %>
|
||||
<%= link_to "Skip to Next Match for Mat #{@mat.name}", mat_path(@mat, bout_number: @next_match.bout_number), class: "btn btn-primary" %>
|
||||
<% if @mat %>
|
||||
<% queue_matches = @queue_matches || @mat.queue_matches %>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<% queue_matches.each_with_index do |queue_match, index| %>
|
||||
<% queue_label = "Queue #{index + 1}" %>
|
||||
<% if queue_match %>
|
||||
<% button_class = queue_match.id == @match.id ? "btn btn-success btn-sm" : "btn btn-primary btn-sm" %>
|
||||
<%= link_to "#{queue_label}: Bout #{queue_match.bout_number}", mat_path(@mat, bout_number: queue_match.bout_number), class: button_class %>
|
||||
<% else %>
|
||||
<button type="button" class="btn btn-default btn-sm" disabled><%= "#{queue_label}: Not assigned" %></button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<h4>Bracket Position: <strong><%= @match.bracket_position %></strong></h4>
|
||||
|
||||
@@ -119,65 +130,6 @@
|
||||
<br>
|
||||
<br>
|
||||
<br>
|
||||
<h4>Match Results</h4>
|
||||
<br>
|
||||
<div data-controller="match-score">
|
||||
<div class="field">
|
||||
<%= f.label "Win type" %><br>
|
||||
<%= f.select :win_type, Match::WIN_TYPES, { include_blank: false }, {
|
||||
data: {
|
||||
match_score_target: "winType",
|
||||
action: "change->match-score#winTypeChanged"
|
||||
}
|
||||
} %>
|
||||
</div>
|
||||
<br>
|
||||
<div class="field">
|
||||
<%= f.label "Overtime Type" %> Leave blank if not overtime. For High School the 1st overtime is SV-1, second overtime is TB-1, third overtime is UTB.<br>
|
||||
<%= f.select(:overtime_type, Match::OVERTIME_TYPES) %>
|
||||
</div>
|
||||
<br>
|
||||
<div class="field">
|
||||
<%= f.label "Winner" %> Please choose the winner<br>
|
||||
<%= f.collection_select :winner_id, @wrestlers, :id, :name_with_school,
|
||||
{ include_blank: true },
|
||||
{
|
||||
data: {
|
||||
match_score_target: "winnerSelect",
|
||||
action: "change->match-score#winnerChanged"
|
||||
}
|
||||
}
|
||||
%>
|
||||
</div>
|
||||
<br>
|
||||
<div class="field">
|
||||
<%= f.label "Final Score" %>
|
||||
<br>
|
||||
<span id="score-help-text">
|
||||
The input will adjust based on the selected win type.
|
||||
</span>
|
||||
<br>
|
||||
<div id="dynamic-score-input" data-match-score-target="dynamicScoreInput"></div>
|
||||
<p id="pin-time-tip" class="text-muted mt-2" style="display: none;" data-match-score-target="pinTimeTip">
|
||||
<strong>Tip:</strong> Pin time is an accumulation over the match, not how much time was left in the current period.
|
||||
<br>For example, if all 3 periods are 2 minutes and a pin happened with 1:27 left in the second period,
|
||||
the pin time would be <strong>2:33</strong> (2 minutes for the first period + 33 seconds elapsed in the second period).
|
||||
</p>
|
||||
<div id="validation-alerts" class="text-danger mt-2" data-match-score-target="validationAlerts"></div>
|
||||
<%= f.hidden_field :score, id: "final-score-field", data: { match_score_target: "finalScoreField" } %>
|
||||
|
||||
<br>
|
||||
<%= f.submit "Update Match", id: "update-match-btn",
|
||||
data: {
|
||||
match_score_target: "submitButton",
|
||||
action: "click->match-score#confirmWinner"
|
||||
},
|
||||
class: "btn btn-success" %>
|
||||
</div>
|
||||
</div><!-- End of match-score controller -->
|
||||
<%= render "matches/match_results_fields", f: f, redirect_path: @match_results_redirect_path %>
|
||||
</div><!-- End of match-data controller div -->
|
||||
|
||||
<br>
|
||||
<%= f.hidden_field :finished, :value => 1 %>
|
||||
<%= f.hidden_field :round, :value => @match.round %>
|
||||
<% end %><!-- End of form_for -->
|
||||
|
||||
113
app/views/matches/_scoreboard.html.erb
Normal file
113
app/views/matches/_scoreboard.html.erb
Normal file
@@ -0,0 +1,113 @@
|
||||
<%
|
||||
source_mode = local_assigns.fetch(:source_mode, "localstorage")
|
||||
display_mode = local_assigns.fetch(:display_mode, "fullscreen")
|
||||
show_mat_banner = local_assigns.fetch(:show_mat_banner, false)
|
||||
show_stats = local_assigns.fetch(:show_stats, false)
|
||||
mat = local_assigns[:mat]
|
||||
match = local_assigns[:match]
|
||||
tournament = local_assigns[:tournament] || match&.tournament
|
||||
|
||||
fullscreen = display_mode == "fullscreen"
|
||||
root_style = if fullscreen
|
||||
"min-height: 100vh; background: #000; color: #fff; display: flex; align-items: stretch; justify-content: stretch; padding: 0; margin: 0; position: relative;"
|
||||
elsif show_stats
|
||||
"background: #000; color: #fff; padding: 0; margin: 0; position: relative; width: 100%; border: 1px solid #222;"
|
||||
else
|
||||
"background: #000; color: #fff; display: flex; align-items: stretch; justify-content: stretch; padding: 0; margin: 0; position: relative; width: 100%; min-height: 260px; border: 1px solid #222;"
|
||||
end
|
||||
inner_style = if fullscreen
|
||||
"display: grid; grid-template-rows: auto 1fr; width: 100vw; min-height: 100vh;"
|
||||
elsif show_stats
|
||||
"display: grid; grid-template-rows: auto auto auto; width: 100%;"
|
||||
else
|
||||
"display: grid; grid-template-rows: auto 1fr; width: 100%; min-height: 260px;"
|
||||
end
|
||||
board_style = fullscreen ? "display: grid; grid-template-columns: 1fr 1.3fr 1fr; width: 100%; min-height: 0;" : "display: grid; grid-template-columns: 1fr 1.2fr 1fr; width: 100%; min-height: 0; min-height: 260px;"
|
||||
panel_padding = fullscreen ? "2vw" : "1rem"
|
||||
name_size = fullscreen ? "clamp(2rem, 4vw, 4rem)" : "clamp(1rem, 2vw, 1.8rem)"
|
||||
school_size = fullscreen ? "clamp(1rem, 2vw, 2rem)" : "clamp(0.85rem, 1.3vw, 1.1rem)"
|
||||
score_size = fullscreen ? "clamp(8rem, 18vw, 16rem)" : "clamp(3rem, 8vw, 6rem)"
|
||||
period_size = fullscreen ? "clamp(1.5rem, 3vw, 2.5rem)" : "clamp(1rem, 2vw, 1.5rem)"
|
||||
clock_size = fullscreen ? "clamp(6rem, 16vw, 14rem)" : "clamp(3rem, 10vw, 5.5rem)"
|
||||
banner_offset = fullscreen ? "6vw" : "2rem"
|
||||
banner_border = fullscreen ? "0.45vw" : "4px"
|
||||
center_border = fullscreen ? "1vw" : "6px"
|
||||
%>
|
||||
|
||||
<div
|
||||
data-controller="match-scoreboard"
|
||||
data-match-scoreboard-source-mode-value="<%= source_mode %>"
|
||||
data-match-scoreboard-display-mode-value="<%= display_mode %>"
|
||||
data-match-scoreboard-match-id-value="<%= match&.id || 0 %>"
|
||||
data-match-scoreboard-mat-id-value="<%= mat&.id || 0 %>"
|
||||
data-match-scoreboard-tournament-id-value="<%= tournament&.id || 0 %>"
|
||||
data-match-scoreboard-initial-bout-number-value="<%= match&.bout_number || 0 %>"
|
||||
style="<%= root_style %>">
|
||||
<div style="<%= inner_style %>">
|
||||
<% if show_mat_banner %>
|
||||
<div style="background: #111; color: #fff; text-align: center; padding: 1vh 2vw; border-bottom: 0.5vw solid #fff;">
|
||||
<div style="font-size: clamp(1.5rem, 3vw, 3rem); font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;">
|
||||
Mat <%= mat&.name %>
|
||||
</div>
|
||||
<div data-match-scoreboard-target="boutLabel" style="font-size: clamp(1rem, 2vw, 1.75rem); letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.95; margin-top: 0.3rem;">
|
||||
Bout <%= match&.bout_number || "" %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div style="background: #111; color: #fff; text-align: center; padding: 0.35rem 1rem; border-bottom: 4px solid #fff;">
|
||||
<div data-match-scoreboard-target="boutLabel" style="font-size: clamp(0.85rem, 1.3vw, 1.1rem); letter-spacing: 0.08em; text-transform: uppercase; opacity: 0.95;">
|
||||
Bout <%= match&.bout_number || "" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div style="<%= board_style %>">
|
||||
<section data-match-scoreboard-target="redSection" style="background: #c91f1f; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: <%= panel_padding %>; text-align: center;">
|
||||
<div data-match-scoreboard-target="redName" style="font-size: <%= name_size %>; font-weight: 700; line-height: 1;">NO MATCH</div>
|
||||
<div data-match-scoreboard-target="redSchool" style="font-size: <%= school_size %>; margin-top: 0.75rem; opacity: 0.95;"></div>
|
||||
<div data-match-scoreboard-target="redTimerIndicator" style="font-size: <%= school_size %>; margin-top: 0.75rem;"></div>
|
||||
<div data-match-scoreboard-target="redScore" style="font-size: <%= score_size %>; font-weight: 800; line-height: 0.9; margin-top: 3vh;">0</div>
|
||||
</section>
|
||||
|
||||
<section data-match-scoreboard-target="centerSection" style="background: #050505; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: <%= panel_padding %>; text-align: center; border-left: <%= center_border %> solid #fff; border-right: <%= center_border %> solid #fff;">
|
||||
<div data-match-scoreboard-target="periodLabel" style="font-size: <%= period_size %>; font-weight: 700; margin-top: 2vh; text-transform: uppercase;">No Match</div>
|
||||
<div data-match-scoreboard-target="clock" style="font-size: <%= clock_size %>; font-weight: 800; line-height: 0.9; margin-top: 3vh;">-</div>
|
||||
<div data-match-scoreboard-target="emptyState" style="display: none; margin-top: 4vh; font-size: clamp(1.5rem, 3vw, 4rem); font-weight: 700; color: #fff;">No Match</div>
|
||||
</section>
|
||||
|
||||
<section data-match-scoreboard-target="greenSection" style="background: #1cab2d; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: <%= panel_padding %>; text-align: center;">
|
||||
<div data-match-scoreboard-target="greenName" style="font-size: <%= name_size %>; font-weight: 700; line-height: 1;">NO MATCH</div>
|
||||
<div data-match-scoreboard-target="greenSchool" style="font-size: <%= school_size %>; margin-top: 0.75rem; opacity: 0.95;"></div>
|
||||
<div data-match-scoreboard-target="greenTimerIndicator" style="font-size: <%= school_size %>; margin-top: 0.75rem;"></div>
|
||||
<div data-match-scoreboard-target="greenScore" style="font-size: <%= score_size %>; font-weight: 800; line-height: 0.9; margin-top: 3vh;">0</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<% if show_stats %>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; background: #fff; color: #111; padding: 12px;">
|
||||
<section style="border: 1px solid #d9d9d9; padding: 12px; min-width: 0;">
|
||||
<div style="font-weight: 700; margin-bottom: 8px;">Red Stats</div>
|
||||
<pre data-match-scoreboard-target="redStats" style="margin: 0; background: #f7f7f7; border: 1px solid #ececec; padding: 10px; min-height: 120px; white-space: pre-wrap; overflow-wrap: anywhere;"></pre>
|
||||
</section>
|
||||
|
||||
<section style="border: 1px solid #d9d9d9; padding: 12px; min-width: 0;">
|
||||
<div style="font-weight: 700; margin-bottom: 8px;">Green Stats</div>
|
||||
<pre data-match-scoreboard-target="greenStats" style="margin: 0; background: #f7f7f7; border: 1px solid #ececec; padding: 10px; min-height: 120px; white-space: pre-wrap; overflow-wrap: anywhere;"></pre>
|
||||
</section>
|
||||
</div>
|
||||
<div style="background: #fff; color: #111; padding: 0 12px 12px;">
|
||||
<section style="border: 1px solid #d9d9d9; padding: 12px;">
|
||||
<div style="font-weight: 700; margin-bottom: 8px;">Last Match Result</div>
|
||||
<div data-match-scoreboard-target="lastMatchResult">-</div>
|
||||
</section>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-match-scoreboard-target="timerBanner"
|
||||
style="display: none; position: absolute; left: <%= banner_offset %>; right: <%= banner_offset %>; top: 50%; transform: translateY(-50%); background: rgba(15, 15, 15, 0.96); border: <%= banner_border %> solid #fff; padding: 1.5vh 2vw; text-align: center; z-index: 20;">
|
||||
<div data-match-scoreboard-target="timerBannerLabel" style="font-size: <%= fullscreen ? "clamp(1.3rem, 2.6vw, 2.6rem)" : "clamp(0.9rem, 1.6vw, 1.25rem)" %>; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;"></div>
|
||||
<div data-match-scoreboard-target="timerBannerClock" style="font-size: <%= fullscreen ? "clamp(2.5rem, 6vw, 6rem)" : "clamp(1.6rem, 4vw, 3rem)" %>; font-weight: 800; line-height: 1; margin-top: 0.5vh;"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,6 +10,18 @@
|
||||
class="alert alert-secondary"
|
||||
style="padding: 5px; margin-bottom: 10px; border-radius: 4px;"></div>
|
||||
|
||||
<% unless @match.finished %>
|
||||
<div data-match-spectate-target="scoreboardContainer" style="margin-bottom: 20px;">
|
||||
<%= render "tournaments/live_score_card",
|
||||
source_mode: "websocket",
|
||||
show_mat_header: false,
|
||||
show_details: false,
|
||||
mat: @match.mat,
|
||||
match: @match,
|
||||
tournament: @tournament %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="match-details">
|
||||
<div class="wrestler-info wrestler1">
|
||||
<h4><%= @wrestler1_name %> (<%= @wrestler1_school_name %>)</h4>
|
||||
|
||||
108
app/views/matches/state.html.erb
Normal file
108
app/views/matches/state.html.erb
Normal file
@@ -0,0 +1,108 @@
|
||||
<h1><%= @wrestler1_name %> VS. <%= @wrestler2_name %></h1>
|
||||
|
||||
<div
|
||||
data-controller="match-state"
|
||||
data-match-state-match-id-value="<%= @match.id %>"
|
||||
data-match-state-tournament-id-value="<%= @match.tournament.id %>"
|
||||
data-match-state-bout-number-value="<%= @match.bout_number %>"
|
||||
data-match-state-weight-label-value="<%= @match.weight&.max %>"
|
||||
data-match-state-bracket-position-value="<%= @match.bracket_position %>"
|
||||
data-match-state-ruleset-value="<%= @match_state_ruleset %>"
|
||||
data-match-state-w1-id-value="<%= @match.w1 || 0 %>"
|
||||
data-match-state-w2-id-value="<%= @match.w2 || 0 %>"
|
||||
data-match-state-w1-name-value="<%= @wrestler1_name %>"
|
||||
data-match-state-w2-name-value="<%= @wrestler2_name %>"
|
||||
data-match-state-w1-school-value="<%= @wrestler1_school_name %>"
|
||||
data-match-state-w2-school-value="<%= @wrestler2_school_name %>">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="panel panel-success" data-match-state-target="greenPanel">
|
||||
<div class="panel-heading">
|
||||
<strong data-match-state-target="greenLabel">Green</strong> <span data-match-state-target="greenName"><%= @wrestler1_name %></span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="text-muted" data-match-state-target="greenSchool"><%= @wrestler1_school_name %></p>
|
||||
<h2 data-match-state-target="greenScore">0</h2>
|
||||
<div data-match-state-target="greenControls"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Bout <%= @match.bout_number %></strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p><strong>Bracket Position:</strong> <%= @match.bracket_position %></p>
|
||||
<p><strong>Period:</strong> <span data-match-state-target="periodLabel"></span></p>
|
||||
<p><strong>Clock:</strong> <span data-match-state-target="clock">2:00</span></p>
|
||||
<p><strong>Status:</strong> <span data-match-state-target="clockStatus">Stopped</span></p>
|
||||
<p><strong>Match Position:</strong> <span data-match-state-target="matchPosition"></span></p>
|
||||
<p><strong>Format:</strong> <span data-match-state-target="formatName"></span></p>
|
||||
<div class="btn-group" style="margin-top: 10px;">
|
||||
<button type="button" class="btn btn-success btn-sm" data-action="click->match-state#startClock">Start</button>
|
||||
<button type="button" class="btn btn-danger btn-sm" data-action="click->match-state#stopClock">Stop</button>
|
||||
</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<strong>Adjust Match Clock</strong>
|
||||
<div class="btn-group" style="margin-top: 8px;">
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="click->match-state#subtractMinute">-1m</button>
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="click->match-state#subtractSecond">-1s</button>
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="click->match-state#addSecond">+1s</button>
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="click->match-state#addMinute">+1m</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 12px;">
|
||||
<button type="button" class="btn btn-primary btn-sm" data-action="click->match-state#swapColors">Swap Red/Green</button>
|
||||
</div>
|
||||
<div style="margin-top: 12px;">
|
||||
<strong>Match Period Navigation</strong>
|
||||
<div class="text-muted">Use these to move between periods and choice periods.</div>
|
||||
<div class="btn-group" style="margin-top: 8px;">
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="click->match-state#previousPhase">Previous Period</button>
|
||||
<button type="button" class="btn btn-default btn-sm" data-action="click->match-state#nextPhase">Next Period</button>
|
||||
</div>
|
||||
<div style="margin-top: 8px;">
|
||||
<strong>Match Time Accumulation:</strong>
|
||||
<span data-match-state-target="accumulationClock">0:00</span>
|
||||
</div>
|
||||
<div style="margin-top: 12px;">
|
||||
<button type="button" class="btn btn-warning btn-sm" data-action="click->match-state#resetMatch">Reset Match</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-match-state-target="choiceActions" style="margin-top: 12px;"></div>
|
||||
<hr>
|
||||
<h4>Event Log</h4>
|
||||
<div data-match-state-target="eventLog"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="panel panel-danger" data-match-state-target="redPanel">
|
||||
<div class="panel-heading">
|
||||
<strong data-match-state-target="redLabel">Red</strong> <span data-match-state-target="redName"><%= @wrestler2_name %></span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<p class="text-muted" data-match-state-target="redSchool"><%= @wrestler2_school_name %></p>
|
||||
<h2 data-match-state-target="redScore">0</h2>
|
||||
<div data-match-state-target="redControls"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default" data-match-state-target="matchResultsPanel">
|
||||
<div class="panel-heading">
|
||||
<strong>Submit Match Results</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<%= form_for(@match) do |f| %>
|
||||
<%= f.hidden_field :w1_stat, data: { match_state_target: "w1StatField" } %>
|
||||
<%= f.hidden_field :w2_stat, data: { match_state_target: "w2StatField" } %>
|
||||
<%= render "matches/match_results_fields", f: f, redirect_path: @match_results_redirect_path, submit_label: "Update Match" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,8 @@
|
||||
<% @mat = mat %>
|
||||
<% @match = local_assigns[:match] || mat.queue1_match %>
|
||||
<% @next_match = local_assigns[:next_match] || mat.queue2_match %>
|
||||
<% @queue_matches = local_assigns[:queue_matches] || mat.queue_matches %>
|
||||
<% @match = local_assigns[:match] || @queue_matches[0] %>
|
||||
<% @match ||= @queue_matches[0] %>
|
||||
<% @next_match = local_assigns[:next_match] || @queue_matches[1] %>
|
||||
<% @show_next_bout_button = local_assigns.key?(:show_next_bout_button) ? local_assigns[:show_next_bout_button] : true %>
|
||||
|
||||
<% @wrestlers = [] %>
|
||||
|
||||
7
app/views/mats/scoreboard.html.erb
Normal file
7
app/views/mats/scoreboard.html.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
<%= render "matches/scoreboard",
|
||||
source_mode: "localstorage",
|
||||
display_mode: "fullscreen",
|
||||
show_mat_banner: true,
|
||||
mat: @mat,
|
||||
match: @match,
|
||||
tournament: @tournament %>
|
||||
33
app/views/mats/state.html.erb
Normal file
33
app/views/mats/state.html.erb
Normal file
@@ -0,0 +1,33 @@
|
||||
<% if @match %>
|
||||
<div
|
||||
data-controller="mat-state"
|
||||
data-mat-state-tournament-id-value="<%= @tournament.id %>"
|
||||
data-mat-state-mat-id-value="<%= @mat.id %>"
|
||||
data-mat-state-bout-number-value="<%= @match.bout_number %>"
|
||||
data-mat-state-match-id-value="<%= @match.id %>"
|
||||
data-mat-state-select-match-url-value="<%= select_match_mat_path(@mat) %>"
|
||||
data-mat-state-weight-label-value="<%= @match.weight&.max %>"
|
||||
data-mat-state-w1-id-value="<%= @match.w1 || 0 %>"
|
||||
data-mat-state-w2-id-value="<%= @match.w2 || 0 %>"
|
||||
data-mat-state-w1-name-value="<%= @wrestler1_name %>"
|
||||
data-mat-state-w2-name-value="<%= @wrestler2_name %>"
|
||||
data-mat-state-w1-school-value="<%= @wrestler1_school_name %>"
|
||||
data-mat-state-w2-school-value="<%= @wrestler2_school_name %>">
|
||||
<h3>Mat <%= @mat.name %></h3>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<% @queue_matches.each_with_index do |queue_match, index| %>
|
||||
<% queue_label = "Queue #{index + 1}" %>
|
||||
<% if queue_match %>
|
||||
<% button_class = queue_match.id == @match.id ? "btn btn-success btn-sm" : "btn btn-primary btn-sm" %>
|
||||
<%= link_to "#{queue_label}: Bout #{queue_match.bout_number}", state_mat_path(@mat, bout_number: queue_match.bout_number), class: button_class %>
|
||||
<% else %>
|
||||
<button type="button" class="btn btn-default btn-sm" disabled><%= "#{queue_label}: Not assigned" %></button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render template: "matches/state" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<h3>Mat <%= @mat.name %></h3>
|
||||
<p>No matches assigned to this mat.</p>
|
||||
<% end %>
|
||||
13
app/views/schools/_wrestler_row_cells.html.erb
Normal file
13
app/views/schools/_wrestler_row_cells.html.erb
Normal file
@@ -0,0 +1,13 @@
|
||||
<% if local_assigns[:school_permission_key].present? %>
|
||||
<% wrestler_path_with_key = wrestler_path(wrestler) %>
|
||||
<% wrestler_path_with_key += "?school_permission_key=#{school_permission_key}" %>
|
||||
<td><%= link_to wrestler.name, wrestler_path_with_key %></td>
|
||||
<% else %>
|
||||
<td><%= link_to wrestler.name, wrestler_path(wrestler) %></td>
|
||||
<% end %>
|
||||
<td><%= wrestler.weight.max %></td>
|
||||
<td><%= wrestler.season_win %>-<%= wrestler.season_loss %> <%= wrestler.criteria %></td>
|
||||
<td><%= wrestler.original_seed %></td>
|
||||
<td><%= wrestler.total_team_points - wrestler.total_points_deducted %></td>
|
||||
<td><%= "Yes" if wrestler.extra? %></td>
|
||||
<td><%= wrestler.next_match_bout_number %> <%= wrestler.next_match_mat_name %></td>
|
||||
@@ -54,19 +54,8 @@
|
||||
<tbody>
|
||||
<% @wrestlers.sort_by { |w| w.weight.max }.each do |wrestler| %>
|
||||
<% if params[:school_permission_key].present? %>
|
||||
<!-- No caching when school_permission_key is present -->
|
||||
<tr>
|
||||
<td>
|
||||
<% wrestler_path_with_key = wrestler_path(wrestler) %>
|
||||
<% wrestler_path_with_key += "?school_permission_key=#{params[:school_permission_key]}" if params[:school_permission_key].present? %>
|
||||
<%= link_to wrestler.name, wrestler_path_with_key %>
|
||||
</td>
|
||||
<td><%= wrestler.weight.max %></td>
|
||||
<td><%= wrestler.season_win %>-<%= wrestler.season_loss %> <%= wrestler.criteria %></td>
|
||||
<td><%= wrestler.original_seed %></td>
|
||||
<td><%= wrestler.total_team_points - wrestler.total_points_deducted %></td>
|
||||
<td><%= "Yes" if wrestler.extra? %></td>
|
||||
<td><%= wrestler.next_match_bout_number %> <%= wrestler.next_match_mat_name %></td>
|
||||
<%= render "schools/wrestler_row_cells", wrestler: wrestler, school_permission_key: params[:school_permission_key] %>
|
||||
|
||||
<% if can? :manage, wrestler.school %>
|
||||
<td>
|
||||
@@ -86,29 +75,16 @@
|
||||
<% end %>
|
||||
</tr>
|
||||
<% else %>
|
||||
<!-- Use caching only when school_permission_key is NOT present -->
|
||||
<% cache ["#{wrestler.id}_school_show", @school] do %>
|
||||
<tr>
|
||||
<td><%= link_to wrestler.name, wrestler_path(wrestler) %></td>
|
||||
<td><%= wrestler.weight.max %></td>
|
||||
<td><%= wrestler.season_win %>-<%= wrestler.season_loss %> <%= wrestler.criteria %></td>
|
||||
<td><%= wrestler.original_seed %></td>
|
||||
<td><%= wrestler.total_team_points - wrestler.total_points_deducted %></td>
|
||||
<td><%= "Yes" if wrestler.extra? %></td>
|
||||
<td><%= wrestler.next_match_bout_number %> <%= wrestler.next_match_mat_name %></td>
|
||||
<% cache ["school_show_wrestler_cells", wrestler] do %>
|
||||
<%= render "schools/wrestler_row_cells", wrestler: wrestler %>
|
||||
<% end %>
|
||||
<% if can? :manage, wrestler.school %>
|
||||
<td>
|
||||
<% edit_wrestler_path_with_key = edit_wrestler_path(wrestler) %>
|
||||
<% edit_wrestler_path_with_key += "?school_permission_key=#{params[:school_permission_key]}" if params[:school_permission_key].present? %>
|
||||
|
||||
<% delete_wrestler_path_with_key = wrestler_path(wrestler) %>
|
||||
<% delete_wrestler_path_with_key += "?school_permission_key=#{params[:school_permission_key]}" if params[:school_permission_key].present? %>
|
||||
|
||||
<%= link_to edit_wrestler_path_with_key, class: "text-decoration-none" do %>
|
||||
<%= link_to edit_wrestler_path(wrestler), class: "text-decoration-none" do %>
|
||||
<span class="fas fa-edit" aria-hidden="true"></span>
|
||||
<% end %>
|
||||
<%= link_to delete_wrestler_path_with_key, data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete #{wrestler.name}? This will delete all of his matches." }, class: "text-decoration-none" do %>
|
||||
<%= link_to wrestler_path(wrestler), data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete #{wrestler.name}? This will delete all of his matches." }, class: "text-decoration-none" do %>
|
||||
<span class="fas fa-trash-alt" aria-hidden="true"></span>
|
||||
<% end %>
|
||||
</td>
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<li>Win by major: 1pt extra</li>
|
||||
<li>Win by tech fall: 1.5pt extra</li>
|
||||
<li>Win by fall, default, dq: 2pt extra</li>
|
||||
<li>BYE points: 2pt (if you win at least 1 match in a pool with a BYE)</li>
|
||||
<li>BYE points: 2pt (if you win at least 1 match in a pool with a BYE). - This only applies if your pool has more BYEs than other pools in your bracket. This does not apply to weight classes with 1 pool.</li>
|
||||
</ul>
|
||||
<p>See placement points below (based on the largest bracket of the tournament)</p>
|
||||
<h4>Pool Types</h4>
|
||||
@@ -71,7 +71,7 @@
|
||||
<li>Win by major: 1pt extra</li>
|
||||
<li>Win by tech: 1.5pt extra</li>
|
||||
<li>Win by fall, default, dq, etc: 2pt extra</li>
|
||||
<li>BYE points: 2pts if you have a bye in the championship bracket and win the next match. 1pt if you have a bye in the consolation bracket and win the next match.</li>
|
||||
<li>BYE points: 2pts if you have a bye in the championship bracket and win the next match. 1pt if you have a bye in the consolation bracket and win the next match. - This only applies if you received a bye in a round with at least 1 match in your backet.</li>
|
||||
</ul>
|
||||
<br>
|
||||
<h3>Modified 16 Man Double Elimination Information</h3>
|
||||
@@ -142,7 +142,7 @@
|
||||
<br>
|
||||
<h3>Future Plans</h3>
|
||||
<br>
|
||||
<p>Future development plans to support 32 and 64 man regulard double elimination, modified (5 per day match rule) 32 man double elimination, and true second double elimination brackets are underway.</p>
|
||||
<p>Future development plans are underway to make the application more flexible, make changes after weigh ins easier, and to add functionality for a live scoreboard.</p>
|
||||
<br>
|
||||
<h3>Contact</h3>
|
||||
<br>
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
<% if @tournaments.size > 0 %>
|
||||
<h3>My Tournaments</h3>
|
||||
<script>
|
||||
// $(document).ready(function() {
|
||||
// $('#tournamentList').dataTable();
|
||||
// pagingType: "bootstrap";
|
||||
// } );
|
||||
</script>
|
||||
<table class="table table-hover" id="tournamentList">
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<% @final_match.each do |match| %>
|
||||
<% cache ["bracket_final_match", match, match.wrestler1, match.wrestler2, @winner_place, params[:print].to_s] do %>
|
||||
<div class="round">
|
||||
<div class="game">
|
||||
<div class="game-top "><%= match.w1_bracket_name.html_safe %> <span></span></div>
|
||||
@@ -11,3 +12,4 @@
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
<style>
|
||||
table.smallText tr td { font-size: 10px; }
|
||||
table.smallText {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table.smallText th,
|
||||
table.smallText td {
|
||||
border: 1px solid #000;
|
||||
}
|
||||
/*
|
||||
* Bracket Layout Specifics
|
||||
*/
|
||||
.bracket {
|
||||
display: flex;
|
||||
font-size: 10px;
|
||||
font-size: 10.5px;
|
||||
gap: 2px;
|
||||
}
|
||||
.game {
|
||||
min-width: 150px;
|
||||
min-height: 50px;
|
||||
min-height: 58px;
|
||||
/*background-color: #ddd;*/
|
||||
border: 1px solid #000; /* Dark border so boxes stay visible when printed */
|
||||
margin: 5px;
|
||||
border: 1.5px solid #000; /* Dark border so boxes stay visible when printed */
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
/*.game:after {
|
||||
@@ -56,14 +64,15 @@ table.smallText tr td { font-size: 10px; }
|
||||
}
|
||||
|
||||
.game-top {
|
||||
border-bottom:1px solid #000;
|
||||
padding: 2px;
|
||||
min-height: 12px;
|
||||
border-bottom:1.5px solid #000;
|
||||
padding: 3px 4px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.bout-number {
|
||||
text-align: center;
|
||||
/*padding-top: 15px;*/
|
||||
line-height: 1.35;
|
||||
padding: 1px 2px;
|
||||
}
|
||||
|
||||
/* Style links within bout-number like default links */
|
||||
@@ -77,15 +86,29 @@ table.smallText tr td { font-size: 10px; }
|
||||
}
|
||||
|
||||
.bracket-winner {
|
||||
border-bottom:1px solid #000;
|
||||
padding: 2px;
|
||||
min-height: 12px;
|
||||
border-bottom:1.5px solid #000;
|
||||
padding: 3px 4px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.game-bottom {
|
||||
border-top:1px solid #000;
|
||||
padding: 2px;
|
||||
min-height: 12px;
|
||||
border-top:1.5px solid #000;
|
||||
padding: 3px 4px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.game,
|
||||
.game-top,
|
||||
.game-bottom,
|
||||
.bracket-winner,
|
||||
table.smallText,
|
||||
table.smallText th,
|
||||
table.smallText td {
|
||||
border-color: #000 !important;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<% if @tournament.tournament_type == "Pool to bracket" %>
|
||||
@@ -95,8 +118,6 @@ table.smallText tr td { font-size: 10px; }
|
||||
<table class='smallText'>
|
||||
<tr>
|
||||
<td valign="top" style="padding: 10px;">
|
||||
<% @matches = @tournament.matches.select{|m| m.weight_id == @weight.id} %>
|
||||
<% @wrestlers = Wrestler.where(weight_id: @weight.id) %>
|
||||
<% @pools = @weight.pool_rounds(@matches) %>
|
||||
<%= render 'pool' %>
|
||||
</td>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<div class="round">
|
||||
<% @round_matches.sort_by{|m| m.bracket_position_number}.each do |match| %>
|
||||
<% cache ["bracket_round_match", match, match.wrestler1, match.wrestler2, params[:print].to_s] do %>
|
||||
<div class="game">
|
||||
<div class="game-top "><%= match.w1_bracket_name.html_safe %> <span></span></div>
|
||||
<% if params[:print] %>
|
||||
@@ -12,4 +13,5 @@
|
||||
<div class="game-bottom "><%= match.w2_bracket_name.html_safe %><span></span></div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
111
app/views/tournaments/_live_score_card.html.erb
Normal file
111
app/views/tournaments/_live_score_card.html.erb
Normal file
@@ -0,0 +1,111 @@
|
||||
<%
|
||||
match = local_assigns[:match]
|
||||
mat = local_assigns[:mat]
|
||||
tournament = local_assigns[:tournament]
|
||||
source_mode = local_assigns.fetch(:source_mode, "mat_websocket")
|
||||
show_mat_header = local_assigns.fetch(:show_mat_header, true)
|
||||
show_details = local_assigns.fetch(:show_details, true)
|
||||
dom_id_suffix = mat&.id || match&.id || "none"
|
||||
%>
|
||||
|
||||
<div
|
||||
data-controller="match-scoreboard"
|
||||
data-match-scoreboard-source-mode-value="<%= source_mode %>"
|
||||
data-match-scoreboard-display-mode-value="embedded"
|
||||
data-match-scoreboard-match-id-value="<%= match&.id || 0 %>"
|
||||
data-match-scoreboard-mat-id-value="<%= mat&.id || 0 %>"
|
||||
data-match-scoreboard-tournament-id-value="<%= tournament.id %>"
|
||||
data-match-scoreboard-initial-bout-number-value="<%= match&.bout_number || 0 %>">
|
||||
|
||||
<div class="panel panel-default" style="margin-bottom: 0;">
|
||||
<% if show_mat_header %>
|
||||
<div class="panel-heading">
|
||||
<strong>Mat <%= mat&.name %></strong>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<table class="table table-bordered table-condensed" style="margin-bottom: 0; table-layout: fixed;">
|
||||
<tr class="active">
|
||||
<td>
|
||||
<strong data-match-scoreboard-target="boutLabel">Bout <%= match&.bout_number || "" %></strong>
|
||||
<span style="margin-left: 12px;" data-match-scoreboard-target="weightLabel">Weight <%= match&.weight&.max || "-" %></span>
|
||||
</td>
|
||||
<td class="text-right" style="white-space: nowrap;">
|
||||
<span class="label label-default" data-match-scoreboard-target="clock">-</span>
|
||||
<span class="label label-primary" data-match-scoreboard-target="periodLabel">No Match</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
<div data-match-scoreboard-target="greenName" style="font-weight: 700; font-size: 1.15em;">NO MATCH</div>
|
||||
<div class="text-muted" data-match-scoreboard-target="greenSchool"></div>
|
||||
<div data-match-scoreboard-target="greenTimerIndicator" style="margin-top: 6px;"></div>
|
||||
</td>
|
||||
<td data-match-scoreboard-target="greenSection" class="text-center" style="background: #1cab2d; color: #fff; font-size: 2rem; font-weight: 700; vertical-align: middle; width: 110px;">
|
||||
<span data-match-scoreboard-target="greenScore">0</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
<div data-match-scoreboard-target="redName" style="font-weight: 700; font-size: 1.15em;">NO MATCH</div>
|
||||
<div class="text-muted" data-match-scoreboard-target="redSchool"></div>
|
||||
<div data-match-scoreboard-target="redTimerIndicator" style="margin-top: 6px;"></div>
|
||||
</td>
|
||||
<td data-match-scoreboard-target="redSection" class="text-center" style="background: #c91f1f; color: #fff; font-size: 2rem; font-weight: 700; vertical-align: middle; width: 110px;">
|
||||
<span data-match-scoreboard-target="redScore">0</span>
|
||||
</td>
|
||||
</tr>
|
||||
<% if show_details %>
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 0;">
|
||||
<div class="panel panel-default" style="margin: 10px;">
|
||||
<div class="panel-heading">
|
||||
<a data-toggle="collapse" href="#live-score-stats-<%= dom_id_suffix %>" aria-expanded="false" aria-controls="live-score-stats-<%= dom_id_suffix %>" style="display: flex; justify-content: space-between; align-items: center; color: #333; text-decoration: none; background: transparent; outline: none;">
|
||||
<strong>Stats</strong>
|
||||
<span class="text-muted" style="font-size: 0.9em;">Show/Hide</span>
|
||||
</a>
|
||||
</div>
|
||||
<div id="live-score-stats-<%= dom_id_suffix %>" class="panel-collapse collapse">
|
||||
<div class="panel-body" style="padding-bottom: 0;">
|
||||
<div class="row">
|
||||
<div class="col-sm-6">
|
||||
<div class="label label-success" style="display: inline-block; margin-bottom: 8px;">Green</div>
|
||||
<pre data-match-scoreboard-target="greenStats" class="well well-sm" style="min-height: 100px; white-space: pre-wrap; overflow-wrap: anywhere; background: #fff;"></pre>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<div class="label label-danger" style="display: inline-block; margin-bottom: 8px;">Red</div>
|
||||
<pre data-match-scoreboard-target="redStats" class="well well-sm" style="min-height: 100px; white-space: pre-wrap; overflow-wrap: anywhere; background: #fff;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" style="padding: 0;">
|
||||
<div class="panel panel-default" style="margin: 10px;">
|
||||
<div class="panel-heading">
|
||||
<a data-toggle="collapse" href="#live-score-last-result-<%= dom_id_suffix %>" aria-expanded="false" aria-controls="live-score-last-result-<%= dom_id_suffix %>" style="display: flex; justify-content: space-between; align-items: center; color: #333; text-decoration: none; background: transparent; outline: none;">
|
||||
<strong>Last Match Result</strong>
|
||||
<span class="text-muted" style="font-size: 0.9em;">Show/Hide</span>
|
||||
</a>
|
||||
</div>
|
||||
<div id="live-score-last-result-<%= dom_id_suffix %>" class="panel-collapse collapse">
|
||||
<div class="panel-body">
|
||||
<div data-match-scoreboard-target="lastMatchResult">-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div data-match-scoreboard-target="emptyState" style="display: none;"></div>
|
||||
<div data-match-scoreboard-target="centerSection" style="display: none;"></div>
|
||||
<div data-match-scoreboard-target="timerBanner" style="display: none;"></div>
|
||||
<div data-match-scoreboard-target="timerBannerLabel" style="display: none;"></div>
|
||||
<div data-match-scoreboard-target="timerBannerClock" style="display: none;"></div>
|
||||
</div>
|
||||
6
app/views/tournaments/_team_score_row.html.erb
Normal file
6
app/views/tournaments/_team_score_row.html.erb
Normal file
@@ -0,0 +1,6 @@
|
||||
<% cache ["team_score_row", school, rank] do %>
|
||||
<tr>
|
||||
<td><%= rank %>. <%= school.name %> (<%= school.abbreviation %>)</td>
|
||||
<td><%= school.page_score_string %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
38
app/views/tournaments/_up_matches_board.html.erb
Normal file
38
app/views/tournaments/_up_matches_board.html.erb
Normal file
@@ -0,0 +1,38 @@
|
||||
<div id="up_matches_board">
|
||||
<h3>Upcoming Matches</h3>
|
||||
<table class="table table-striped table-bordered table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mat </th>
|
||||
<th>On Mat</th>
|
||||
<th>On Deck</th>
|
||||
<th>In The Hole</th>
|
||||
<th>Warm Up</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% (local_assigns[:mats] || tournament.up_matches_mats).each do |m| %>
|
||||
<%= render "tournaments/up_matches_mat_row", mat: m %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<br>
|
||||
<h3>Matches not assigned</h3>
|
||||
<br>
|
||||
<table class="table table-striped table-bordered table-condensed" id="matchList">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Round</th>
|
||||
<th>Bout Number</th>
|
||||
<th>Weight Class</th>
|
||||
<th>Matchup</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<%= render partial: "tournaments/up_matches_unassigned_row", collection: (local_assigns[:matches] || tournament.up_matches_unassigned_matches), as: :match %>
|
||||
</tbody>
|
||||
</table>
|
||||
<br>
|
||||
</div>
|
||||
35
app/views/tournaments/_up_matches_mat_row.html.erb
Normal file
35
app/views/tournaments/_up_matches_mat_row.html.erb
Normal file
@@ -0,0 +1,35 @@
|
||||
<% queue1_match, queue2_match, queue3_match, queue4_match = mat.queue_matches %>
|
||||
<% queue_match_dependencies = [queue1_match, queue2_match, queue3_match, queue4_match].compact.flat_map { |match| [match, match.wrestler1, match.wrestler2] } %>
|
||||
<% cache ["up_matches_mat_row", mat, *queue_match_dependencies] do %>
|
||||
<tr>
|
||||
<td><%= mat.name %></td>
|
||||
<td>
|
||||
<% if queue1_match %><strong><%= queue1_match.bout_number %></strong> (<%= queue1_match.bracket_position %>)<br>
|
||||
<%= queue1_match.weight_max %> lbs
|
||||
<br><%= queue1_match.w1_bracket_name %> vs. <br>
|
||||
<%= queue1_match.w2_bracket_name %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<% if queue2_match %><strong><%= queue2_match.bout_number %></strong> (<%= queue2_match.bracket_position %>)<br>
|
||||
<%= queue2_match.weight_max %> lbs
|
||||
<br><%= queue2_match.w1_bracket_name %> vs. <br>
|
||||
<%= queue2_match.w2_bracket_name %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<% if queue3_match %><strong><%= queue3_match.bout_number %></strong> (<%= queue3_match.bracket_position %>)<br>
|
||||
<%= queue3_match.weight_max %> lbs
|
||||
<br><%= queue3_match.w1_bracket_name %> vs. <br>
|
||||
<%= queue3_match.w2_bracket_name %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<% if queue4_match %><strong><%= queue4_match.bout_number %></strong> (<%= queue4_match.bracket_position %>)<br>
|
||||
<%= queue4_match.weight_max %> lbs
|
||||
<br><%= queue4_match.w1_bracket_name %> vs. <br>
|
||||
<%= queue4_match.w2_bracket_name %>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
@@ -0,0 +1,8 @@
|
||||
<% cache ["up_matches_unassigned_row", match, match.wrestler1, match.wrestler2] do %>
|
||||
<tr>
|
||||
<td>Round <%= match.round %></td>
|
||||
<td><%= match.bout_number %></td>
|
||||
<td><%= match.weight_max %></td>
|
||||
<td><%= match.w1_bracket_name %> vs. <%= match.w2_bracket_name %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
@@ -1,20 +1,20 @@
|
||||
<style>
|
||||
/* General styles for pages */
|
||||
@page {
|
||||
margin: 0.5in; /* Universal margin for all pages */
|
||||
margin: 0.35in;
|
||||
}
|
||||
|
||||
.page {
|
||||
width: 7.5in; /* Portrait width (8.5in - margins) */
|
||||
height: 10in; /* Portrait height (11in - margins) */
|
||||
width: 7.8in; /* 8.5in - 2 * 0.35in */
|
||||
height: 10.3in; /* 11in - 2 * 0.35in */
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-landscape {
|
||||
width: 10in; /* Landscape width (11in - margins) */
|
||||
height: 7.5in; /* Landscape height (8.5in - margins) */
|
||||
width: 10.3in; /* 11in - 2 * 0.35in */
|
||||
height: 7.8in; /* 8.5in - 2 * 0.35in */
|
||||
margin: auto;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
@@ -26,6 +26,11 @@
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.bracket-container h4 {
|
||||
margin-top: 0.15rem;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
|
||||
/* Print-specific styles */
|
||||
@media print {
|
||||
/* Set orientation for portrait pages */
|
||||
@@ -51,6 +56,10 @@
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.bracket {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* Optional: Hide elements not needed in print */
|
||||
.no-print {
|
||||
display: none;
|
||||
@@ -62,15 +71,10 @@
|
||||
function scaleContent() {
|
||||
document.querySelectorAll('.page, .page-landscape').forEach(page => {
|
||||
const container = page.querySelector('.bracket-container');
|
||||
const isLandscape = page.classList.contains('page-landscape');
|
||||
|
||||
// Page dimensions (1 inch = 96px)
|
||||
const pageWidth = isLandscape ? 10 * 96 : 7.5 * 96;
|
||||
const pageHeight = isLandscape ? 7.5 * 96 : 10 * 96;
|
||||
|
||||
// Subtract margins (0.5 inch margin)
|
||||
const availableWidth = pageWidth - (0.5 * 96 * 2);
|
||||
const availableHeight = pageHeight - (0.5 * 96 * 2);
|
||||
// Use the actual page box size (already accounts for @page margins)
|
||||
const availableWidth = page.clientWidth;
|
||||
const availableHeight = page.clientHeight;
|
||||
|
||||
// Measure content dimensions
|
||||
const contentWidth = container.scrollWidth;
|
||||
@@ -80,8 +84,8 @@
|
||||
const scaleX = availableWidth / contentWidth;
|
||||
const scaleY = availableHeight / contentHeight;
|
||||
|
||||
// Use a slightly relaxed scaling to avoid over-aggressive shrinking
|
||||
const scale = Math.min(scaleX, scaleY, 1); // Ensure scale does not exceed 100% (1)
|
||||
// Keep a tiny buffer so borders/text don't clip at print edges
|
||||
const scale = Math.min(scaleX, scaleY, 1) * 0.99;
|
||||
|
||||
// Apply the scale
|
||||
container.style.transform = `scale(${scale})`;
|
||||
@@ -91,9 +95,9 @@
|
||||
const scaledWidth = contentWidth * scale;
|
||||
const scaledHeight = contentHeight * scale;
|
||||
|
||||
// Center the content within the page
|
||||
const horizontalPadding = (pageWidth - scaledWidth) / 2;
|
||||
const verticalPadding = (pageHeight - scaledHeight) / 2;
|
||||
// Center the content within the available page box
|
||||
const horizontalPadding = (availableWidth - scaledWidth) / 2;
|
||||
const verticalPadding = (availableHeight - scaledHeight) / 2;
|
||||
|
||||
// Apply margin adjustments
|
||||
container.style.marginLeft = `${Math.max(0, horizontalPadding)}px`;
|
||||
@@ -119,14 +123,15 @@
|
||||
<% @weights.sort_by{|w| w.max}.each do |weight| %>
|
||||
<% if @tournament.tournament_type == "Pool to bracket" %>
|
||||
<!-- Need to define what the tournaments#bracket controller defines -->
|
||||
<% @matches = @tournament.matches.select{|m| m.weight_id == weight.id} %>
|
||||
<% @wrestlers = Wrestler.where(weight_id: weight.id) %>
|
||||
<% @matches = @matches_by_weight_id[weight.id] || [] %>
|
||||
<% @wrestlers = @wrestlers_by_weight_id[weight.id] || [] %>
|
||||
<% @pools = weight.pool_rounds(@matches) %>
|
||||
<% @weight = weight %>
|
||||
<%= render 'bracket_partial' %>
|
||||
<% elsif @tournament.tournament_type.include? "Modified 16 Man Double Elimination" or @tournament.tournament_type.include? "Regular Double Elimination" %>
|
||||
<!-- Need to define what the tournaments#bracket controller defines -->
|
||||
<% @matches = weight.matches %>
|
||||
<% @matches = @matches_by_weight_id[weight.id] || [] %>
|
||||
<% @wrestlers = @wrestlers_by_weight_id[weight.id] || [] %>
|
||||
<% @weight = weight %>
|
||||
<%= render 'bracket_partial' %>
|
||||
<% end %>
|
||||
|
||||
@@ -55,10 +55,10 @@
|
||||
</style>
|
||||
|
||||
<% @matches.each do |match| %>
|
||||
<% if match.w1 && match.w2 %>
|
||||
<% w1 = Wrestler.find(match.w1) %>
|
||||
<% w2 = Wrestler.find(match.w2) %>
|
||||
<% end %>
|
||||
<% w1 = @wrestlers_by_id[match.w1] %>
|
||||
<% w2 = @wrestlers_by_id[match.w2] %>
|
||||
<% w1_name = w1&.name || match.loser1_name %>
|
||||
<% w2_name = w2&.name || match.loser2_name %>
|
||||
|
||||
<div class="pagebreak">
|
||||
<p><strong>Bout Number:</strong> <%= match.bout_number %> <strong>Weight Class:</strong> <%= match.weight.max %> <strong>Round:</strong> <%= match.round %> <strong>Bracket Position:</strong> <%= match.bracket_position %></p>
|
||||
@@ -69,10 +69,10 @@
|
||||
<tr class="small-row">
|
||||
<th class="fixed-width">Circle Winner</th>
|
||||
<th>
|
||||
<p><%= match.w1_name %>-<%= w1&.school&.name %></p>
|
||||
<p><%= w1_name %>-<%= w1&.school&.name %></p>
|
||||
</th>
|
||||
<th>
|
||||
<p><%= match.w2_name %>-<%= w2&.school&.name %></p>
|
||||
<p><%= w2_name %>-<%= w2&.school&.name %></p>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<% cache ["#{@weight.id}_bracket", @weight] do %>
|
||||
<% cache ["#{@weight.id}_bracket", @weight, params[:print].to_s] do %>
|
||||
<%= render 'bracket_partial' %>
|
||||
<% end %>
|
||||
<% if @tournament.tournament_type == "Pool to bracket" %>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
<%
|
||||
wrestlers_by_id = @tournament.wrestlers.index_by(&:id)
|
||||
weights_by_id = @tournament.weights.index_by(&:id)
|
||||
mats_by_id = @tournament.mats.index_by(&:id)
|
||||
sorted_matches = @tournament.matches.sort_by(&:bout_number)
|
||||
%>
|
||||
{
|
||||
"tournament": {
|
||||
"attributes": <%= @tournament.attributes.to_json %>,
|
||||
@@ -14,13 +20,13 @@
|
||||
"weight": wrestler.weight&.attributes
|
||||
}
|
||||
) }.to_json %>,
|
||||
"matches": <%= @tournament.matches.sort_by(&:bout_number).map { |match| match.attributes.merge(
|
||||
"matches": <%= sorted_matches.map { |match| match.attributes.merge(
|
||||
{
|
||||
"w1_name": Wrestler.find_by(id: match.w1)&.name,
|
||||
"w2_name": Wrestler.find_by(id: match.w2)&.name,
|
||||
"winner_name": Wrestler.find_by(id: match.winner_id)&.name,
|
||||
"weight": Weight.find_by(id: match.weight_id)&.attributes,
|
||||
"mat": Mat.find_by(id: match.mat_id)&.attributes
|
||||
"w1_name": wrestlers_by_id[match.w1]&.name,
|
||||
"w2_name": wrestlers_by_id[match.w2]&.name,
|
||||
"winner_name": wrestlers_by_id[match.winner_id]&.name,
|
||||
"weight": weights_by_id[match.weight_id]&.attributes,
|
||||
"mat": mats_by_id[match.mat_id]&.attributes
|
||||
}
|
||||
) }.to_json %>
|
||||
}
|
||||
|
||||
12
app/views/tournaments/live_scores.html.erb
Normal file
12
app/views/tournaments/live_scores.html.erb
Normal file
@@ -0,0 +1,12 @@
|
||||
<h1><%= @tournament.name %> Live Scores</h1>
|
||||
|
||||
<% if @mats.any? %>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(520px, 1fr)); gap: 20px; align-items: start;">
|
||||
<% @mats.each do |mat| %>
|
||||
<% match = mat.selected_scoreboard_match || mat.queue1_match %>
|
||||
<%= render "tournaments/live_score_card", mat: mat, match: match, tournament: @tournament %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p>No mats have been created for this tournament.</p>
|
||||
<% end %>
|
||||
@@ -1,13 +1,15 @@
|
||||
|
||||
|
||||
<h1>All <%= @tournament.name %> matches</h1>
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$('#matchesList').dataTable();
|
||||
pagingType: "bootstrap";
|
||||
} );
|
||||
</script>
|
||||
</br>
|
||||
|
||||
<% matches_path = "/tournaments/#{@tournament.id}/matches" %>
|
||||
|
||||
<%= form_tag(matches_path, method: :get, id: "search-form") do %>
|
||||
<%= text_field_tag :search, params[:search], placeholder: "Search wrestler, school, or bout #" %>
|
||||
<%= submit_tag "Search" %>
|
||||
<% end %>
|
||||
|
||||
<p>Search by wrestler name, school name, or bout number.</p>
|
||||
|
||||
<br>
|
||||
<table class="table table-striped table-bordered table-condensed" id="matchesList">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -35,6 +37,49 @@
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<% if @total_pages.present? && @total_pages > 1 %>
|
||||
<nav aria-label="Matches pagination">
|
||||
<ul class="pagination">
|
||||
<% if @page > 1 %>
|
||||
<li class="page-item">
|
||||
<%= link_to "Previous", { controller: "tournaments", action: "matches", id: @tournament.id, page: @page - 1, search: params[:search] }, class: "page-link" %>
|
||||
</li>
|
||||
<% else %>
|
||||
<li class="page-item disabled"><span class="page-link">Previous</span></li>
|
||||
<% end %>
|
||||
|
||||
<% window = 5
|
||||
left = [1, @page - window / 2].max
|
||||
right = [@total_pages, left + window - 1].min
|
||||
left = [1, right - window + 1].max
|
||||
%>
|
||||
<% (left..right).each do |p| %>
|
||||
<% if p == @page %>
|
||||
<li class="page-item active"><span class="page-link"><%= p %></span></li>
|
||||
<% else %>
|
||||
<li class="page-item"><%= link_to p, { controller: "tournaments", action: "matches", id: @tournament.id, page: p, search: params[:search] }, class: "page-link" %></li>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @page < @total_pages %>
|
||||
<li class="page-item">
|
||||
<%= link_to "Next", { controller: "tournaments", action: "matches", id: @tournament.id, page: @page + 1, search: params[:search] }, class: "page-link" %>
|
||||
</li>
|
||||
<% else %>
|
||||
<li class="page-item disabled"><span class="page-link">Next</span></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<p class="text-muted">
|
||||
<% start_index = ((@page - 1) * @per_page) + 1
|
||||
end_index = [@page * @per_page, @total_count].min
|
||||
%>
|
||||
Showing <%= start_index %> - <%= end_index %> of <%= @total_count %> matches
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<br>
|
||||
<p>Total matches without byes: <%= @matches.select{|m| m.loser1_name != 'BYE' and m.loser2_name != 'BYE'}.size %></p>
|
||||
<p>Unfinished matches: <%= @matches.select{|m| m.finished != 1 and m.loser1_name != 'BYE' and m.loser2_name != 'BYE'}.size %></p>
|
||||
<p>Total matches without byes: <%= @matches_without_byes_count %></p>
|
||||
<p>Unfinished matches: <%= @unfinished_matches_without_byes_count %></p>
|
||||
|
||||
50
app/views/tournaments/qrcode.html.erb
Normal file
50
app/views/tournaments/qrcode.html.erb
Normal file
@@ -0,0 +1,50 @@
|
||||
<style>
|
||||
.qr-page {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.qr-page h1 {
|
||||
margin: 0 0 24px 0;
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.qr-code {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.qr-code svg {
|
||||
width: min(80vmin, 720px);
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="qr-page">
|
||||
<h1><%= @tournament.name %> Brackets and Results Available Here</h1>
|
||||
<div class="qr-code">
|
||||
<%= raw @qrcode.as_svg(
|
||||
offset: 0,
|
||||
color: "000",
|
||||
shape_rendering: "crispEdges",
|
||||
module_size: 8,
|
||||
standalone: true
|
||||
) %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,13 +129,24 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Current Match</th>
|
||||
<th><%= link_to " New Mat" , "/mats/new?tournament=#{@tournament.id}", :class=>"fas fa-plus" %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @mats.each do |mat| %>
|
||||
<% current_match = mat.queue1_match %>
|
||||
<tr>
|
||||
<td><%= link_to "Mat #{mat.name}", mat %></td>
|
||||
<td>
|
||||
<% if current_match %>
|
||||
<%= link_to "Stat Match", stat_match_path(current_match), class: "btn btn-primary btn-sm" %>
|
||||
<%= link_to "State Match", state_mat_path(mat), class: "btn btn-success btn-sm" %>
|
||||
<%= link_to "Scoreboard", scoreboard_mat_path(mat, print: true), class: "btn btn-warning btn-sm", target: "_blank", rel: "noopener" %>
|
||||
<% else %>
|
||||
<%= link_to "Scoreboard", scoreboard_mat_path(mat, print: true), class: "btn btn-warning btn-sm", target: "_blank", rel: "noopener" %>
|
||||
<% end %>
|
||||
</td>
|
||||
<% if can? :manage, @tournament %>
|
||||
<td>
|
||||
<%= link_to mat, data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete Mat #{mat.name}?" }, class: "text-decoration-none" do %>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
|
||||
<% cache ["#{@tournament.id}_team_scores", @tournament] do %>
|
||||
|
||||
<% team_scores_last_updated = @schools.map(&:updated_at).compact.max&.utc&.to_fs(:nsec) %>
|
||||
<% cache ["team_scores", @tournament.id, @schools.size, team_scores_last_updated] do %>
|
||||
<table class="pagebreak table table-striped table-bordered">
|
||||
<h3>Team Scores</h3>
|
||||
<thead>
|
||||
@@ -11,11 +10,8 @@
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% @schools.each do |school| %>
|
||||
<tr>
|
||||
<td><%= @schools.index(school) + 1 %>. <%= school.name %> (<%= school.abbreviation %>)</td>
|
||||
<td><%= school.page_score_string %></td>
|
||||
</tr>
|
||||
<% @schools.each_with_index do |school, index| %>
|
||||
<%= render "tournaments/team_score_row", school: school, rank: index + 1 %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,107 +1,19 @@
|
||||
<% cache ["#{@tournament.id}_up_matches", @tournament] do %>
|
||||
<script>
|
||||
// $(document).ready(function() {
|
||||
// $('#matchList').dataTable();
|
||||
// } );
|
||||
</script>
|
||||
<script>
|
||||
const setUpMatchesRefresh = () => {
|
||||
if (window.__upMatchesRefreshTimeout) {
|
||||
clearTimeout(window.__upMatchesRefreshTimeout);
|
||||
}
|
||||
window.__upMatchesRefreshTimeout = setTimeout(() => {
|
||||
window.location.reload(true);
|
||||
}, 30000);
|
||||
};
|
||||
<div data-controller="up-matches-connection">
|
||||
<% if params[:print] != "true" %>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<%= link_to "Show Bout Board in Full Screen", up_matches_path(@tournament, print: true), class: "btn btn-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
document.addEventListener("turbo:load", setUpMatchesRefresh);
|
||||
// turbo:before-cache stops the timer refresh from occurring if you navigate away from up_matches
|
||||
document.addEventListener("turbo:before-cache", () => {
|
||||
if (window.__upMatchesRefreshTimeout) {
|
||||
clearTimeout(window.__upMatchesRefreshTimeout);
|
||||
window.__upMatchesRefreshTimeout = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<br>
|
||||
<br>
|
||||
<h5 style="color:red">This page reloads every 30s</h5>
|
||||
<br>
|
||||
<h3>Upcoming Matches</h3>
|
||||
<br>
|
||||
<table class="table table-striped table-bordered table-condensed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mat </th>
|
||||
<th>On Mat</th>
|
||||
<th>On Deck</th>
|
||||
<th>In The Hole</th>
|
||||
<th>Warm Up</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<%= turbo_stream_from @tournament, data: { up_matches_connection_target: "stream" } %>
|
||||
<div
|
||||
id="up-matches-cable-status-indicator"
|
||||
data-up-matches-connection-target="statusIndicator"
|
||||
class="alert alert-secondary"
|
||||
style="padding: 5px; margin-bottom: 10px; border-radius: 4px;"
|
||||
>
|
||||
Connecting to server for real-time up matches updates...
|
||||
</div>
|
||||
|
||||
<tbody>
|
||||
<% @mats.each.map do |m| %>
|
||||
<tr>
|
||||
<td><%= m.name %></td>
|
||||
<td>
|
||||
<% if m.queue1_match %><strong><%=m.queue1_match.bout_number%></strong> (<%= m.queue1_match.bracket_position %>)<br>
|
||||
<%= m.queue1_match.weight_max %> lbs
|
||||
<br><%= m.queue1_match.w1_bracket_name %> vs. <br>
|
||||
<%= m.queue1_match.w2_bracket_name %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<% if m.queue2_match %><strong><%=m.queue2_match.bout_number%></strong> (<%= m.queue2_match.bracket_position %>)<br>
|
||||
<%= m.queue2_match.weight_max %> lbs
|
||||
<br><%= m.queue2_match.w1_bracket_name %> vs. <br>
|
||||
<%= m.queue2_match.w2_bracket_name %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<% if m.queue3_match %><strong><%=m.queue3_match.bout_number%></strong> (<%= m.queue3_match.bracket_position %>)<br>
|
||||
<%= m.queue3_match.weight_max %> lbs
|
||||
<br><%= m.queue3_match.w1_bracket_name %> vs. <br>
|
||||
<%= m.queue3_match.w2_bracket_name %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<% if m.queue4_match %><strong><%=m.queue4_match.bout_number%></strong> (<%= m.queue4_match.bracket_position %>)<br>
|
||||
<%= m.queue4_match.weight_max %> lbs
|
||||
<br><%= m.queue4_match.w1_bracket_name %> vs. <br>
|
||||
<%= m.queue4_match.w2_bracket_name %>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<br>
|
||||
<h3>Matches not assigned</h3>
|
||||
<br>
|
||||
<table class="table table-striped table-bordered table-condensed" id="matchList">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Round</th>
|
||||
<th>Bout Number</th>
|
||||
<th>Weight Class</th>
|
||||
<th>Matchup</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<% if @matches.size > 0 %>
|
||||
<% @matches.each.map do |m| %>
|
||||
<tr>
|
||||
<td>Round <%= m.round %></td>
|
||||
<td><%= m.bout_number %></td>
|
||||
<td><%= m.weight_max %></td>
|
||||
<td><%= m.w1_bracket_name %> vs. <%= m.w2_bracket_name %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
<% end %>
|
||||
<%= render "up_matches_board", tournament: @tournament, mats: @mats, matches: @matches %>
|
||||
</div>
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
height: 1in;
|
||||
}
|
||||
</style>
|
||||
<% @tournament.schools.each do |school| %>
|
||||
<% @schools.each do |school| %>
|
||||
<table class="table table-striped table-bordered table-condensed pagebreak">
|
||||
<h5><%= school.name %></h4>
|
||||
<p><strong>Weigh In Ref:</strong> <%= @tournament.weigh_in_ref %></p>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@@ -27,7 +28,7 @@
|
||||
<tr>
|
||||
<td><%= wrestler.name %></td>
|
||||
<td><%= wrestler.weight.max %></td>
|
||||
<td></td>
|
||||
<td><%= wrestler.offical_weight %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= form_tag @wrestlers_update_path do %>
|
||||
<%= form_tag "/tournaments/#{@tournament.id}/weigh_in/#{@weight.id}", method: :post do %>
|
||||
<% @wrestlers.order("original_seed asc").each do |wrestler| %>
|
||||
<% if wrestler.weight_id == @weight.id %>
|
||||
<tr>
|
||||
@@ -19,7 +19,7 @@
|
||||
<td><%= wrestler.weight.max %></td>
|
||||
<td>
|
||||
<% if user_signed_in? %>
|
||||
<%= fields_for "wrestler[]", wrestler do |w| %>
|
||||
<%= fields_for "wrestler[#{wrestler.id}]", wrestler do |w| %>
|
||||
<%= w.number_field :offical_weight, :step => 'any' %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
|
||||
10
app/views/weights/_readonly_wrestler_row.html.erb
Normal file
10
app/views/weights/_readonly_wrestler_row.html.erb
Normal file
@@ -0,0 +1,10 @@
|
||||
<% cache ["weight_show_wrestler_row", wrestler] do %>
|
||||
<tr>
|
||||
<td><%= link_to wrestler.name, wrestler %></td>
|
||||
<td><%= wrestler.school.name %></td>
|
||||
<td><%= wrestler.original_seed %></td>
|
||||
<td><%= wrestler.season_win %>-<%= wrestler.season_loss %></td>
|
||||
<td><%= wrestler.criteria %> Win <%= wrestler.season_win_percentage %>%</td>
|
||||
<td><%= "Yes" if wrestler.extra? %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
@@ -14,35 +14,36 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @wrestlers.sort_by{|w| [w.original_seed ? 0 : 1, w.original_seed || 0]}.each do |wrestler| %>
|
||||
<% sorted_wrestlers = @wrestlers.sort_by{|w| [w.original_seed ? 0 : 1, w.original_seed || 0]} %>
|
||||
<% if can? :manage, @tournament %>
|
||||
<% sorted_wrestlers.each do |wrestler| %>
|
||||
<% if wrestler.weight_id == @weight.id %>
|
||||
<tr>
|
||||
<td><%= link_to "#{wrestler.name}", wrestler %></td>
|
||||
<td><%= link_to wrestler.name, wrestler %></td>
|
||||
<td><%= wrestler.school.name %></td>
|
||||
<td>
|
||||
<% if can? :manage, @tournament %>
|
||||
<%= fields_for "wrestler[]", wrestler do |w| %>
|
||||
<%= w.text_field :original_seed %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= wrestler.original_seed %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td><%= wrestler.season_win %>-<%= wrestler.season_loss %></td>
|
||||
<td><%= wrestler.criteria %> Win <%= wrestler.season_win_percentage %>%</td>
|
||||
<td><% if wrestler.extra? == true %>
|
||||
Yes
|
||||
<% end %></td>
|
||||
<% if can? :manage, @tournament %>
|
||||
<td><%= "Yes" if wrestler.extra? %></td>
|
||||
<td>
|
||||
<%= link_to wrestler, data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete #{wrestler.name}? THIS WILL DELETE ALL MATCHES." }, class: "text-decoration-none" do %>
|
||||
<span class="fas fa-trash-alt" aria-hidden="true"></span>
|
||||
<% end %>
|
||||
</td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<% sorted_wrestlers.each do |wrestler| %>
|
||||
<% if wrestler.weight_id == @weight.id %>
|
||||
<%= render "weights/readonly_wrestler_row", wrestler: wrestler %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<br><p>*All wrestlers without a seed (determined by tournament director) will be assigned a random bracket line.</p>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
project_dir="$(dirname $( dirname $(readlink -f ${BASH_SOURCE[0]})))"
|
||||
|
||||
cd ${project_dir}
|
||||
npm install
|
||||
npm run test:js
|
||||
bundle exec rake db:migrate RAILS_ENV=test
|
||||
CI=true brakeman
|
||||
bundle audit
|
||||
|
||||
@@ -3,4 +3,4 @@ project_dir="$(dirname $(readlink -f ${BASH_SOURCE[0]}))/.."
|
||||
|
||||
docker build -f ${project_dir}/deploy/rails-prod-Dockerfile -t wrestlingdevtests ${project_dir}/.
|
||||
docker run --rm -it wrestlingdevtests bash /rails/bin/run-all-tests.sh
|
||||
bash ${project_dir}/cypress-tests/run-cypress-tests.sh
|
||||
# bash ${project_dir}/cypress-tests/run-cypress-tests.sh
|
||||
|
||||
@@ -25,9 +25,6 @@ module Wrestling
|
||||
config.active_record.schema_format = :ruby
|
||||
config.active_record.dump_schemas = :all
|
||||
|
||||
# Fix deprecation warning for to_time in Rails 8.1
|
||||
config.active_support.to_time_preserves_timezone = :zone
|
||||
|
||||
# Please, add to the `ignore` list any other `lib` subdirectories that do
|
||||
# not contain `.rb` files, or that should not be reloaded or eager loaded.
|
||||
# Common ones are `templates`, `generators`, or `middleware`, for example.
|
||||
@@ -57,6 +54,5 @@ module Wrestling
|
||||
config.active_support.cache_format_version = 7.1
|
||||
|
||||
config.load_defaults 8.1
|
||||
config.active_support.to_time_preserves_timezone = :zone
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,15 +11,21 @@ pin "@rails/actioncable", to: "actioncable.esm.js" # For Action Cable
|
||||
# and pin it directly, e.g., pin "jquery", to: "jquery.min.js"
|
||||
pin "jquery", to: "jquery.js"
|
||||
|
||||
# Pin Bootstrap and DataTables from vendor/assets/javascripts/
|
||||
# Pin Bootstrap from vendor/assets/javascripts/
|
||||
pin "bootstrap", to: "bootstrap.min.js"
|
||||
pin "datatables.net", to: "jquery.dataTables.min.js" # Assuming this is how you want to import it
|
||||
|
||||
# If Bootstrap requires Popper.js, and you have it in vendor/assets/javascripts/
|
||||
# pin "@popperjs/core", to: "popper.min.js" # Or the actual filename if different
|
||||
|
||||
# Pin controllers from app/assets/javascripts/controllers
|
||||
pin_all_from "app/assets/javascripts/controllers", under: "controllers"
|
||||
pin "match-state-config", to: "lib/match_state/config.js"
|
||||
pin "match-state-engine", to: "lib/match_state/engine.js"
|
||||
pin "match-state-serializers", to: "lib/match_state/serializers.js"
|
||||
pin "match-state-presenters", to: "lib/match_state/presenters.js"
|
||||
pin "match-state-transport", to: "lib/match_state/transport.js"
|
||||
pin "match-state-scoreboard-presenters", to: "lib/match_state/scoreboard_presenters.js"
|
||||
pin "match-state-scoreboard-state", to: "lib/match_state/scoreboard_state.js"
|
||||
|
||||
# Pin all JS files from app/assets/javascripts directory
|
||||
pin_all_from "app/assets/javascripts", under: "assets/javascripts"
|
||||
@@ -3,12 +3,19 @@ Wrestling::Application.routes.draw do
|
||||
mount ActionCable.server => '/cable'
|
||||
mount MissionControl::Jobs::Engine, at: "/jobs"
|
||||
|
||||
resources :mats
|
||||
resources :mats do
|
||||
member do
|
||||
get :state
|
||||
get :scoreboard
|
||||
post :select_match
|
||||
end
|
||||
end
|
||||
post "mats/:id/assign_next_match" => "mats#assign_next_match", :as => :assign_next_match
|
||||
|
||||
resources :matches do
|
||||
member do
|
||||
get :stat
|
||||
get :state
|
||||
get :spectate
|
||||
get :edit_assignment
|
||||
patch :update_assignment
|
||||
@@ -73,6 +80,8 @@ Wrestling::Application.routes.draw do
|
||||
get 'tournaments/:id/bout_sheets' => 'tournaments#bout_sheets'
|
||||
get 'tournaments/:id/no_matches' => 'tournaments#no_matches'
|
||||
get 'tournaments/:id/matches' => 'tournaments#matches'
|
||||
get 'tournaments/:id/qrcode' => 'tournaments#qrcode'
|
||||
get 'tournaments/:id/live_scores' => 'tournaments#live_scores'
|
||||
get 'tournaments/:id/delegate' => 'tournaments#delegate', :as => :tournament_delegate
|
||||
post 'tournaments/:id/delegate' => 'tournaments#delegate', :as => :set_tournament_delegate
|
||||
delete 'tournaments/:id/:delegate/remove_delegate' => 'tournaments#remove_delegate', :as => :delete_delegate_path
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: mariadb-replica-watcher
|
||||
labels:
|
||||
app: wrestlingdev
|
||||
component: mariadb-watcher
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: wrestlingdev
|
||||
component: mariadb-watcher
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: wrestlingdev
|
||||
component: mariadb-watcher
|
||||
spec:
|
||||
containers:
|
||||
- name: replica-watcher
|
||||
image: mariadb:10.3
|
||||
env:
|
||||
- name: MARIADB_ROOT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wrestlingdev-secrets
|
||||
key: dbpassword
|
||||
- name: MYSQL_REPLICATION_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wrestlingdev-secrets
|
||||
key: replication_user
|
||||
- name: MYSQL_REPLICATION_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wrestlingdev-secrets
|
||||
key: replication_password
|
||||
- name: MASTER_SERVICE_HOST
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wrestlingdev-secrets
|
||||
key: replication_host
|
||||
- name: REPLICA_SERVICE_HOST
|
||||
value: "wrestlingdev-mariadb"
|
||||
- name: DB_NAME
|
||||
value: "wrestlingdev"
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
set -euo pipefail
|
||||
LOG=/var/log/replica-watcher.log
|
||||
echo "replica-watcher starting: $(date -u)" >>"$LOG"
|
||||
|
||||
trim() { sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'; }
|
||||
get_val() {
|
||||
grep -m1 -E "^[[:space:]]*$1[[:space:]]*:" \
|
||||
| sed -E "s/^[[:space:]]*$1[[:space:]]*:[[:space:]]*(.*)$/\1/" \
|
||||
| tr -d '\r' \
|
||||
| xargs
|
||||
}
|
||||
|
||||
# initial wait
|
||||
sleep 120
|
||||
while true; do
|
||||
echo "$(date -u) Checking SHOW SLAVE STATUS" | tee -a "$LOG"
|
||||
SLAVE_RAW=$(mysql --protocol=TCP -h "$REPLICA_SERVICE_HOST" -uroot -p"$MARIADB_ROOT_PASSWORD" -e "SHOW SLAVE STATUS\\G" 2>>"$LOG" || true)
|
||||
|
||||
NEED=0
|
||||
if [ -z "$SLAVE_RAW" ]; then
|
||||
echo "SHOW SLAVE STATUS is empty (replication not configured / not running) -> will rebootstrap" | tee -a "$LOG"
|
||||
NEED=1
|
||||
else
|
||||
SLAVE_IO=$(echo "$SLAVE_RAW" | get_val "Slave_IO_Running")
|
||||
SLAVE_SQL=$(echo "$SLAVE_RAW" | get_val "Slave_SQL_Running")
|
||||
LAST_IO_ERRNO=$(echo "$SLAVE_RAW" | get_val "Last_IO_Errno")
|
||||
LAST_SQL_ERRNO=$(echo "$SLAVE_RAW" | get_val "Last_SQL_Errno")
|
||||
LAST_IO_ERR=$(echo "$SLAVE_RAW" | get_val "Last_IO_Error")
|
||||
LAST_SQL_ERR=$(echo "$SLAVE_RAW" | get_val "Last_SQL_Error")
|
||||
|
||||
echo "Slave IO='${SLAVE_IO:-}' Slave SQL='${SLAVE_SQL:-}'" | tee -a "$LOG"
|
||||
echo "Last_IO_Errno='${LAST_IO_ERRNO:-}' Last_SQL_Errno='${LAST_SQL_ERRNO:-}'" | tee -a "$LOG"
|
||||
echo "Last_IO_Error='${LAST_IO_ERR:-}' Last_SQL_Error='${LAST_SQL_ERR:-}'" | tee -a "$LOG"
|
||||
|
||||
if [ "${SLAVE_IO:-}" = "Yes" ] && [ "${SLAVE_SQL:-}" = "Yes" ] \
|
||||
&& { [ -z "${LAST_IO_ERR:-}" ] || [ "${LAST_IO_ERR,,}" = "no error" ]; } \
|
||||
&& { [ -z "${LAST_SQL_ERR:-}" ] || [ "${LAST_SQL_ERR,,}" = "no error" ]; } \
|
||||
&& { [ -z "${LAST_IO_ERRNO:-}" ] || [ "${LAST_IO_ERRNO:-0}" = "0" ]; } \
|
||||
&& { [ -z "${LAST_SQL_ERRNO:-}" ] || [ "${LAST_SQL_ERRNO:-0}" = "0" ]; }; then
|
||||
echo "Both slave threads running and no replication errors -> no action" | tee -a "$LOG"
|
||||
else
|
||||
NOT_RUNNING=0
|
||||
[ "${SLAVE_IO:-No}" != "Yes" ] && NOT_RUNNING=1
|
||||
[ "${SLAVE_SQL:-No}" != "Yes" ] && NOT_RUNNING=1
|
||||
HAS_ERROR=0
|
||||
[ -n "${LAST_IO_ERRNO:-}" ] && [ "${LAST_IO_ERRNO:-0}" != "0" ] && HAS_ERROR=1
|
||||
[ -n "${LAST_SQL_ERRNO:-}" ] && [ "${LAST_SQL_ERRNO:-0}" != "0" ] && HAS_ERROR=1
|
||||
ERR_TEXT="$(printf '%s %s' "${LAST_IO_ERR:-}" "${LAST_SQL_ERR:-}" | tr '[:upper:]' '[:lower:]' | trim)"
|
||||
[ -n "$ERR_TEXT" ] && [ "$ERR_TEXT" != "no error" ] && HAS_ERROR=1
|
||||
|
||||
echo "Decision: NOT_RUNNING=$NOT_RUNNING HAS_ERROR=$HAS_ERROR" | tee -a "$LOG"
|
||||
[ $NOT_RUNNING -eq 1 ] || [ $HAS_ERROR -eq 1 ] && NEED=1 || echo "Threads healthy -> no action" | tee -a "$LOG"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ $NEED -eq 1 ]; then
|
||||
echo "$(date -u) Starting rebootstrap flow" | tee -a "$LOG"
|
||||
|
||||
MASTER_STATUS=$(mysql --protocol=TCP -h "$MASTER_SERVICE_HOST" -uroot -p"$MARIADB_ROOT_PASSWORD" -sse "SHOW MASTER STATUS;" 2>>"$LOG" || true)
|
||||
MASTER_LOG_FILE=$(echo "$MASTER_STATUS" | awk '{print $1}' | trim || true)
|
||||
MASTER_LOG_POS=$(echo "$MASTER_STATUS" | awk '{print $2}' | trim || true)
|
||||
if [ -z "$MASTER_LOG_FILE" ] || [ -z "$MASTER_LOG_POS" ]; then
|
||||
echo "Failed to get master position from $MASTER_SERVICE_HOST" | tee -a "$LOG"
|
||||
sleep 120; continue
|
||||
fi
|
||||
echo "Master position: ${MASTER_LOG_FILE}:${MASTER_LOG_POS}" | tee -a "$LOG"
|
||||
|
||||
echo "Stopping slave on replica host" | tee -a "$LOG"
|
||||
mysql --protocol=TCP -h "$REPLICA_SERVICE_HOST" -uroot -p"$MARIADB_ROOT_PASSWORD" -e "STOP SLAVE;" >>"$LOG" 2>&1 || true
|
||||
|
||||
DUMP_FILE="/tmp/${DB_NAME}_backup.sql"
|
||||
echo "Dumping ${DB_NAME} from master ${MASTER_SERVICE_HOST}" | tee -a "$LOG"
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
if ! timeout 300 mysqldump --protocol=TCP -h "$MASTER_SERVICE_HOST" -uroot -p"$MARIADB_ROOT_PASSWORD" --single-transaction "$DB_NAME" \
|
||||
| tee "$DUMP_FILE" >/dev/null 2>>"$LOG"; then
|
||||
echo "Dump FAILED; aborting this cycle" | tee -a "$LOG"; sleep 120; continue
|
||||
fi
|
||||
else
|
||||
if ! mysqldump --protocol=TCP -h "$MASTER_SERVICE_HOST" -uroot -p"$MARIADB_ROOT_PASSWORD" --single-transaction "$DB_NAME" \
|
||||
| tee "$DUMP_FILE" >/dev/null 2>>"$LOG"; then
|
||||
echo "Dump FAILED; aborting this cycle" | tee -a "$LOG"; sleep 120; continue
|
||||
fi
|
||||
fi
|
||||
|
||||
ls -lh $DUMP_FILE
|
||||
|
||||
echo "Ensuring database '$DB_NAME' exists on replica" | tee -a "$LOG"
|
||||
mysql --protocol=TCP -h "$REPLICA_SERVICE_HOST" -uroot -p"$MARIADB_ROOT_PASSWORD" \
|
||||
-e "CREATE DATABASE IF NOT EXISTS \`$DB_NAME\`;" >>"$LOG" 2>&1
|
||||
|
||||
echo "Importing dump into replica host" | tee -a "$LOG"
|
||||
if ! cat "$DUMP_FILE" | mysql --protocol=TCP -h "$REPLICA_SERVICE_HOST" -uroot -p"$MARIADB_ROOT_PASSWORD" "$DB_NAME" >>"$LOG" 2>&1; then
|
||||
echo "Import FAILED; aborting this cycle (replication will not be reconfigured)" | tee -a "$LOG"
|
||||
sleep 120; continue
|
||||
fi
|
||||
echo "Import completed successfully" | tee -a "$LOG"
|
||||
|
||||
echo "Reconfiguring replication to ${MASTER_SERVICE_HOST}:${MASTER_LOG_FILE}:${MASTER_LOG_POS}" | tee -a "$LOG"
|
||||
mysql --protocol=TCP -h "$REPLICA_SERVICE_HOST" -uroot -p"$MARIADB_ROOT_PASSWORD" -e "RESET SLAVE ALL;" >>"$LOG" 2>&1 || true
|
||||
mysql --protocol=TCP -h "$REPLICA_SERVICE_HOST" -uroot -p"$MARIADB_ROOT_PASSWORD" -e "CHANGE MASTER TO MASTER_HOST='${MASTER_SERVICE_HOST}', MASTER_USER='${MYSQL_REPLICATION_USER}', MASTER_PASSWORD='${MYSQL_REPLICATION_PASSWORD}', MASTER_LOG_FILE='${MASTER_LOG_FILE}', MASTER_LOG_POS=${MASTER_LOG_POS}; START SLAVE;" >>"$LOG" 2>&1 || true
|
||||
|
||||
echo "SHOW SLAVE STATUS after rebootstrap:" | tee -a "$LOG"
|
||||
mysql --protocol=TCP -h "$REPLICA_SERVICE_HOST" -uroot -p"$MARIADB_ROOT_PASSWORD" -e "SHOW SLAVE STATUS\\G" >>"$LOG" 2>&1 || true
|
||||
fi
|
||||
|
||||
echo "Sleeping 120s before next check" | tee -a "$LOG"
|
||||
sleep 120
|
||||
done
|
||||
restartPolicy: Always
|
||||
@@ -27,17 +27,19 @@ spec:
|
||||
storage: 20Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: wrestlingdev-mariadb
|
||||
labels:
|
||||
app: wrestlingdev
|
||||
spec:
|
||||
replicas: 1
|
||||
serviceName: wrestlingdev-mariadb
|
||||
selector:
|
||||
matchLabels:
|
||||
app: wrestlingdev
|
||||
strategy:
|
||||
type: Recreate
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
@@ -47,6 +49,43 @@ spec:
|
||||
prometheus.io/port: "9125"
|
||||
prometheus.io/scrape: "true"
|
||||
spec:
|
||||
initContainers:
|
||||
- name: bootstrap
|
||||
image: mariadb:10.3
|
||||
env:
|
||||
- name: MARIADB_ROOT_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wrestlingdev-secrets
|
||||
key: dbpassword
|
||||
- name: MASTER_HOST
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wrestlingdev-secrets
|
||||
key: replication_host
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
if [ -d /var/lib/mysql/mysql ]; then
|
||||
echo "Data directory already initialized, skipping bootstrap"
|
||||
exit 0
|
||||
fi
|
||||
echo "Fresh data directory — bootstrapping replica from ${MASTER_HOST}"
|
||||
DBS=$(mysql --protocol=TCP -h "$MASTER_HOST" -uroot -p"$MARIADB_ROOT_PASSWORD" \
|
||||
-e "SHOW DATABASES;" --skip-column-names \
|
||||
| grep -Ev '^(information_schema|performance_schema|mysql|sys)$' \
|
||||
| tr '\n' ' ')
|
||||
echo "Dumping databases: ${DBS}"
|
||||
mysqldump --protocol=TCP -h "$MASTER_HOST" -uroot -p"$MARIADB_ROOT_PASSWORD" \
|
||||
--single-transaction --master-data=2 --gtid --databases $DBS \
|
||||
> /docker-entrypoint-initdb.d/dump.sql
|
||||
echo "Bootstrap dump complete"
|
||||
volumeMounts:
|
||||
- name: wrestlingdev-mariadb-persistent-storage
|
||||
mountPath: /var/lib/mysql
|
||||
- name: init-scripts
|
||||
mountPath: /docker-entrypoint-initdb.d
|
||||
containers:
|
||||
- image: mariadb:10.3
|
||||
name: mariadb
|
||||
@@ -56,6 +95,48 @@ spec:
|
||||
secretKeyRef:
|
||||
name: wrestlingdev-secrets
|
||||
key: dbpassword
|
||||
- name: MASTER_HOST
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wrestlingdev-secrets
|
||||
key: replication_host
|
||||
- name: MYSQL_REPLICATION_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wrestlingdev-secrets
|
||||
key: replication_user
|
||||
- name: MYSQL_REPLICATION_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: wrestlingdev-secrets
|
||||
key: replication_password
|
||||
lifecycle:
|
||||
postStart:
|
||||
exec:
|
||||
command:
|
||||
- bash
|
||||
- -c
|
||||
- |
|
||||
for i in $(seq 1 60); do
|
||||
mysqladmin ping -uroot -p"$MARIADB_ROOT_PASSWORD" --protocol=TCP -h 127.0.0.1 --silent && break
|
||||
sleep 2
|
||||
done
|
||||
SLAVE_STATUS=$(mysql -uroot -p"$MARIADB_ROOT_PASSWORD" -e "SHOW SLAVE STATUS\G" 2>/dev/null)
|
||||
SLAVE_IO=$(echo "$SLAVE_STATUS" | grep -m1 "Slave_IO_Running" | awk '{print $2}')
|
||||
SLAVE_SQL=$(echo "$SLAVE_STATUS" | grep -m1 "Slave_SQL_Running" | awk '{print $2}')
|
||||
if [ "${SLAVE_IO}" = "Yes" ] && [ "${SLAVE_SQL}" = "Yes" ]; then
|
||||
echo "Replication is already running"
|
||||
exit 0
|
||||
fi
|
||||
mysql -uroot -p"$MARIADB_ROOT_PASSWORD" -e "STOP SLAVE; RESET SLAVE ALL;"
|
||||
if [ -f /docker-entrypoint-initdb.d/dump.sql ]; then
|
||||
GTID_POS=$(grep -m1 "SET GLOBAL gtid_slave_pos" /docker-entrypoint-initdb.d/dump.sql | sed "s/.*gtid_slave_pos='\([^']*\)'.*/\1/")
|
||||
echo "Setting gtid_slave_pos from dump: '${GTID_POS}'"
|
||||
mysql -uroot -p"$MARIADB_ROOT_PASSWORD" -e "SET GLOBAL gtid_slave_pos='${GTID_POS}';"
|
||||
fi
|
||||
mysql -uroot -p"$MARIADB_ROOT_PASSWORD" \
|
||||
-e "CHANGE MASTER TO MASTER_HOST='${MASTER_HOST}', MASTER_USER='${MYSQL_REPLICATION_USER}', MASTER_PASSWORD='${MYSQL_REPLICATION_PASSWORD}', MASTER_USE_GTID=slave_pos;" \
|
||||
-e "START SLAVE;"
|
||||
ports:
|
||||
- containerPort: 3306
|
||||
name: mariadb
|
||||
@@ -64,6 +145,8 @@ spec:
|
||||
mountPath: /var/lib/mysql
|
||||
- name: mysettings-config-volume
|
||||
mountPath: /etc/mysql/mariadb.conf.d
|
||||
- name: init-scripts
|
||||
mountPath: /docker-entrypoint-initdb.d
|
||||
# resources:
|
||||
# limits:
|
||||
# memory: "512Mi"
|
||||
@@ -180,6 +263,8 @@ spec:
|
||||
- name: mysettings-config-volume
|
||||
configMap:
|
||||
name: mariadb-mysettings
|
||||
- name: init-scripts
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
@@ -191,29 +276,44 @@ metadata:
|
||||
data:
|
||||
70-mysettings.cnf: |
|
||||
[mariadb]
|
||||
# Slow log
|
||||
# Slow query log — records queries taking longer than long_query_time seconds
|
||||
slow_query_log=1
|
||||
#slow_query_log_file=/var/log/mariadb/slow.log
|
||||
slow_query_log_file=/var/lib/mysql/slow.log
|
||||
long_query_time=0.2
|
||||
|
||||
# mysqltunner recommendations
|
||||
# Max size for in-memory temp tables before spilling to disk
|
||||
tmp_table_size=32M
|
||||
max_heap_table_size=32M
|
||||
# Collect detailed query/table statistics (required by some monitoring tools)
|
||||
performance_schema=ON
|
||||
# Size of each InnoDB redo log file; increase for write-heavy workloads
|
||||
innodb_log_file_size=32M
|
||||
# Number of open table handles to cache; reduces overhead of reopening tables
|
||||
table_open_cache=4000
|
||||
# replica settings
|
||||
server_id=2 # Default server_id, can be overridden for master/slave
|
||||
log_bin=mysql-bin # Enable binary logging
|
||||
binlog_format=ROW # Recommended for replication
|
||||
log_slave_updates=ON # Ensure slaves log updates (useful for multi-source replication)
|
||||
sync_binlog=1 # Flush binary logs after each transaction for safety
|
||||
read_only=0 # Default, will be managed by the init script
|
||||
expire_logs_days=7 # Retain binary logs for 7 days
|
||||
|
||||
# if you want to ignore dbs to replicate
|
||||
# replicate-ignore-db=wrestlingtourney-queue
|
||||
# if you only want to replicate certain dbs
|
||||
# Replication (replica)
|
||||
# Must be unique and different from the master's server_id
|
||||
server_id=2
|
||||
# Enable binary logging on the replica (required for log_slave_updates)
|
||||
log_bin=mysql-bin
|
||||
# ROW format is safest: records exact row changes rather than SQL statements
|
||||
binlog_format=ROW
|
||||
# Write replicated events into this replica's own binlog (needed for chained replicas)
|
||||
log_slave_updates=ON
|
||||
# Enforce GTID consistency — rejects transactions that would break GTID sequences
|
||||
gtid_strict_mode=ON
|
||||
# Flush binlog to disk on every commit; prevents binlog loss on crash
|
||||
sync_binlog=1
|
||||
# Prevent accidental writes directly to the replica
|
||||
read_only=1
|
||||
# How many days to retain binary logs before automatic purge
|
||||
expire_logs_days=7
|
||||
|
||||
# Only replicate the application database — rails-specific: excludes the solid_queue DB so
|
||||
# background job workers can run independently on the replica cluster
|
||||
replicate-do-db=wrestlingdev
|
||||
# replicate-ignore-db=wrestlingtourney-queue
|
||||
|
||||
# /etc/mysql/mariadb.conf.d/70-mysettings.cnf
|
||||
|
||||
@@ -27,17 +27,19 @@ spec:
|
||||
storage: 20Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: wrestlingdev-mariadb
|
||||
labels:
|
||||
app: wrestlingdev
|
||||
spec:
|
||||
replicas: 1
|
||||
serviceName: wrestlingdev-mariadb
|
||||
selector:
|
||||
matchLabels:
|
||||
app: wrestlingdev
|
||||
strategy:
|
||||
type: Recreate
|
||||
updateStrategy:
|
||||
type: RollingUpdate
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
@@ -227,25 +229,39 @@ metadata:
|
||||
data:
|
||||
70-mysettings.cnf: |
|
||||
[mariadb]
|
||||
# Slow log
|
||||
# Slow query log — records queries taking longer than long_query_time seconds
|
||||
slow_query_log=1
|
||||
#slow_query_log_file=/var/log/mariadb/slow.log
|
||||
slow_query_log_file=/var/lib/mysql/slow.log
|
||||
long_query_time=0.2
|
||||
|
||||
# mysqltunner recommendations
|
||||
# Max size for in-memory temp tables before spilling to disk
|
||||
tmp_table_size=32M
|
||||
max_heap_table_size=32M
|
||||
# Collect detailed query/table statistics (required by some monitoring tools)
|
||||
performance_schema=ON
|
||||
# Size of each InnoDB redo log file; increase for write-heavy workloads
|
||||
innodb_log_file_size=32M
|
||||
# Number of open table handles to cache; reduces overhead of reopening tables
|
||||
table_open_cache=4000
|
||||
# How many days to retain general error/slow logs
|
||||
expire_logs_days=7
|
||||
|
||||
# master slave
|
||||
server_id=1 # Unique server ID for the master
|
||||
log_bin=mysql-bin # Enable binary logging
|
||||
binlog_format=ROW # Recommended format for replication (ROW, STATEMENT, or MIXED)
|
||||
log_slave_updates=ON # Ensure any changes replicated to the master are also logged to the binary log (useful for multi-source replication)
|
||||
sync_binlog=1 # Ensures binary logs are synchronized with disk after each transaction for data safety
|
||||
expire_logs_days=7 # Optional: Number of days to retain binary logs (helps with cleanup)
|
||||
# Replication (master)
|
||||
# Unique ID for this server across the whole replication topology
|
||||
server_id=1
|
||||
# Enable binary logging — required for replication
|
||||
log_bin=mysql-bin
|
||||
# ROW format is safest: records exact row changes rather than SQL statements
|
||||
binlog_format=ROW
|
||||
# Include replicated events in this server's own binlog (needed for chained replicas)
|
||||
log_slave_updates=ON
|
||||
# Enforce GTID consistency — rejects transactions that would break GTID sequences
|
||||
gtid_strict_mode=ON
|
||||
# Flush binlog to disk on every commit; prevents binlog loss on crash
|
||||
sync_binlog=1
|
||||
# How many days to retain binary logs before automatic purge
|
||||
expire_logs_days=7
|
||||
|
||||
# /etc/mysql/mariadb.conf.d/70-mysettings.cnf
|
||||
# /etc/mysql/mariadb.conf.d/70-mysettings.cnf is included by the main config and will override any conflicting settings in the default config files. This allows us to customize settings without modifying the base image.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM ruby:3.2.0
|
||||
FROM ruby:4.0.1
|
||||
|
||||
# Accept build arguments for user/group IDs
|
||||
ARG USER_ID=1000
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM ruby:3.2.0-slim
|
||||
FROM ruby:4.0.1-slim
|
||||
|
||||
#HEALTHCHECK --start-period=60s CMD curl http://127.0.0.1/
|
||||
|
||||
@@ -15,6 +15,8 @@ RUN apt-get -qq update --fix-missing \
|
||||
libsqlite3-dev \
|
||||
wget \
|
||||
default-libmysqlclient-dev \
|
||||
libyaml-dev \
|
||||
pkg-config \
|
||||
nodejs \
|
||||
tzdata \
|
||||
git \
|
||||
|
||||
2162
package-lock.json
generated
Normal file
2162
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user