1
0
mirror of https://github.com/jcwimer/wrestlingApp synced 2026-04-11 16:01:56 +00:00

9 Commits

124 changed files with 12434 additions and 1239 deletions

4
.gitignore vendored
View File

@@ -21,6 +21,7 @@ tmp
.rvmrc .rvmrc
deploy/prod.env deploy/prod.env
frontend/node_modules frontend/node_modules
node_modules
.aider* .aider*
# Ignore cypress test results # Ignore cypress test results
@@ -34,3 +35,6 @@ cypress-tests/cypress/videos
# generated by cine mcp settings # generated by cine mcp settings
~/ ~/
/.ruby-lsp
.codex

11
AGENTS.md Normal file
View 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.

View File

@@ -5,7 +5,6 @@ import "@hotwired/turbo-rails";
import { createConsumer } from "@rails/actioncable"; // Import createConsumer directly import { createConsumer } from "@rails/actioncable"; // Import createConsumer directly
import "jquery"; import "jquery";
import "bootstrap"; import "bootstrap";
import "datatables.net";
// Stimulus setup // Stimulus setup
import { Application } from "@hotwired/stimulus"; import { Application } from "@hotwired/stimulus";
@@ -19,13 +18,21 @@ window.Stimulus = application;
import WrestlerColorController from "controllers/wrestler_color_controller"; import WrestlerColorController from "controllers/wrestler_color_controller";
import MatchScoreController from "controllers/match_score_controller"; import MatchScoreController from "controllers/match_score_controller";
import MatchDataController from "controllers/match_data_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 MatchSpectateController from "controllers/match_spectate_controller";
import UpMatchesConnectionController from "controllers/up_matches_connection_controller";
// Register controllers // Register controllers
application.register("wrestler-color", WrestlerColorController); application.register("wrestler-color", WrestlerColorController);
application.register("match-score", MatchScoreController); application.register("match-score", MatchScoreController);
application.register("match-data", MatchDataController); 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("match-spectate", MatchSpectateController);
application.register("up-matches-connection", UpMatchesConnectionController);
// Your existing Action Cable consumer setup // Your existing Action Cable consumer setup
(function() { (function() {
@@ -39,7 +46,7 @@ application.register("match-spectate", MatchSpectateController);
} }
}).call(this); }).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 // 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. // handled by Sprockets `require_tree`, you'll need to import them here explicitly.

View File

@@ -0,0 +1,163 @@
import { Controller } from "@hotwired/stimulus"
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 {
window.localStorage.setItem(this.storageKey(), JSON.stringify({
boutNumber: this.boutNumberValue,
matchId: this.matchIdValue,
updatedAt: Date.now()
}))
} 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) {
window.localStorage.setItem(this.lastMatchResultStorageKey(), text)
} else {
window.localStorage.removeItem(this.lastMatchResultStorageKey())
}
} catch (_error) {
}
}
loadLastMatchResult() {
try {
return window.localStorage.getItem(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
}
}

View File

@@ -2,25 +2,44 @@ import { Controller } from "@hotwired/stimulus"
export default class extends Controller { export default class extends Controller {
static targets = [ static targets = [
"winType", "winnerSelect", "submitButton", "dynamicScoreInput", "winType", "overtimeSelect", "winnerSelect", "submitButton", "dynamicScoreInput",
"finalScoreField", "validationAlerts", "pinTimeTip" "finalScoreField", "validationAlerts", "pinTimeTip"
] ]
static values = { static values = {
winnerScore: { type: String, default: "0" }, 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() { connect() {
console.log("Match score controller connected") 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(() => { setTimeout(() => {
this.updateScoreInput() this.updateScoreInput()
this.validateForm() this.validateForm()
}, 50) }, 50)
} }
disconnect() {
this.element.removeEventListener("input", this.boundMarkManualOverride)
this.element.removeEventListener("change", this.boundMarkManualOverride)
}
winTypeChanged() { winTypeChanged() {
if (this.finishedValue) {
this.validateForm()
return
}
this.updateScoreInput() this.updateScoreInput()
this.validateForm() this.validateForm()
} }
@@ -30,6 +49,7 @@ export default class extends Controller {
} }
updateScoreInput() { updateScoreInput() {
if (this.finishedValue) return
const winType = this.winTypeTarget.value const winType = this.winTypeTarget.value
this.dynamicScoreInputTarget.innerHTML = "" this.dynamicScoreInputTarget.innerHTML = ""
@@ -48,6 +68,9 @@ export default class extends Controller {
this.dynamicScoreInputTarget.appendChild(minuteInput) this.dynamicScoreInputTarget.appendChild(minuteInput)
this.dynamicScoreInputTarget.appendChild(secondInput) 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 // Add event listeners to the new inputs
const inputs = this.dynamicScoreInputTarget.querySelectorAll("input") const inputs = this.dynamicScoreInputTarget.querySelectorAll("input")
inputs.forEach(input => { inputs.forEach(input => {
@@ -111,6 +134,43 @@ export default class extends Controller {
this.validateForm() 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() { updatePinTimeScore() {
const minuteInput = this.dynamicScoreInputTarget.querySelector("#minutes") const minuteInput = this.dynamicScoreInputTarget.querySelector("#minutes")
const secondInput = this.dynamicScoreInputTarget.querySelector("#seconds") const secondInput = this.dynamicScoreInputTarget.querySelector("#seconds")

View File

@@ -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")}`
}
}

View File

@@ -3,7 +3,7 @@ import { Controller } from "@hotwired/stimulus"
export default class extends Controller { export default class extends Controller {
static targets = [ static targets = [
"w1Stats", "w2Stats", "winner", "winType", "w1Stats", "w2Stats", "winner", "winType",
"score", "finished", "statusIndicator" "score", "finished", "statusIndicator", "scoreboardContainer"
] ]
static values = { static values = {
@@ -134,6 +134,9 @@ export default class extends Controller {
if (data.finished !== undefined && this.hasFinishedTarget) { if (data.finished !== undefined && this.hasFinishedTarget) {
this.finishedTarget.textContent = data.finished ? 'Yes' : 'No' this.finishedTarget.textContent = data.finished ? 'Yes' : 'No'
if (this.hasScoreboardContainerTarget) {
this.scoreboardContainerTarget.style.display = data.finished ? 'none' : 'block'
}
} }
} }
} }

View File

@@ -0,0 +1,803 @@
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
} 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)
}
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
})
}
}

View File

@@ -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")
}
}

View 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
}
}

View 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_")
)
}

View 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
}

View 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
}
}

View 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
}

View 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] || {})
}
])
)
}
}

View File

@@ -0,0 +1,39 @@
export function loadJson(storage, key) {
try {
const rawValue = storage.getItem(key)
if (!rawValue) return null
return JSON.parse(rawValue)
} catch (_error) {
return null
}
}
export function saveJson(storage, key, value) {
try {
storage.setItem(key, JSON.stringify(value))
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
}

View 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

View File

@@ -1,4 +1,6 @@
class MatchChannel < ApplicationCable::Channel class MatchChannel < ApplicationCable::Channel
SCOREBOARD_CACHE_TTL = 1.hours
def subscribed def subscribed
@match = Match.find_by(id: params[:match_id]) @match = Match.find_by(id: params[:match_id])
Rails.logger.info "[MatchChannel] Client subscribed with match_id: #{params[:match_id]}. Match found: #{@match.present?}" 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
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 def unsubscribed
Rails.logger.info "[MatchChannel] Client unsubscribed for match #{@match&.id}" Rails.logger.info "[MatchChannel] Client unsubscribed for match #{@match&.id}"
end end
@@ -75,7 +90,8 @@ class MatchChannel < ApplicationCable::Channel
win_type: @match.win_type, win_type: @match.win_type,
winner_name: @match.winner&.name, winner_name: @match.winner&.name,
winner_id: @match.winner_id, winner_id: @match.winner_id,
finished: @match.finished finished: @match.finished,
scoreboard_state: Rails.cache.read(scoreboard_cache_key)
}.compact }.compact
if payload.present? 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." Rails.logger.info "[MatchChannel] request_sync payload empty for match #{@match.id}, not transmitting."
end end
end end
private
def scoreboard_cache_key
"tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state"
end
end end

View File

@@ -4,7 +4,7 @@ class MatAssignmentRulesController < ApplicationController
before_action :set_mat_assignment_rule, only: [:edit, :update, :destroy] before_action :set_mat_assignment_rule, only: [:edit, :update, :destroy]
def index 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 @weights_by_id = @tournament.weights.index_by(&:id) # For quick lookup
end end

View File

@@ -1,6 +1,7 @@
class MatchesController < ApplicationController class MatchesController < ApplicationController
before_action :set_match, only: [:show, :edit, :update, :stat, :spectate, :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, :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
# GET /matches/1.json # GET /matches/1.json
@@ -22,49 +23,12 @@ class MatchesController < ApplicationController
end end
def stat def stat
# @show_next_bout_button = false load_match_stat_context
if params[:match] end
@match = Match.where(:id => params[:match]).includes(:wrestlers).first
end def state
@wrestlers = [] load_match_stat_context
if @match @match_state_ruleset = "folkstyle_usa"
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"
end
end end
# GET /matches/:id/spectate # GET /matches/:id/spectate
@@ -142,26 +106,19 @@ class MatchesController < ApplicationController
win_type: @match.win_type, win_type: @match.win_type,
winner_id: @match.winner_id, winner_id: @match.winner_id,
winner_name: @match.winner&.name, 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] redirect_path = resolve_match_redirect_path(session[:return_path]) || "/tournaments/#{@match.tournament.id}"
sanitized_return_path = sanitize_return_path(session[:return_path]) format.html { redirect_to redirect_path, notice: 'Match was successfully updated.' }
format.html { redirect_to sanitized_return_path, notice: 'Match was successfully updated.' } session.delete(:return_path)
session.delete(:return_path) # Remove the session variable
else
format.html { redirect_to "/tournaments/#{@match.tournament.id}", notice: 'Match was successfully updated.' }
end
format.json { head :no_content } format.json { head :no_content }
else else
if session[:error_return_path] error_path = resolve_match_redirect_path(session[:error_return_path]) || "/tournaments/#{@match.tournament.id}"
format.html { redirect_to session.delete(:error_return_path), alert: "Match did not save because: #{@match.errors.full_messages.to_s}" } 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 } 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 end
end end
@@ -182,11 +139,66 @@ class MatchesController < ApplicationController
authorize! :manage, @match.tournament authorize! :manage, @match.tournament
end 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) uri = URI.parse(path)
params = Rack::Utils.parse_nested_query(uri.query) return nil if uri.scheme.present? || uri.host.present?
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
uri.to_s # Return the full path as a string 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
end end

View File

@@ -1,22 +1,21 @@
class MatsController < ApplicationController class MatsController < ApplicationController
before_action :set_mat, only: [:show, :edit, :update, :destroy, :assign_next_match] 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, :assign_next_match] before_action :check_access, only: [:new,:create,:update,:destroy,:edit,:show, :state, :scoreboard, :assign_next_match, :select_match]
before_action :check_for_matches, only: [:show]
# GET /mats/1 # GET /mats/1
# GET /mats/1.json # GET /mats/1.json
def show def show
bout_number_param = params[:bout_number] # Read the bout_number from the URL params bout_number_param = params[:bout_number]
@queue_matches = @mat.queue_matches
if bout_number_param @match = if bout_number_param
@show_next_bout_button = false @queue_matches.compact.find { |m| m.bout_number == bout_number_param.to_i }
@match = @mat.queue_matches.compact.find { |m| m.bout_number == bout_number_param.to_i }
else else
@show_next_bout_button = true @queue_matches[0]
@match = @mat.queue1_match
end end
# If a requested bout is no longer queued, fall back to queue1.
@next_match = @mat.queue2_match # Second match on the mat @match ||= @queue_matches[0]
@next_match = @queue_matches[1]
@show_next_bout_button = false
@wrestlers = [] @wrestlers = []
if @match if @match
@@ -45,10 +44,33 @@ class MatsController < ApplicationController
@tournament = @match.tournament @tournament = @match.tournament
end 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 session[:error_return_path] = request.original_fullpath
end 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 # GET /mats/new
def new def new
@mat = Mat.new @mat = Mat.new
@@ -141,12 +163,65 @@ class MatsController < ApplicationController
authorize! :manage, @tournament authorize! :manage, @tournament
end end
def sanitize_mat_redirect_path(path)
return nil if path.blank?
def check_for_matches uri = URI.parse(path)
if @mat return nil if uri.scheme.present? || uri.host.present?
if @mat.tournament.matches.empty?
redirect_to "/tournaments/#{@tournament.id}/no_matches" params = Rack::Utils.parse_nested_query(uri.query)
end params.delete("bout_number")
uri.query = params.to_query.presence
uri.to_s
rescue URI::InvalidURIError
nil
end end
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 end

View File

@@ -1,11 +1,11 @@
class StaticPagesController < ApplicationController class StaticPagesController < ApplicationController
def my_tournaments def my_tournaments
tournaments_created = current_user.tournaments tournaments_created = current_user.tournaments.to_a
tournaments_delegated = current_user.delegated_tournaments tournaments_delegated = current_user.delegated_tournaments.to_a
all_tournaments = tournaments_created + tournaments_delegated all_tournaments = tournaments_created + tournaments_delegated
@tournaments = all_tournaments.sort_by{|t| t.days_until_start} @tournaments = all_tournaments.sort_by{|t| t.days_until_start}
@schools = current_user.delegated_schools @schools = current_user.delegated_schools.includes(:tournament)
end end
def not_allowed def not_allowed

View File

@@ -1,13 +1,13 @@
class TournamentsController < ApplicationController 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,:qrcode] 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_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_access_destroy, only: [:destroy,:delegate,:remove_delegate]
before_action :check_tournament_errors, only: [:generate_matches] before_action :check_tournament_errors, only: [:generate_matches]
before_action :check_for_matches, only: [:all_results,:up_matches,:bracket,:all_brackets] before_action :check_for_matches, only: [:all_results,:bracket,:all_brackets]
before_action :check_access_read, only: [:all_results,:up_matches,:bracket,:all_brackets] before_action :check_access_read, only: [:all_results,:up_matches,:bracket,:all_brackets,:live_scores]
def weigh_in_sheet def weigh_in_sheet
@schools = @tournament.schools.includes(wrestlers: :weight)
end end
def calculate_team_scores def calculate_team_scores
@@ -92,12 +92,9 @@ class TournamentsController < ApplicationController
end end
end end
end end
@users_delegates = [] @users_delegates = SchoolDelegate.includes(:user, :school)
@tournament.schools.each do |s| .joins(:school)
s.delegates.each do |d| .where(schools: { tournament_id: @tournament.id })
@users_delegates << d
end
end
end end
def delegate def delegate
@@ -115,11 +112,63 @@ class TournamentsController < ApplicationController
end end
end end
end end
@users_delegates = @tournament.delegates @users_delegates = @tournament.delegates.includes(:user)
end end
def matches 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 if @match
@w1 = @match.wrestler1 @w1 = @match.wrestler1
@w2 = @match.wrestler2 @w2 = @match.wrestler2
@@ -129,10 +178,18 @@ class TournamentsController < ApplicationController
def weigh_in_weight def weigh_in_weight
if params[:wrestler] 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 end
if params[:weight] 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_id = @tournament.id
@tournament_name = @tournament.name @tournament_name = @tournament.name
@weights = @tournament.weights @weights = @tournament.weights
@@ -159,8 +216,11 @@ class TournamentsController < ApplicationController
def all_brackets def all_brackets
@schools = @tournament.schools @schools = @tournament.schools
@schools = @schools.sort_by{|s| s.page_score_string}.reverse! @schools = @schools.sort_by{|s| s.page_score_string}.reverse!
@matches = @tournament.matches.includes(:wrestlers,:schools) @weights = @tournament.weights.includes(:matches, wrestlers: :school)
@weights = @tournament.weights.includes(:matches,:wrestlers) 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 end
def bracket def bracket
@@ -182,6 +242,10 @@ class TournamentsController < ApplicationController
@bracket_position = nil @bracket_position = nil
end end
def live_scores
@mats = @tournament.mats.sort_by(&:name)
end
def generate_matches def generate_matches
GenerateTournamentMatches.new(@tournament).generate GenerateTournamentMatches.new(@tournament).generate
end end
@@ -203,25 +267,30 @@ class TournamentsController < ApplicationController
def up_matches def up_matches
# .where.not(loser1_name: 'BYE') won't return matches with NULL loser1_name @matches = @tournament.up_matches_unassigned_matches
# so I was only getting back matches with Loser of BOUT_NUMBER @mats = @tournament.up_matches_mats
@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)
end end
def bout_sheets 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] if params[:round]
round = params[:round] round = params[:round]
if round != "All" 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 else
@matches = @tournament.matches.sort_by{|match| match.bout_number} @matches = matches_scope
.includes(:weight)
.order(:bout_number)
end 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
end end

View File

@@ -7,8 +7,11 @@ class Mat < ApplicationRecord
validates :name, presence: true validates :name, presence: true
QUEUE_SLOTS = %w[queue1 queue2 queue3 queue4].freeze 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_save :clear_queue_matches_cache
after_commit :broadcast_up_matches_board, on: :update, if: :up_matches_queue_changed?
def assign_next_match def assign_next_match
slot = first_empty_queue_slot slot = first_empty_queue_slot
@@ -96,7 +99,9 @@ class Mat < ApplicationRecord
@queue_matches = if ids.empty? @queue_matches = if ids.empty?
[nil, nil, nil, nil] [nil, nil, nil, nil]
else else
matches_by_id = Match.where(id: ids).index_by(&:id) 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 } slot_ids.map { |match_id| match_id ? matches_by_id[match_id] : nil }
end end
@queue_match_slot_ids = slot_ids @queue_match_slot_ids = slot_ids
@@ -181,12 +186,63 @@ class Mat < ApplicationRecord
def clear_queue! def clear_queue!
update!(queue1: nil, queue2: nil, queue3: nil, queue4: nil) update!(queue1: nil, queue2: nil, queue3: nil, queue4: nil)
broadcast_current_match
end end
def unfinished_matches def unfinished_matches
matches.select{|m| m.finished != 1}.sort_by{|m| m.bout_number} matches.select{|m| m.finished != 1}.sort_by{|m| m.bout_number}
end 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 private
def clear_queue_matches_cache def clear_queue_matches_cache
@@ -272,6 +328,23 @@ class Mat < ApplicationRecord
show_next_bout_button: true 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
end end

View File

@@ -5,6 +5,8 @@ class Match < ApplicationRecord
belongs_to :weight, touch: true belongs_to :weight, touch: true
belongs_to :mat, touch: true, optional: true belongs_to :mat, touch: true, optional: true
belongs_to :winner, class_name: 'Wrestler', foreign_key: 'winner_id', 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 :wrestlers, :through => :weight
has_many :schools, :through => :wrestlers has_many :schools, :through => :wrestlers
validate :score_validation, :win_type_validation, :bracket_position_validation, :overtime_type_validation 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 # update mat show with correct match if bout board is reset
# this is done with a turbo stream # 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_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. # Enqueue advancement and related actions after the DB transaction has committed.
# Using after_commit ensures any background jobs enqueued inside these callbacks # Using after_commit ensures any background jobs enqueued inside these callbacks
@@ -178,14 +181,6 @@ class Match < ApplicationRecord
end end
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 def w1_name
if self.w1 != nil if self.w1 != nil
wrestler1.name wrestler1.name
@@ -203,7 +198,7 @@ class Match < ApplicationRecord
end end
def w1_bracket_name 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 = ""
return_string_ending = "" return_string_ending = ""
if self.w1 and self.winner_id == self.w1 if self.w1 and self.winner_id == self.w1
@@ -223,7 +218,7 @@ class Match < ApplicationRecord
end end
def w2_bracket_name 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 = ""
return_string_ending = "" return_string_ending = ""
if self.w2 and self.winner_id == self.w2 if self.w2 and self.winner_id == self.w2
@@ -289,6 +284,17 @@ class Match < ApplicationRecord
self.weight.max self.weight.max
end 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) def replace_loser_name_with_wrestler(w,loser_name)
if self.loser1_name == loser_name if self.loser1_name == loser_name
self.w1 = w.id self.w1 = w.id
@@ -366,4 +372,8 @@ class Match < ApplicationRecord
) )
end end
end end
def broadcast_up_matches_board
Tournament.broadcast_up_matches_board(tournament_id)
end
end end

View File

@@ -69,8 +69,35 @@ class Tournament < ApplicationRecord
end end
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 def destroy_all_matches
matches.destroy_all matches.destroy_all
mats.each(&:clear_queue!)
end end
def matches_by_round(round) def matches_by_round(round)
@@ -97,18 +124,11 @@ class Tournament < ApplicationRecord
end end
def pointAdjustments def pointAdjustments
point_adjustments = [] school_scope = Teampointadjust.where(school_id: schools.select(:id))
self.schools.each do |s| wrestler_scope = Teampointadjust.where(wrestler_id: wrestlers.select(:id))
s.deductedPoints.each do |d|
point_adjustments << d Teampointadjust.includes(:school, :wrestler)
end .merge(school_scope.or(wrestler_scope))
end
self.wrestlers.each do |w|
w.deductedPoints.each do |d|
point_adjustments << d
end
end
point_adjustments
end end
def remove_school_delegations def remove_school_delegations

View File

@@ -53,19 +53,16 @@ class User < ApplicationRecord
end end
def delegated_tournaments def delegated_tournaments
tournaments_delegated = [] Tournament.joins(:delegates)
delegated_tournament_permissions.each do |t| .where(tournament_delegates: { user_id: id })
tournaments_delegated << t.tournament .distinct
end
tournaments_delegated
end end
def delegated_schools def delegated_schools
schools_delegated = [] School.joins(:delegates)
delegated_school_permissions.each do |t| .where(school_delegates: { user_id: id })
schools_delegated << t.school .includes(:tournament)
end .distinct
schools_delegated
end end
def self.search(search) def self.search(search)

View File

@@ -156,7 +156,7 @@ class Weight < ApplicationRecord
end end
def calculate_bracket_size def calculate_bracket_size
num_wrestlers = wrestlers.reload.size num_wrestlers = wrestlers.size
return nil if num_wrestlers <= 0 # Handle invalid input return nil if num_wrestlers <= 0 # Handle invalid input
# Find the smallest power of 2 greater than or equal to num_wrestlers # Find the smallest power of 2 greater than or equal to num_wrestlers

View File

@@ -12,21 +12,97 @@ class AdvanceWrestler
end end
def advance_raw def advance_raw
@last_match.reload @last_match = Match.find_by(id: @last_match&.id)
@wrestler.reload @wrestler = Wrestler.includes(:school, :weight).find_by(id: @wrestler.id)
if @last_match && @last_match.finished? return unless @last_match && @wrestler && @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" context = preload_advancement_context
DoubleEliminationAdvance.new(@wrestler, @last_match).bracket_advancement if @tournament.tournament_type.include? "Regular Double Elimination" 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 end
persist_advancement_changes(context)
advance_pending_matches(matches_to_advance)
@wrestler.school.calculate_score @wrestler.school.calculate_score
end end
def pool_to_bracket_advancement def preload_advancement_context
if @wrestler.weight.all_pool_matches_finished(@wrestler.pool) and (@wrestler.finished_bracket_matches.size < 1) weight = Weight.includes(:matches, :wrestlers).find(@wrestler.weight_id)
PoolOrder.new(@wrestler.weight.wrestlers_in_pool(@wrestler.pool)).getPoolOrder {
weight: weight,
matches: weight.matches.to_a,
wrestlers: weight.wrestlers.to_a
}
end
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 end
PoolAdvance.new(@wrestler).advanceWrestler 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
end end

View File

@@ -1,8 +1,12 @@
class DoubleEliminationAdvance class DoubleEliminationAdvance
def initialize(wrestler,last_match) attr_reader :matches_to_advance
def initialize(wrestler,last_match, matches: nil)
@wrestler = wrestler @wrestler = wrestler
@last_match = last_match @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) @next_match_position_number = (@last_match.bracket_position_number / 2.0)
end end
@@ -48,7 +52,7 @@ class DoubleEliminationAdvance
end end
if next_match_bracket_position 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 end
if next_match if next_match
@@ -59,18 +63,16 @@ class DoubleEliminationAdvance
def update_new_match(match, wrestler_number) def update_new_match(match, wrestler_number)
if wrestler_number == 2 or (match.loser1_name and match.loser1_name.include? "Loser of") if wrestler_number == 2 or (match.loser1_name and match.loser1_name.include? "Loser of")
match.w2 = @wrestler.id match.w2 = @wrestler.id
match.save
elsif wrestler_number == 1 elsif wrestler_number == 1
match.w1 = @wrestler.id match.w1 = @wrestler.id
match.save
end end
end end
def update_consolation_bye def update_consolation_bye
bout = @wrestler.last_match.bout_number 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) 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.size > 0 if next_match
next_match.first.replace_loser_name_with_bye("Loser of #{bout}") replace_loser_name_with_bye(next_match, "Loser of #{bout}")
end end
end end
@@ -84,27 +86,18 @@ class DoubleEliminationAdvance
def losers_bracket_advancement def losers_bracket_advancement
bout = @last_match.bout_number 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? if next_match.present?
next_match.replace_loser_name_with_wrestler(@wrestler, "Loser of #{bout}") replace_loser_name_with_wrestler(next_match, @wrestler, "Loser of #{bout}")
next_match.reload
if next_match.loser1_name == "BYE" || next_match.loser2_name == "BYE" if next_match.loser1_name == "BYE" || next_match.loser2_name == "BYE"
next_match.winner_id = @wrestler.id next_match.winner_id = @wrestler.id
next_match.win_type = "BYE" next_match.win_type = "BYE"
next_match.score = "" next_match.score = ""
next_match.finished = 1 next_match.finished = 1
# puts "Before save: winner_id=#{next_match.winner_id}" next_match.finished_at = Time.current
@matches_to_advance << next_match
# 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
end end
end end
end end
@@ -112,51 +105,69 @@ class DoubleEliminationAdvance
def advance_double_byes def advance_double_byes
weight = @wrestler.weight 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 = 1
match.finished_at = Time.current
match.score = "" match.score = ""
match.win_type = "BYE" match.win_type = "BYE"
next_match_position_number = (match.bracket_position_number / 2.0).ceil 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} 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_matches = weight.matches.select{|m| m.round == after_matches.first.round and m.is_consolation_match == match.is_consolation_match } next if after_matches.empty?
this_round_matches = weight.matches.select{|m| m.round == match.round and m.is_consolation_match == match.is_consolation_match } 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 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 = next_matches.select{|m| m.bracket_position_number == match.bracket_position_number}.first
next_match.loser2_name = "BYE" next_match.loser2_name = "BYE" if next_match
next_match.save
elsif next_matches.size < this_round_matches.size and next_matches.size > 0 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 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" next_match.loser2_name = "BYE"
else elsif next_match
next_match.loser1_name = "BYE" next_match.loser1_name = "BYE"
end end
end end
next_match.save
match.save
end end
end end
def set_bye_for_placement def set_bye_for_placement
weight = @wrestler.weight weight = @wrestler.weight
fifth_finals = weight.matches.select{|match| match.bracket_position == '5/6'}.first fifth_finals = @matches.select{|match| match.weight_id == weight.id && match.bracket_position == '5/6'}.first
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 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| conso_quarter.each do |match|
if match.loser1_name == "BYE" or match.loser2_name == "BYE" 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 end
end end
if fifth_finals 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| conso_semis.each do |match|
if match.loser1_name == "BYE" or match.loser2_name == "BYE" 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
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 end

View File

@@ -1,8 +1,12 @@
class ModifiedDoubleEliminationAdvance class ModifiedDoubleEliminationAdvance
def initialize(wrestler,last_match) attr_reader :matches_to_advance
def initialize(wrestler,last_match, matches: nil)
@wrestler = wrestler @wrestler = wrestler
@last_match = last_match @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) @next_match_position_number = (@last_match.bracket_position_number / 2.0)
end end
@@ -25,42 +29,41 @@ class ModifiedDoubleEliminationAdvance
update_consolation_bye update_consolation_bye
end end
if @last_match.bracket_position == "Quarter" 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) update_new_match(new_match, get_wrestler_number)
elsif @last_match.bracket_position == "Semis" 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) update_new_match(new_match, get_wrestler_number)
elsif @last_match.bracket_position == "Conso Semis" 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) update_new_match(new_match, get_wrestler_number)
elsif @last_match.bracket_position == "Conso Quarter" elsif @last_match.bracket_position == "Conso Quarter"
# it's a special bracket where a semi loser is not dropping down # 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) update_new_match(new_match, get_wrestler_number)
elsif @last_match.bracket_position == "Bracket Round of 16" 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) update_new_match(new_match, get_wrestler_number)
elsif @last_match.bracket_position == "Conso Round of 8" 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) update_new_match(new_match, get_wrestler_number)
end end
end end
def update_new_match(match, wrestler_number) 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") if wrestler_number == 2 or (match.loser1_name and match.loser1_name.include? "Loser of")
match.w2 = @wrestler.id match.w2 = @wrestler.id
match.save
elsif wrestler_number == 1 elsif wrestler_number == 1
match.w1 = @wrestler.id match.w1 = @wrestler.id
match.save
end end
end end
def update_consolation_bye def update_consolation_bye
bout = @wrestler.last_match.bout_number 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) 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.size > 0 if next_match
next_match.first.replace_loser_name_with_bye("Loser of #{bout}") replace_loser_name_with_bye(next_match, "Loser of #{bout}")
end end
end end
@@ -74,27 +77,18 @@ class ModifiedDoubleEliminationAdvance
def losers_bracket_advancement def losers_bracket_advancement
bout = @last_match.bout_number 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? if next_match.present?
next_match.replace_loser_name_with_wrestler(@wrestler, "Loser of #{bout}") replace_loser_name_with_wrestler(next_match, @wrestler, "Loser of #{bout}")
next_match.reload
if next_match.loser1_name == "BYE" || next_match.loser2_name == "BYE" if next_match.loser1_name == "BYE" || next_match.loser2_name == "BYE"
next_match.winner_id = @wrestler.id next_match.winner_id = @wrestler.id
next_match.win_type = "BYE" next_match.win_type = "BYE"
next_match.score = "" next_match.score = ""
next_match.finished = 1 next_match.finished = 1
# puts "Before save: winner_id=#{next_match.winner_id}" next_match.finished_at = Time.current
@matches_to_advance << next_match
# 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
end end
end end
end end
@@ -102,43 +96,53 @@ class ModifiedDoubleEliminationAdvance
def advance_double_byes def advance_double_byes
weight = @wrestler.weight 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 = 1
match.finished_at = Time.current
match.score = "" match.score = ""
match.win_type = "BYE" match.win_type = "BYE"
next_match_position_number = (match.bracket_position_number / 2.0).ceil 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} 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_matches = weight.matches.select{|m| m.round == after_matches.first.round and m.is_consolation_match == match.is_consolation_match } next if after_matches.empty?
this_round_matches = weight.matches.select{|m| m.round == match.round and m.is_consolation_match == match.is_consolation_match } 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 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 = next_matches.select{|m| m.bracket_position_number == match.bracket_position_number}.first
next_match.loser2_name = "BYE" next_match.loser2_name = "BYE" if next_match
next_match.save
elsif next_matches.size < this_round_matches.size and next_matches.size > 0 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 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" next_match.loser2_name = "BYE"
else elsif next_match
next_match.loser1_name = "BYE" next_match.loser1_name = "BYE"
end end
end end
next_match.save
match.save
end end
end end
def set_bye_for_placement def set_bye_for_placement
weight = @wrestler.weight 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 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| conso_quarter.each do |match|
if match.loser1_name == "BYE" or match.loser2_name == "BYE" 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 end
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 end

View File

@@ -1,8 +1,13 @@
class PoolAdvance class PoolAdvance
def initialize(wrestler) attr_reader :matches_to_advance
def initialize(wrestler, last_match, matches: nil, wrestlers: nil)
@wrestler = wrestler @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 end
def advanceWrestler def advanceWrestler
@@ -17,15 +22,15 @@ class PoolAdvance
def poolToBracketAdvancment def poolToBracketAdvancment
pool = @wrestler.pool pool = @wrestler.pool
# This has to always run because the last match in a pool might not be a pool winner or runner up # 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 winner = @wrestlers.find { |w| w.weight_id == @wrestler.weight.id && w.pool_placement == 1 && w.pool == pool }
runner_up = Wrestler.where("weight_id = ? and pool_placement = 2 and pool = ?",@wrestler.weight.id, pool).first runner_up = @wrestlers.find { |w| w.weight_id == @wrestler.weight.id && w.pool_placement == 2 && w.pool == pool }
if runner_up 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 = @matches.find { |m| m.weight_id == @wrestler.weight.id && (m.loser1_name == "Runner Up Pool #{pool}" || m.loser2_name == "Runner Up Pool #{pool}") }
runner_up_match.replace_loser_name_with_wrestler(runner_up,"Runner Up Pool #{pool}") replace_loser_name_with_wrestler(runner_up_match, runner_up, "Runner Up Pool #{pool}") if runner_up_match
end end
if winner 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 = @matches.find { |m| m.weight_id == @wrestler.weight.id && (m.loser1_name == "Winner Pool #{pool}" || m.loser2_name == "Winner Pool #{pool}") }
winner_match.replace_loser_name_with_wrestler(winner,"Winner Pool #{pool}") replace_loser_name_with_wrestler(winner_match, winner, "Winner Pool #{pool}") if winner_match
end end
end end
@@ -45,36 +50,40 @@ class PoolAdvance
def winner_advance def winner_advance
if @wrestler.last_match.bracket_position == "Quarter" 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) updateNewMatch(new_match)
end end
if @wrestler.last_match.bracket_position == "Semis" 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) updateNewMatch(new_match)
end end
if @wrestler.last_match.bracket_position == "Conso Semis" 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) updateNewMatch(new_match)
end end
end end
def updateNewMatch(match) def updateNewMatch(match)
return unless match
if @wrestler.next_match_position_number == @wrestler.next_match_position_number.ceil if @wrestler.next_match_position_number == @wrestler.next_match_position_number.ceil
match.w2 = @wrestler.id match.w2 = @wrestler.id
match.save
end end
if @wrestler.next_match_position_number != @wrestler.next_match_position_number.ceil if @wrestler.next_match_position_number != @wrestler.next_match_position_number.ceil
match.w1 = @wrestler.id match.w1 = @wrestler.id
match.save
end end
end end
def loser_advance def loser_advance
bout = @wrestler.last_match.bout_number 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) 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.size > 0 if next_match
next_match.first.replace_loser_name_with_wrestler(@wrestler,"Loser of #{bout}") replace_loser_name_with_wrestler(next_match, @wrestler, "Loser of #{bout}")
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
end end

View File

@@ -4,8 +4,6 @@ class PoolOrder
end end
def getPoolOrder def getPoolOrder
# clear caching for weight for bracket page
@wrestlers.first.weight.touch
setOriginalPoints setOriginalPoints
while checkForTies(@wrestlers) == true while checkForTies(@wrestlers) == true
getWrestlersOrderByPoolAdvancePoints.each do |wrestler| getWrestlersOrderByPoolAdvancePoints.each do |wrestler|
@@ -18,7 +16,6 @@ class PoolOrder
getWrestlersOrderByPoolAdvancePoints.each_with_index do |wrestler, index| getWrestlersOrderByPoolAdvancePoints.each_with_index do |wrestler, index|
placement = index + 1 placement = index + 1
wrestler.pool_placement = placement wrestler.pool_placement = placement
wrestler.save
end end
@wrestlers.sort_by{|w| w.poolAdvancePoints}.reverse! @wrestlers.sort_by{|w| w.poolAdvancePoints}.reverse!
end end
@@ -29,7 +26,6 @@ class PoolOrder
def setOriginalPoints def setOriginalPoints
@wrestlers.each do |w| @wrestlers.each do |w|
matches = w.reload.all_matches
w.pool_placement_tiebreaker = nil w.pool_placement_tiebreaker = nil
w.pool_placement = nil w.pool_placement = nil
w.poolAdvancePoints = w.pool_wins.size w.poolAdvancePoints = w.pool_wins.size

View 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

View File

@@ -3,33 +3,30 @@ class DoubleEliminationGenerateLoserNames
@tournament = tournament @tournament = tournament
end end
# Entry point: assign loser placeholders and advance any byes # Compatibility wrapper. Returns transformed rows and does not persist.
def assign_loser_names 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| @tournament.weights.each do |weight|
# only assign loser names if there's conso matches to be had next unless weight.calculate_bracket_size > 2
if weight.calculate_bracket_size > 2
assign_loser_names_for_weight(weight) assign_loser_names_in_memory(weight, rows)
advance_bye_matches_championship(weight) assign_bye_outcomes_in_memory(weight, rows)
advance_bye_matches_consolation(weight)
end
end end
rows
end end
private def assign_loser_names_in_memory(weight, match_rows)
# Assign loser names for a single weight bracket
def assign_loser_names_for_weight(weight)
bracket_size = weight.calculate_bracket_size bracket_size = weight.calculate_bracket_size
matches = weight.matches.reload return if bracket_size <= 2
num_placers = @tournament.number_of_placers
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) champ_rounds = dynamic_championship_rounds(bracket_size)
conso_rounds = dynamic_consolation_rounds(bracket_size) conso_rounds = dynamic_consolation_rounds(bracket_size)
first_round = { bracket_position: first_round_label(bracket_size) } first_round = { bracket_position: first_round_label(bracket_size) }
champ_full = [first_round] + champ_rounds champ_full = [first_round] + champ_rounds
# Map championship losers into consolation slots
mappings = [] mappings = []
champ_full[0...-1].each_with_index do |champ_info, i| champ_full[0...-1].each_with_index do |champ_info, i|
map_idx = i.zero? ? 0 : (2 * i - 1) map_idx = i.zero? ? 0 : (2 * i - 1)
@@ -37,128 +34,109 @@ class DoubleEliminationGenerateLoserNames
mappings << { mappings << {
championship_bracket_position: champ_info[:bracket_position], championship_bracket_position: champ_info[:bracket_position],
consolation_bracket_position: conso_rounds[map_idx][:bracket_position], consolation_bracket_position: conso_rounds[map_idx][:bracket_position],
both_wrestlers: i.zero?, both_wrestlers: i.zero?,
champ_round_index: i champ_round_index: i
} }
end end
# Apply loser-name mappings
mappings.each do |map| mappings.each do |map|
champ = matches.select { |m| m.bracket_position == map[:championship_bracket_position] } champ = rows.select { |r| r[:bracket_position] == map[:championship_bracket_position] }
.sort_by(&:bracket_position_number) .sort_by { |r| r[:bracket_position_number] }
conso = matches.select { |m| m.bracket_position == map[:consolation_bracket_position] } conso = rows.select { |r| r[:bracket_position] == map[:consolation_bracket_position] }
.sort_by(&:bracket_position_number) .sort_by { |r| r[:bracket_position_number] }
conso.reverse! if map[:champ_round_index].odd?
current_champ_round_index = map[:champ_round_index]
if current_champ_round_index.odd?
conso.reverse!
end
idx = 0 idx = 0
# Determine if this mapping is for losers from the first championship round is_first_feed = map[:champ_round_index].zero?
is_first_champ_round_feed = map[:champ_round_index].zero?
conso.each do |cm| conso.each do |cm|
champ_match1 = champ[idx] champ_match1 = champ[idx]
if champ_match1 if champ_match1
if is_first_champ_round_feed && ((champ_match1.w1 && champ_match1.w2.nil?) || (champ_match1.w1.nil? && champ_match1.w2)) if is_first_feed && single_competitor_match_row?(champ_match1)
cm.loser1_name = "BYE" cm[:loser1_name] = "BYE"
else else
cm.loser1_name = "Loser of #{champ_match1.bout_number}" cm[:loser1_name] = "Loser of #{champ_match1[:bout_number]}"
end end
else else
cm.loser1_name = nil # Should not happen if bracket generation is correct cm[:loser1_name] = nil
end end
if map[:both_wrestlers] # This is true only if is_first_champ_round_feed if map[:both_wrestlers]
idx += 1 # Increment for the second championship match idx += 1
champ_match2 = champ[idx] champ_match2 = champ[idx]
if champ_match2 if champ_match2
# BYE check is only relevant for the first championship round feed if is_first_feed && single_competitor_match_row?(champ_match2)
if is_first_champ_round_feed && ((champ_match2.w1 && champ_match2.w2.nil?) || (champ_match2.w1.nil? && champ_match2.w2)) cm[:loser2_name] = "BYE"
cm.loser2_name = "BYE"
else else
cm.loser2_name = "Loser of #{champ_match2.bout_number}" cm[:loser2_name] = "Loser of #{champ_match2[:bout_number]}"
end end
else else
cm.loser2_name = nil # Should not happen cm[:loser2_name] = nil
end end
end end
idx += 1 # Increment for the next consolation match or next pair from championship idx += 1
end end
end end
# 5th/6th place
if bracket_size >= 5 && num_placers >= 6 && weight.wrestlers.size > 4 if bracket_size >= 5 && num_placers >= 6 && weight.wrestlers.size > 4
conso_semis = matches.select { |m| m.bracket_position == "Conso Semis" } conso_semis = rows.select { |r| r[:bracket_position] == "Conso Semis" }.sort_by { |r| r[:bracket_position_number] }
.sort_by(&:bracket_position_number) m56 = rows.find { |r| r[:bracket_position] == "5/6" }
if conso_semis.size >= 2 if conso_semis.size >= 2 && m56
m56 = matches.find { |m| m.bracket_position == "5/6" } m56[:loser1_name] = "Loser of #{conso_semis[0][:bout_number]}"
m56.loser1_name = "Loser of #{conso_semis[0].bout_number}" m56[:loser2_name] = "Loser of #{conso_semis[1][:bout_number]}"
m56.loser2_name = "Loser of #{conso_semis[1].bout_number}" if m56
end end
end end
# 7th/8th place
if bracket_size >= 7 && num_placers >= 8 && weight.wrestlers.size > 6 if bracket_size >= 7 && num_placers >= 8 && weight.wrestlers.size > 6
conso_quarters = matches.select { |m| m.bracket_position == "Conso Quarter" } conso_quarters = rows.select { |r| r[:bracket_position] == "Conso Quarter" }.sort_by { |r| r[:bracket_position_number] }
.sort_by(&:bracket_position_number) m78 = rows.find { |r| r[:bracket_position] == "7/8" }
if conso_quarters.size >= 2 if conso_quarters.size >= 2 && m78
m78 = matches.find { |m| m.bracket_position == "7/8" } m78[:loser1_name] = "Loser of #{conso_quarters[0][:bout_number]}"
m78.loser1_name = "Loser of #{conso_quarters[0].bout_number}" m78[:loser2_name] = "Loser of #{conso_quarters[1][:bout_number]}"
m78.loser2_name = "Loser of #{conso_quarters[1].bout_number}" if m78
end end
end end
matches.each(&:save!)
end end
# Advance first-round byes in championship bracket def assign_bye_outcomes_in_memory(weight, match_rows)
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
bracket_size = weight.calculate_bracket_size bracket_size = weight.calculate_bracket_size
first_conso = dynamic_consolation_rounds(bracket_size).first return if bracket_size <= 2
matches.select { |m| m.round == first_conso[:round] && m.bracket_position == first_conso[:bracket_position] } rows = match_rows.select { |r| r[:weight_id] == weight.id }
.sort_by(&:bracket_position_number) first_round = rows.map { |r| r[:round] }.compact.min
.each { |m| handle_bye(m) } rows.select { |r| r[:round] == first_round }.each { |row| apply_bye_to_row(row) }
end
# Mark bye match, set finished, and advance first_conso = dynamic_consolation_rounds(bracket_size).first
def handle_bye(match) if first_conso
if [match.w1, match.w2].compact.size == 1 rows.select { |r| r[:round] == first_conso[:round] && r[:bracket_position] == first_conso[:bracket_position] }
match.finished = 1 .each { |row| apply_bye_to_row(row) }
match.win_type = 'BYE'
if match.w1
match.winner_id = match.w1
match.loser2_name = 'BYE'
else
match.winner_id = match.w2
match.loser1_name = 'BYE'
end
match.score = ''
match.save!
match.advance_wrestlers
end end
end end
# Helpers for dynamic bracket labels 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
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
def first_round_label(size) def first_round_label(size)
case size case size
when 2 then 'Final' when 2 then "Final"
when 4 then 'Semis' when 4 then "Semis"
when 8 then 'Quarter' when 8 then "Quarter"
else "Bracket Round of #{size}" else "Bracket Round of #{size}"
end end
end end
@@ -186,10 +164,10 @@ class DoubleEliminationGenerateLoserNames
def bracket_label(participants) def bracket_label(participants)
case participants case participants
when 2 then '1/2' when 2 then "1/2"
when 4 then 'Semis' when 4 then "Semis"
when 8 then 'Quarter' when 8 then "Quarter"
else "Bracket Round of #{participants}" else "Bracket Round of #{participants}"
end end
end end
@@ -197,12 +175,12 @@ class DoubleEliminationGenerateLoserNames
max_j_for_bracket = (2 * (Math.log2(bracket_size).to_i - 1) - 1) max_j_for_bracket = (2 * (Math.log2(bracket_size).to_i - 1) - 1)
if participants == 2 && j == max_j_for_bracket if participants == 2 && j == max_j_for_bracket
return '3/4' "3/4"
elsif participants == 4 elsif participants == 4
return j.odd? ? 'Conso Quarter' : 'Conso Semis' j.odd? ? "Conso Quarter" : "Conso Semis"
else else
suffix = j.odd? ? ".1" : ".2" suffix = j.odd? ? ".1" : ".2"
return "Conso Round of #{participants}#{suffix}" "Conso Round of #{participants}#{suffix}"
end end
end end
end end

View File

@@ -1,29 +1,33 @@
class DoubleEliminationMatchGeneration class DoubleEliminationMatchGeneration
def initialize(tournament) def initialize(tournament, weights: nil)
@tournament = tournament @tournament = tournament
@weights = weights
end end
def generate_matches def generate_matches
# build_match_rows
# PHASE 1: Generate matches (with local round definitions). end
#
@tournament.weights.each do |weight| def build_match_rows
generate_matches_for_weight(weight) rows_by_weight_id = {}
generation_weights.each do |weight|
rows_by_weight_id[weight.id] = generate_match_rows_for_weight(weight)
end end
# align_rows_to_largest_bracket(rows_by_weight_id)
# PHASE 2: Align all rounds to match the largest brackets definitions. rows_by_weight_id.values.flatten
#
align_all_rounds_to_largest_bracket
end end
########################################################################### ###########################################################################
# PHASE 1: Generate all matches for each bracket, using a single definition. # 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_size = weight.calculate_bracket_size
bracket_info = define_bracket_matches(bracket_size) bracket_info = define_bracket_matches(bracket_size)
return unless bracket_info return [] unless bracket_info
rows = []
# 1) Round one matchups # 1) Round one matchups
bracket_info[:round_one_matchups].each_with_index do |matchup, idx| bracket_info[:round_one_matchups].each_with_index do |matchup, idx|
@@ -32,7 +36,7 @@ class DoubleEliminationMatchGeneration
bracket_pos_number = idx + 1 bracket_pos_number = idx + 1
round_number = matchup[:round] round_number = matchup[:round]
create_matchup_from_seed( rows << create_matchup_from_seed(
seed1, seed1,
seed2, seed2,
bracket_position, bracket_position,
@@ -49,7 +53,7 @@ class DoubleEliminationMatchGeneration
round_number = round_info[:round] round_number = round_info[:round]
matches_this_round.times do |i| matches_this_round.times do |i|
create_matchup( rows << create_matchup(
nil, nil,
nil, nil,
bracket_position, bracket_position,
@@ -67,7 +71,7 @@ class DoubleEliminationMatchGeneration
round_number = round_info[:round] round_number = round_info[:round]
matches_this_round.times do |i| matches_this_round.times do |i|
create_matchup( rows << create_matchup(
nil, nil,
nil, nil,
bracket_position, bracket_position,
@@ -79,12 +83,14 @@ class DoubleEliminationMatchGeneration
# 5/6, 7/8 placing logic # 5/6, 7/8 placing logic
if weight.wrestlers.size >= 5 && @tournament.number_of_placers >= 6 && matches_this_round == 1 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 end
if weight.wrestlers.size >= 7 && @tournament.number_of_placers >= 8 && matches_this_round == 1 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
end end
rows
end end
# Single bracket definition dynamically generated for any power-of-two bracket size. # 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. # PHASE 2: Overwrite rounds in all smaller brackets to match the largest one.
########################################################################### ###########################################################################
def align_all_rounds_to_largest_bracket def align_rows_to_largest_bracket(rows_by_weight_id)
largest_weight = @tournament.weights.max_by { |w| w.calculate_bracket_size } largest_weight = generation_weights.max_by { |w| w.calculate_bracket_size }
return unless largest_weight return unless largest_weight
position_to_round = {} position_to_round = {}
largest_weight.tournament.matches.where(weight_id: largest_weight.id).each do |m| rows_by_weight_id.fetch(largest_weight.id, []).each do |row|
position_to_round[m.bracket_position] ||= m.round position_to_round[row[:bracket_position]] ||= row[:round]
end end
@tournament.matches.find_each do |match| rows_by_weight_id.each_value do |rows|
if position_to_round.key?(match.bracket_position) rows.each do |row|
match.update(round: position_to_round[match.bracket_position]) row[:round] = position_to_round[row[:bracket_position]] if position_to_round.key?(row[:bracket_position])
end end
end end
end end
@@ -192,8 +198,12 @@ class DoubleEliminationMatchGeneration
########################################################################### ###########################################################################
# Helper methods # Helper methods
########################################################################### ###########################################################################
def generation_weights
@weights || @tournament.weights.to_a
end
def wrestler_with_seed(seed, weight) 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 end
def create_matchup_from_seed(w1_seed, w2_seed, bracket_position, bracket_position_number, round, weight) def create_matchup_from_seed(w1_seed, w2_seed, bracket_position, bracket_position_number, round, weight)
@@ -208,14 +218,15 @@ class DoubleEliminationMatchGeneration
end end
def create_matchup(w1, w2, bracket_position, bracket_position_number, round, weight) def create_matchup(w1, w2, bracket_position, bracket_position_number, round, weight)
weight.tournament.matches.create!( {
w1: w1, w1: w1,
w2: w2, w2: w2,
tournament_id: weight.tournament_id,
weight_id: weight.id, weight_id: weight.id,
round: round, round: round,
bracket_position: bracket_position, bracket_position: bracket_position,
bracket_position_number: bracket_position_number bracket_position_number: bracket_position_number
) }
end end
# Calculates the sequence of seeds for the first round of a power-of-two bracket. # Calculates the sequence of seeds for the first round of a power-of-two bracket.

View File

@@ -10,62 +10,183 @@ class GenerateTournamentMatches
def generate_raw def generate_raw
standardStartingActions standardStartingActions
PoolToBracketMatchGeneration.new(@tournament).generatePoolToBracketMatches if @tournament.tournament_type == "Pool to bracket" generation_context = preload_generation_context
ModifiedSixteenManMatchGeneration.new(@tournament).generate_matches if @tournament.tournament_type.include? "Modified 16 Man Double Elimination" seed_wrestlers_in_memory(generation_context)
DoubleEliminationMatchGeneration.new(@tournament).generate_matches if @tournament.tournament_type.include? "Regular Double Elimination" 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 postMatchCreationActions
PoolToBracketMatchGeneration.new(@tournament).assignLoserNames if @tournament.tournament_type == "Pool to bracket" advance_bye_matches_after_insert
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"
end end
def standardStartingActions def standardStartingActions
@tournament.curently_generating_matches = 1 @tournament.curently_generating_matches = 1
@tournament.save @tournament.save
WipeTournamentMatches.new(@tournament).setUpMatchGeneration 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 end
def postMatchCreationActions def postMatchCreationActions
moveFinalsMatchesToLastRound if ! @tournament.tournament_type.include? "Regular Double Elimination"
assignBouts
@tournament.reset_and_fill_bout_board @tournament.reset_and_fill_bout_board
@tournament.curently_generating_matches = nil @tournament.curently_generating_matches = nil
@tournament.save! @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 end
def assignBouts def assignBouts
bout_counts = Hash.new(0) bout_counts = Hash.new(0)
@tournament.matches.reload timestamp = Time.current
@tournament.matches.sort_by{|m| [m.round, m.weight_max]}.each do |m| ordered_matches = Match.joins(:weight)
m.bout_number = m.round * 1000 + bout_counts[m.round] .where(tournament_id: @tournament.id)
bout_counts[m.round] += 1 .order("matches.round ASC, weights.max ASC, matches.id ASC")
m.save! .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 end
Match.upsert_all(updates) if updates.any?
end end
def moveFinalsMatchesToLastRound def moveFinalsMatchesToLastRound
finalsRound = @tournament.reload.total_rounds 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"} @tournament.matches
finalsMatches. each do |m| .where(bracket_position: ["1/2", "3/4", "5/6", "7/8"])
m.round = finalsRound .update_all(round: finalsRound, updated_at: Time.current)
m.save
end
end end
def unAssignMats def unAssignMats
matches = @tournament.matches.reload @tournament.matches.update_all(mat_id: nil, updated_at: Time.current)
matches.each do |m|
m.mat_id = nil
m.save!
end
end end
def unAssignBouts def unAssignBouts
bout_counts = Hash.new(0) @tournament.matches.update_all(bout_number: nil, updated_at: Time.current)
@tournament.matches.each do |m|
m.bout_number = nil
m.save!
end
end end
end end

View File

@@ -1,95 +1,91 @@
class ModifiedSixteenManGenerateLoserNames class ModifiedSixteenManGenerateLoserNames
def initialize( tournament ) def initialize(tournament)
@tournament = tournament @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)
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}"
end
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
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 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}"
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
# 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
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 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 apply_bye_to_row(row)
return unless [row[:w1], row[:w2]].compact.size == 1
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 end

View File

@@ -1,70 +1,75 @@
class ModifiedSixteenManMatchGeneration class ModifiedSixteenManMatchGeneration
def initialize( tournament ) def initialize( tournament, weights: nil )
@tournament = tournament @tournament = tournament
@number_of_placers = @tournament.number_of_placers @number_of_placers = @tournament.number_of_placers
@weights = weights
end end
def generate_matches def generate_matches
@tournament.weights.each do |weight| rows = []
generate_matches_for_weight(weight) generation_weights.each do |weight|
rows.concat(generate_match_rows_for_weight(weight))
end end
rows
end end
def generate_matches_for_weight(weight) def generate_match_rows_for_weight(weight)
round_one(weight) rows = []
round_two(weight) round_one(weight, rows)
round_three(weight) round_two(weight, rows)
round_four(weight) round_three(weight, rows)
round_five(weight) round_four(weight, rows)
round_five(weight, rows)
rows
end end
def round_one(weight) def round_one(weight, rows)
create_matchup_from_seed(1,16, "Bracket Round of 16", 1, 1,weight) rows << 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) rows << 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) rows << 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) rows << 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) rows << 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) rows << 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) rows << 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) rows << create_matchup_from_seed(2,15, "Bracket Round of 16", 8, 1,weight)
end end
def round_two(weight) def round_two(weight, rows)
create_matchup(nil,nil,"Quarter",1,2,weight) rows << create_matchup(nil,nil,"Quarter",1,2,weight)
create_matchup(nil,nil,"Quarter",2,2,weight) rows << create_matchup(nil,nil,"Quarter",2,2,weight)
create_matchup(nil,nil,"Quarter",3,2,weight) rows << create_matchup(nil,nil,"Quarter",3,2,weight)
create_matchup(nil,nil,"Quarter",4,2,weight) rows << create_matchup(nil,nil,"Quarter",4,2,weight)
create_matchup(nil,nil,"Conso Round of 8",1,2,weight) rows << create_matchup(nil,nil,"Conso Round of 8",1,2,weight)
create_matchup(nil,nil,"Conso Round of 8",2,2,weight) rows << create_matchup(nil,nil,"Conso Round of 8",2,2,weight)
create_matchup(nil,nil,"Conso Round of 8",3,2,weight) rows << create_matchup(nil,nil,"Conso Round of 8",3,2,weight)
create_matchup(nil,nil,"Conso Round of 8",4,2,weight) rows << create_matchup(nil,nil,"Conso Round of 8",4,2,weight)
end end
def round_three(weight) def round_three(weight, rows)
create_matchup(nil,nil,"Semis",1,3,weight) rows << create_matchup(nil,nil,"Semis",1,3,weight)
create_matchup(nil,nil,"Semis",2,3,weight) rows << create_matchup(nil,nil,"Semis",2,3,weight)
create_matchup(nil,nil,"Conso Quarter",1,3,weight) rows << create_matchup(nil,nil,"Conso Quarter",1,3,weight)
create_matchup(nil,nil,"Conso Quarter",2,3,weight) rows << create_matchup(nil,nil,"Conso Quarter",2,3,weight)
create_matchup(nil,nil,"Conso Quarter",3,3,weight) rows << create_matchup(nil,nil,"Conso Quarter",3,3,weight)
create_matchup(nil,nil,"Conso Quarter",4,3,weight) rows << create_matchup(nil,nil,"Conso Quarter",4,3,weight)
end end
def round_four(weight) def round_four(weight, rows)
create_matchup(nil,nil,"Conso Semis",1,4,weight) rows << create_matchup(nil,nil,"Conso Semis",1,4,weight)
create_matchup(nil,nil,"Conso Semis",2,4,weight) rows << create_matchup(nil,nil,"Conso Semis",2,4,weight)
end end
def round_five(weight) def round_five(weight, rows)
create_matchup(nil,nil,"1/2",1,5,weight) rows << create_matchup(nil,nil,"1/2",1,5,weight)
create_matchup(nil,nil,"3/4",1,5,weight) rows << create_matchup(nil,nil,"3/4",1,5,weight)
create_matchup(nil,nil,"5/6",1,5,weight) rows << create_matchup(nil,nil,"5/6",1,5,weight)
if @number_of_placers >= 8 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
end end
def wrestler_with_seed(seed,weight) 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 if wrestler
return wrestler.id return wrestler.id
else else
@@ -79,13 +84,18 @@ class ModifiedSixteenManMatchGeneration
end end
def create_matchup(w1, w2, bracket_position, bracket_position_number,round,weight) def create_matchup(w1, w2, bracket_position, bracket_position_number,round,weight)
@tournament.matches.create( {
w1: w1, w1: w1,
w2: w2, w2: w2,
tournament_id: @tournament.id,
weight_id: weight.id, weight_id: weight.id,
round: round, round: round,
bracket_position: bracket_position, bracket_position: bracket_position,
bracket_position_number: bracket_position_number bracket_position_number: bracket_position_number
) }
end
def generation_weights
@weights || @tournament.weights.to_a
end end
end end

View File

@@ -12,18 +12,19 @@ class PoolBracketGeneration
end end
def generateBracketMatches() def generateBracketMatches()
@rows = []
if @pool_bracket_type == "twoPoolsToSemi" if @pool_bracket_type == "twoPoolsToSemi"
return twoPoolsToSemi() twoPoolsToSemi()
elsif @pool_bracket_type == "twoPoolsToFinal" elsif @pool_bracket_type == "twoPoolsToFinal"
return twoPoolsToFinal() twoPoolsToFinal()
elsif @pool_bracket_type == "fourPoolsToQuarter" elsif @pool_bracket_type == "fourPoolsToQuarter"
return fourPoolsToQuarter() fourPoolsToQuarter()
elsif @pool_bracket_type == "fourPoolsToSemi" elsif @pool_bracket_type == "fourPoolsToSemi"
return fourPoolsToSemi() fourPoolsToSemi()
elsif @pool_bracket_type == "eightPoolsToQuarter" elsif @pool_bracket_type == "eightPoolsToQuarter"
return eightPoolsToQuarter() eightPoolsToQuarter()
end end
return [] @rows
end end
def twoPoolsToSemi() def twoPoolsToSemi()
@@ -86,14 +87,15 @@ class PoolBracketGeneration
end end
def createMatchup(w1_name, w2_name, bracket_position, bracket_position_number) def createMatchup(w1_name, w2_name, bracket_position, bracket_position_number)
@tournament.matches.create( @rows << {
loser1_name: w1_name, loser1_name: w1_name,
loser2_name: w2_name, loser2_name: w2_name,
tournament_id: @tournament.id,
weight_id: @weight.id, weight_id: @weight.id,
round: @round, round: @round,
bracket_position: bracket_position, bracket_position: bracket_position,
bracket_position_number: bracket_position_number bracket_position_number: bracket_position_number
) }
end end
end end

View File

@@ -1,35 +1,46 @@
class PoolGeneration class PoolGeneration
def initialize(weight) def initialize(weight, wrestlers: nil)
@weight = weight @weight = weight
@tournament = @weight.tournament @tournament = @weight.tournament
@pool = 1 @pool = 1
@wrestlers = wrestlers
end end
def generatePools def generatePools
GeneratePoolNumbers.new(@weight).savePoolNumbers GeneratePoolNumbers.new(@weight).savePoolNumbers(wrestlers: wrestlers_for_weight, persist: false)
rows = []
pools = @weight.pools pools = @weight.pools
while @pool <= pools while @pool <= pools
roundRobin rows.concat(roundRobin)
@pool += 1 @pool += 1
end end
rows
end end
def roundRobin 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 = RoundRobinTournament.schedule(wrestlers).reverse
pool_matches.each_with_index do |b, index| pool_matches.each_with_index do |b, index|
round = index + 1 round = index + 1
bouts = b.map bouts = b.map
bouts.each do |bout| bouts.each do |bout|
if bout[0] != nil and bout[1] != nil if bout[0] != nil and bout[1] != nil
@tournament.matches.create( rows << {
w1: bout[0].id, w1: bout[0].id,
w2: bout[1].id, w2: bout[1].id,
tournament_id: @tournament.id,
weight_id: @weight.id, weight_id: @weight.id,
bracket_position: "Pool", bracket_position: "Pool",
round: round) round: round
}
end end
end end
end end
rows
end end
def wrestlers_for_weight
@wrestlers || @weight.wrestlers.to_a
end
end end

View File

@@ -1,80 +1,97 @@
class PoolToBracketGenerateLoserNames class PoolToBracketGenerateLoserNames
def initialize( tournament ) def initialize(tournament)
@tournament = tournament @tournament = tournament
end
# 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
@tournament.weights.each_with_object([]) do |weight, all_rows|
all_rows.concat(assignLoserNamesWeight(weight))
end
end
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 end
def assignLoserNamesWeight(weight) if third_fourth
matches_by_weight = @tournament.matches.where(weight_id: weight.id) s1 = semis.find { |s| s[:bracket_position_number] == 1 }
if weight.pool_bracket_type == "twoPoolsToSemi" s2 = semis.find { |s| s[:bracket_position_number] == 2 }
twoPoolsToSemiLoser(matches_by_weight) third_fourth[:loser1_name] = "Loser of #{s1[:bout_number]}" if s1
elsif (weight.pool_bracket_type == "fourPoolsToQuarter") or (weight.pool_bracket_type == "eightPoolsToQuarter") third_fourth[:loser2_name] = "Loser of #{s2[:bout_number]}" if s2
fourPoolsToQuarterLoser(matches_by_weight)
elsif weight.pool_bracket_type == "fourPoolsToSemi"
fourPoolsToSemiLoser(matches_by_weight)
end
saveMatches(matches_by_weight)
end
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)
end end
end
def twoPoolsToSemiLoser(matches_by_weight) if seventh_eighth
match1 = matches_by_weight.select{|m| m.loser1_name == "Winner Pool 1"}.first c1 = conso_semis.find { |c| c[:bracket_position_number] == 1 }
match2 = matches_by_weight.select{|m| m.loser1_name == "Winner Pool 2"}.first c2 = conso_semis.find { |c| c[:bracket_position_number] == 2 }
matchChange = matches_by_weight.select{|m| m.bracket_position == "3/4"}.first seventh_eighth[:loser1_name] = "Loser of #{c1[:bout_number]}" if c1
matchChange.loser1_name = "Loser of #{match1.bout_number}" seventh_eighth[:loser2_name] = "Loser of #{c2[:bout_number]}" if c2
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 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 end
def fourPoolsToSemiLoser(matches_by_weight) def four_pools_to_semi_loser_rows(rows)
semis = matches_by_weight.select{|m| m.bracket_position == "Semis"} semis = rows.select { |m| m[:bracket_position] == "Semis" }
thirdFourth = matches_by_weight.select{|m| m.bracket_position == "3/4"}.first conso_semis = rows.select { |m| m[:bracket_position] == "Conso Semis" }
consoSemis = matches_by_weight.select{|m| m.bracket_position == "Conso Semis"} third_fourth = rows.find { |m| m[:bracket_position] == "3/4" }
seventhEighth = matches_by_weight.select{|m| m.bracket_position == "7/8"}.first seventh_eighth = rows.find { |m| m[:bracket_position] == "7/8" }
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) if third_fourth
matches.each do |m| s1 = semis.find { |s| s[:bracket_position_number] == 1 }
m.save! s2 = semis.find { |s| s[:bracket_position_number] == 2 }
end third_fourth[:loser1_name] = "Loser of #{s1[:bout_number]}" if s1
end 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 end

View File

@@ -1,41 +1,89 @@
class PoolToBracketMatchGeneration class PoolToBracketMatchGeneration
def initialize( tournament ) def initialize(tournament, weights: nil, wrestlers_by_weight_id: nil)
@tournament = tournament @tournament = tournament
@weights = weights
@wrestlers_by_weight_id = wrestlers_by_weight_id
end end
def generatePoolToBracketMatches def generatePoolToBracketMatches
@tournament.weights.order(:max).each do |weight| rows = []
PoolGeneration.new(weight).generatePools() generation_weights.each do |weight|
last_match = @tournament.matches.where(weight: weight).order(round: :desc).limit(1).first wrestlers = wrestlers_for_weight(weight)
highest_round = last_match.round pool_rows = PoolGeneration.new(weight, wrestlers: wrestlers).generatePools
PoolBracketGeneration.new(weight, highest_round).generateBracketMatches() 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 end
movePoolSeedsToFinalPoolRound
movePoolSeedsToFinalPoolRound(rows)
rows
end end
def movePoolSeedsToFinalPoolRound def movePoolSeedsToFinalPoolRound(match_rows)
@tournament.weights.each do |w| generation_weights.each do |w|
setOriginalSeedsToWrestleLastPoolRound(w) setOriginalSeedsToWrestleLastPoolRound(w, match_rows)
end end
end end
def setOriginalSeedsToWrestleLastPoolRound(weight) def setOriginalSeedsToWrestleLastPoolRound(weight, match_rows)
pool = 1 pool = 1
until pool > weight.pools wrestlers = wrestlers_for_weight(weight)
wrestler1 = weight.pool_wrestlers_sorted_by_bracket_line(pool).first weight_pools = weight.pools
wrestler2 = weight.pool_wrestlers_sorted_by_bracket_line(pool).second until pool > weight_pools
match = wrestler1.pool_matches.sort_by{|m| m.round}.last pool_wrestlers = wrestlers.select { |w| w.pool == pool }.sort_by(&:bracket_line)
if match.w1 != wrestler2.id or match.w2 != wrestler2.id wrestler1 = pool_wrestlers.first
if match.w1 == wrestler1.id wrestler2 = pool_wrestlers.second
SwapWrestlers.new.swap_wrestlers_bracket_lines(match.w2,wrestler2.id) if wrestler1 && wrestler2
elsif match.w2 == wrestler1.id pool_matches = match_rows.select { |row| row[:weight_id] == weight.id && row[:bracket_position] == "Pool" && (row[:w1] == wrestler1.id || row[:w2] == wrestler1.id) }
SwapWrestlers.new.swap_wrestlers_bracket_lines(match.w1,wrestler2.id) match = pool_matches.max_by { |row| row[:round] }
end 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 end
pool += 1 pool += 1
end end
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 def assignLoserNames
PoolToBracketGenerateLoserNames.new(@tournament).assignLoserNames PoolToBracketGenerateLoserNames.new(@tournament).assignLoserNames

View File

@@ -3,16 +3,22 @@ class TournamentSeeding
@tournament = tournament @tournament = tournament
end end
def set_seeds def set_seeds(weights: nil, persist: true)
@tournament.weights.each do |weight| weights_to_seed = weights || @tournament.weights.includes(:wrestlers)
updated_wrestlers = []
weights_to_seed.each do |weight|
wrestlers = weight.wrestlers wrestlers = weight.wrestlers
bracket_size = weight.calculate_bracket_size bracket_size = weight.calculate_bracket_size
wrestlers = reset_bracket_line_for_lines_higher_than_bracket_size(wrestlers, bracket_size) wrestlers = reset_bracket_line_for_lines_higher_than_bracket_size(wrestlers, bracket_size)
wrestlers = set_original_seed_to_bracket_line(wrestlers) wrestlers = set_original_seed_to_bracket_line(wrestlers)
wrestlers = random_seeding(wrestlers, bracket_size) wrestlers = random_seeding(wrestlers, bracket_size)
wrestlers.each(&:save) updated_wrestlers.concat(wrestlers)
end end
persist_bracket_lines(updated_wrestlers) if persist
updated_wrestlers
end end
def random_seeding(wrestlers, bracket_size) def random_seeding(wrestlers, bracket_size)
@@ -96,4 +102,19 @@ class TournamentSeeding
end end
result result
end 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 end

View File

@@ -14,7 +14,7 @@ class WipeTournamentMatches
end end
def wipeMatches def wipeMatches
@tournament.matches.destroy_all @tournament.destroy_all_matches
end end
def resetSchoolScores def resetSchoolScores

View File

@@ -3,11 +3,13 @@ class GeneratePoolNumbers
@weight = weight @weight = weight
end end
def savePoolNumbers def savePoolNumbers(wrestlers: nil, persist: true)
@weight.wrestlers.each do |wrestler| 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.pool = get_wrestler_pool_number(@weight.pools, wrestler.bracket_line)
wrestler.save
end end
persist_pool_numbers(wrestlers_to_update) if persist
wrestlers_to_update
end end
def get_wrestler_pool_number(number_of_pools, wrestler_seed) def get_wrestler_pool_number(number_of_pools, wrestler_seed)
@@ -36,4 +38,20 @@ class GeneratePoolNumbers
pool pool
end 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 end

View File

@@ -54,29 +54,20 @@ class CalculateWrestlerTeamScore
def byePoints def byePoints
points = 0 points = 0
if @tournament.tournament_type == "Pool to bracket" 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 points += 2
end end
end end
if @tournament.tournament_type.include? "Regular Double Elimination" if @tournament.tournament_type.include? "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 @wrestler.championship_advancement_wins.any? &&
# if they have a win in the championship round or if they got a bye all the way to finals and won the finals @wrestler.championship_byes.any? &&
points += @wrestler.championship_byes.size * 2 any_bye_round_had_wrestled_match?(@wrestler.championship_byes)
points += 2
end 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 @wrestler.consolation_advancement_wins.any? &&
# if they have a win in the consolation round or if they got a bye all the way to 3rd/4th match and won @wrestler.consolation_byes.any? &&
points += @wrestler.consolation_byes.size * 1 any_bye_round_had_wrestled_match?(@wrestler.consolation_byes)
end points += 1
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
end end
end end
return points return points
@@ -86,4 +77,30 @@ class CalculateWrestlerTeamScore
(@wrestler.pin_wins.size * 2) + (@wrestler.tech_wins.size * 1.5) + (@wrestler.major_wins.size * 1) (@wrestler.pin_wins.size * 2) + (@wrestler.tech_wins.size * 1.5) + (@wrestler.major_wins.size * 1)
end 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 end

View File

@@ -29,6 +29,7 @@
</ul> </ul>
</li> </li>
<li><%= link_to " Bout Board" , "/tournaments/#{@tournament.id}/up_matches", class: "fas fa-list-alt" %></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 %> <% end %>
<% if can? :manage, @tournament %> <% if can? :manage, @tournament %>
<li class="dropdown"> <li class="dropdown">

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

View File

@@ -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> <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> <h4>Bout <strong><%= @match.bout_number %></strong></h4>
<% if @show_next_bout_button && @next_match %> <% if @mat %>
<%= link_to "Skip to Next Match for Mat #{@mat.name}", mat_path(@mat, bout_number: @next_match.bout_number), class: "btn btn-primary" %> <% 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 %> <% end %>
<h4>Bracket Position: <strong><%= @match.bracket_position %></strong></h4> <h4>Bracket Position: <strong><%= @match.bracket_position %></strong></h4>
@@ -119,65 +130,6 @@
<br> <br>
<br> <br>
<br> <br>
<h4>Match Results</h4> <%= render "matches/match_results_fields", f: f, redirect_path: @match_results_redirect_path %>
<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 -->
</div><!-- End of match-data controller div --> </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 --> <% end %><!-- End of form_for -->

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

View File

@@ -10,6 +10,17 @@
class="alert alert-secondary" class="alert alert-secondary"
style="padding: 5px; margin-bottom: 10px; border-radius: 4px;"></div> style="padding: 5px; margin-bottom: 10px; border-radius: 4px;"></div>
<% unless @match.finished %>
<div data-match-spectate-target="scoreboardContainer" style="margin-bottom: 20px;">
<%= render "matches/scoreboard",
source_mode: "websocket",
display_mode: "embedded",
show_mat_banner: false,
match: @match,
tournament: @tournament %>
</div>
<% end %>
<div class="match-details"> <div class="match-details">
<div class="wrestler-info wrestler1"> <div class="wrestler-info wrestler1">
<h4><%= @wrestler1_name %> (<%= @wrestler1_school_name %>)</h4> <h4><%= @wrestler1_name %> (<%= @wrestler1_school_name %>)</h4>

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

View File

@@ -1,6 +1,8 @@
<% @mat = mat %> <% @mat = mat %>
<% @match = local_assigns[:match] || mat.queue1_match %> <% @queue_matches = local_assigns[:queue_matches] || mat.queue_matches %>
<% @next_match = local_assigns[:next_match] || mat.queue2_match %> <% @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 %> <% @show_next_bout_button = local_assigns.key?(:show_next_bout_button) ? local_assigns[:show_next_bout_button] : true %>
<% @wrestlers = [] %> <% @wrestlers = [] %>

View File

@@ -0,0 +1,7 @@
<%= render "matches/scoreboard",
source_mode: "localstorage",
display_mode: "fullscreen",
show_mat_banner: true,
mat: @mat,
match: @match,
tournament: @tournament %>

View 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 %>

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

View File

@@ -54,19 +54,8 @@
<tbody> <tbody>
<% @wrestlers.sort_by { |w| w.weight.max }.each do |wrestler| %> <% @wrestlers.sort_by { |w| w.weight.max }.each do |wrestler| %>
<% if params[:school_permission_key].present? %> <% if params[:school_permission_key].present? %>
<!-- No caching when school_permission_key is present -->
<tr> <tr>
<td> <%= render "schools/wrestler_row_cells", wrestler: wrestler, school_permission_key: params[:school_permission_key] %>
<% 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>
<% if can? :manage, wrestler.school %> <% if can? :manage, wrestler.school %>
<td> <td>
@@ -86,34 +75,21 @@
<% end %> <% end %>
</tr> </tr>
<% else %> <% else %>
<!-- Use caching only when school_permission_key is NOT present --> <tr>
<% cache ["#{wrestler.id}_school_show", @school] do %> <% cache ["school_show_wrestler_cells", wrestler] do %>
<tr> <%= render "schools/wrestler_row_cells", wrestler: wrestler %>
<td><%= link_to wrestler.name, wrestler_path(wrestler) %></td> <% end %>
<td><%= wrestler.weight.max %></td> <% if can? :manage, wrestler.school %>
<td><%= wrestler.season_win %>-<%= wrestler.season_loss %> <%= wrestler.criteria %></td> <td>
<td><%= wrestler.original_seed %></td> <%= link_to edit_wrestler_path(wrestler), class: "text-decoration-none" do %>
<td><%= wrestler.total_team_points - wrestler.total_points_deducted %></td> <span class="fas fa-edit" aria-hidden="true"></span>
<td><%= "Yes" if wrestler.extra? %></td> <% end %>
<td><%= wrestler.next_match_bout_number %> <%= wrestler.next_match_mat_name %></td> <%= 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 %>
<% end %> <span class="fas fa-trash-alt" aria-hidden="true"></span>
<% if can? :manage, wrestler.school %> <% end %>
<td> </td>
<% edit_wrestler_path_with_key = edit_wrestler_path(wrestler) %> <% end %>
<% edit_wrestler_path_with_key += "?school_permission_key=#{params[:school_permission_key]}" if params[:school_permission_key].present? %> </tr>
<% 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 %>
<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 %>
<span class="fas fa-trash-alt" aria-hidden="true"></span>
<% end %>
</td>
<% end %>
</tr>
<% end %> <% end %>
<% end %> <% end %>
</tbody> </tbody>

View File

@@ -48,7 +48,7 @@
<li>Win by major: 1pt extra</li> <li>Win by major: 1pt extra</li>
<li>Win by tech fall: 1.5pt extra</li> <li>Win by tech fall: 1.5pt extra</li>
<li>Win by fall, default, dq: 2pt 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> </ul>
<p>See placement points below (based on the largest bracket of the tournament)</p> <p>See placement points below (based on the largest bracket of the tournament)</p>
<h4>Pool Types</h4> <h4>Pool Types</h4>
@@ -71,7 +71,7 @@
<li>Win by major: 1pt extra</li> <li>Win by major: 1pt extra</li>
<li>Win by tech: 1.5pt extra</li> <li>Win by tech: 1.5pt extra</li>
<li>Win by fall, default, dq, etc: 2pt 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> </ul>
<br> <br>
<h3>Modified 16 Man Double Elimination Information</h3> <h3>Modified 16 Man Double Elimination Information</h3>
@@ -142,7 +142,7 @@
<br> <br>
<h3>Future Plans</h3> <h3>Future Plans</h3>
<br> <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> <br>
<h3>Contact</h3> <h3>Contact</h3>
<br> <br>

View File

@@ -1,11 +1,5 @@
<% if @tournaments.size > 0 %> <% if @tournaments.size > 0 %>
<h3>My Tournaments</h3> <h3>My Tournaments</h3>
<script>
// $(document).ready(function() {
// $('#tournamentList').dataTable();
// pagingType: "bootstrap";
// } );
</script>
<table class="table table-hover" id="tournamentList"> <table class="table table-hover" id="tournamentList">
<thead> <thead>
<tr> <tr>

View File

@@ -1,13 +1,15 @@
<% @final_match.each do |match| %> <% @final_match.each do |match| %>
<div class="round"> <% cache ["bracket_final_match", match, match.wrestler1, match.wrestler2, @winner_place, params[:print].to_s] do %>
<div class="game"> <div class="round">
<div class="game-top "><%= match.w1_bracket_name.html_safe %> <span></span></div> <div class="game">
<% if params[:print] %> <div class="game-top "><%= match.w1_bracket_name.html_safe %> <span></span></div>
<div class="bout-number"><p><%= match.bout_number %> <%= match.bracket_score_string %></p><p><%= @winner_place %> Place Winner</p></div> <% if params[:print] %>
<% else %> <div class="bout-number"><p><%= match.bout_number %> <%= match.bracket_score_string %></p><p><%= @winner_place %> Place Winner</p></div>
<div class="bout-number"><p><%= link_to match.bout_number, spectate_match_path(match) %> <%= match.bracket_score_string %></p><p><%= @winner_place %> Place Winner</p></div> <% else %>
<% end %> <div class="bout-number"><p><%= link_to match.bout_number, spectate_match_path(match) %> <%= match.bracket_score_string %></p><p><%= @winner_place %> Place Winner</p></div>
<div class="game-bottom "><%= match.w2_bracket_name.html_safe %><span></span></div> <% end %>
</div> <div class="game-bottom "><%= match.w2_bracket_name.html_safe %><span></span></div>
</div> </div>
</div>
<% end %>
<% end %> <% end %>

View File

@@ -1,18 +1,26 @@
<style> <style>
table.smallText tr td { font-size: 10px; } 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 Layout Specifics
*/ */
.bracket { .bracket {
display: flex; display: flex;
font-size: 10px; font-size: 10.5px;
gap: 2px;
} }
.game { .game {
min-width: 150px; min-width: 150px;
min-height: 50px; min-height: 58px;
/*background-color: #ddd;*/ /*background-color: #ddd;*/
border: 1px solid #000; /* Dark border so boxes stay visible when printed */ border: 1.5px solid #000; /* Dark border so boxes stay visible when printed */
margin: 5px; margin: 4px;
} }
/*.game:after { /*.game:after {
@@ -56,14 +64,15 @@ table.smallText tr td { font-size: 10px; }
} }
.game-top { .game-top {
border-bottom:1px solid #000; border-bottom:1.5px solid #000;
padding: 2px; padding: 3px 4px;
min-height: 12px; min-height: 16px;
} }
.bout-number { .bout-number {
text-align: center; text-align: center;
/*padding-top: 15px;*/ line-height: 1.35;
padding: 1px 2px;
} }
/* Style links within bout-number like default links */ /* Style links within bout-number like default links */
@@ -77,15 +86,29 @@ table.smallText tr td { font-size: 10px; }
} }
.bracket-winner { .bracket-winner {
border-bottom:1px solid #000; border-bottom:1.5px solid #000;
padding: 2px; padding: 3px 4px;
min-height: 12px; min-height: 16px;
} }
.game-bottom { .game-bottom {
border-top:1px solid #000; border-top:1.5px solid #000;
padding: 2px; padding: 3px 4px;
min-height: 12px; 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> </style>
<% if @tournament.tournament_type == "Pool to bracket" %> <% if @tournament.tournament_type == "Pool to bracket" %>
@@ -95,8 +118,6 @@ table.smallText tr td { font-size: 10px; }
<table class='smallText'> <table class='smallText'>
<tr> <tr>
<td valign="top" style="padding: 10px;"> <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) %> <% @pools = @weight.pool_rounds(@matches) %>
<%= render 'pool' %> <%= render 'pool' %>
</td> </td>

View File

@@ -1,15 +1,17 @@
<div class="round"> <div class="round">
<% @round_matches.sort_by{|m| m.bracket_position_number}.each do |match| %> <% @round_matches.sort_by{|m| m.bracket_position_number}.each do |match| %>
<div class="game"> <% cache ["bracket_round_match", match, match.wrestler1, match.wrestler2, params[:print].to_s] do %>
<div class="game-top "><%= match.w1_bracket_name.html_safe %> <span></span></div> <div class="game">
<% if params[:print] %> <div class="game-top "><%= match.w1_bracket_name.html_safe %> <span></span></div>
<div class="bout-number"><%= match.bout_number %> <%= match.bracket_score_string %>&nbsp;</div> <% if params[:print] %>
<% else %> <div class="bout-number"><%= match.bout_number %> <%= match.bracket_score_string %>&nbsp;</div>
<div class="bout-number"><%= link_to match.bout_number, spectate_match_path(match) %> <%= match.bracket_score_string %>&nbsp;</div> <% else %>
<% end %> <div class="bout-number"><%= link_to match.bout_number, spectate_match_path(match) %> <%= match.bracket_score_string %>&nbsp;</div>
<div class="bout-number">Round <%= match.round %></div> <% end %>
<div class="bout-number"><%= match.bracket_position %></div> <div class="bout-number">Round <%= match.round %></div>
<div class="game-bottom "><%= match.w2_bracket_name.html_safe %><span></span></div> <div class="bout-number"><%= match.bracket_position %></div>
</div> <div class="game-bottom "><%= match.w2_bracket_name.html_safe %><span></span></div>
</div>
<% end %>
<% end %> <% end %>
</div> </div>

View File

@@ -0,0 +1,103 @@
<%
match = local_assigns[:match]
mat = local_assigns[:mat]
tournament = local_assigns[:tournament]
%>
<div
data-controller="match-scoreboard"
data-match-scoreboard-source-mode-value="mat_websocket"
data-match-scoreboard-display-mode-value="embedded"
data-match-scoreboard-match-id-value="<%= match&.id || 0 %>"
data-match-scoreboard-mat-id-value="<%= mat.id %>"
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;">
<div class="panel-heading">
<strong>Mat <%= mat.name %></strong>
</div>
<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>
<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-<%= mat.id %>" aria-expanded="false" aria-controls="live-score-stats-<%= mat.id %>" 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-<%= mat.id %>" 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-<%= mat.id %>" aria-expanded="false" aria-controls="live-score-last-result-<%= mat.id %>" 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-<%= mat.id %>" class="panel-collapse collapse">
<div class="panel-body">
<div data-match-scoreboard-target="lastMatchResult">-</div>
</div>
</div>
</div>
</td>
</tr>
</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>

View 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 %>

View 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&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</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>

View File

@@ -1,5 +1,6 @@
<% queue1_match, queue2_match, queue3_match, queue4_match = mat.queue_matches %> <% queue1_match, queue2_match, queue3_match, queue4_match = mat.queue_matches %>
<% cache ["up_matches_mat_row", mat, mat.queue1, mat.queue2, mat.queue3, mat.queue4] do %> <% 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> <tr>
<td><%= mat.name %></td> <td><%= mat.name %></td>
<td> <td>

View File

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

View File

@@ -1,20 +1,20 @@
<style> <style>
/* General styles for pages */ /* General styles for pages */
@page { @page {
margin: 0.5in; /* Universal margin for all pages */ margin: 0.35in;
} }
.page { .page {
width: 7.5in; /* Portrait width (8.5in - margins) */ width: 7.8in; /* 8.5in - 2 * 0.35in */
height: 10in; /* Portrait height (11in - margins) */ height: 10.3in; /* 11in - 2 * 0.35in */
margin: auto; margin: auto;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
.page-landscape { .page-landscape {
width: 10in; /* Landscape width (11in - margins) */ width: 10.3in; /* 11in - 2 * 0.35in */
height: 7.5in; /* Landscape height (8.5in - margins) */ height: 7.8in; /* 8.5in - 2 * 0.35in */
margin: auto; margin: auto;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
@@ -26,6 +26,11 @@
transform-origin: top left; transform-origin: top left;
} }
.bracket-container h4 {
margin-top: 0.15rem;
margin-bottom: 0.45rem;
}
/* Print-specific styles */ /* Print-specific styles */
@media print { @media print {
/* Set orientation for portrait pages */ /* Set orientation for portrait pages */
@@ -51,6 +56,10 @@
transform-origin: top left; transform-origin: top left;
} }
.bracket {
page-break-inside: avoid;
}
/* Optional: Hide elements not needed in print */ /* Optional: Hide elements not needed in print */
.no-print { .no-print {
display: none; display: none;
@@ -62,15 +71,10 @@
function scaleContent() { function scaleContent() {
document.querySelectorAll('.page, .page-landscape').forEach(page => { document.querySelectorAll('.page, .page-landscape').forEach(page => {
const container = page.querySelector('.bracket-container'); const container = page.querySelector('.bracket-container');
const isLandscape = page.classList.contains('page-landscape');
// Page dimensions (1 inch = 96px) // Use the actual page box size (already accounts for @page margins)
const pageWidth = isLandscape ? 10 * 96 : 7.5 * 96; const availableWidth = page.clientWidth;
const pageHeight = isLandscape ? 7.5 * 96 : 10 * 96; const availableHeight = page.clientHeight;
// Subtract margins (0.5 inch margin)
const availableWidth = pageWidth - (0.5 * 96 * 2);
const availableHeight = pageHeight - (0.5 * 96 * 2);
// Measure content dimensions // Measure content dimensions
const contentWidth = container.scrollWidth; const contentWidth = container.scrollWidth;
@@ -80,8 +84,8 @@
const scaleX = availableWidth / contentWidth; const scaleX = availableWidth / contentWidth;
const scaleY = availableHeight / contentHeight; const scaleY = availableHeight / contentHeight;
// Use a slightly relaxed scaling to avoid over-aggressive shrinking // Keep a tiny buffer so borders/text don't clip at print edges
const scale = Math.min(scaleX, scaleY, 1); // Ensure scale does not exceed 100% (1) const scale = Math.min(scaleX, scaleY, 1) * 0.99;
// Apply the scale // Apply the scale
container.style.transform = `scale(${scale})`; container.style.transform = `scale(${scale})`;
@@ -91,9 +95,9 @@
const scaledWidth = contentWidth * scale; const scaledWidth = contentWidth * scale;
const scaledHeight = contentHeight * scale; const scaledHeight = contentHeight * scale;
// Center the content within the page // Center the content within the available page box
const horizontalPadding = (pageWidth - scaledWidth) / 2; const horizontalPadding = (availableWidth - scaledWidth) / 2;
const verticalPadding = (pageHeight - scaledHeight) / 2; const verticalPadding = (availableHeight - scaledHeight) / 2;
// Apply margin adjustments // Apply margin adjustments
container.style.marginLeft = `${Math.max(0, horizontalPadding)}px`; container.style.marginLeft = `${Math.max(0, horizontalPadding)}px`;
@@ -119,14 +123,15 @@
<% @weights.sort_by{|w| w.max}.each do |weight| %> <% @weights.sort_by{|w| w.max}.each do |weight| %>
<% if @tournament.tournament_type == "Pool to bracket" %> <% if @tournament.tournament_type == "Pool to bracket" %>
<!-- Need to define what the tournaments#bracket controller defines --> <!-- Need to define what the tournaments#bracket controller defines -->
<% @matches = @tournament.matches.select{|m| m.weight_id == weight.id} %> <% @matches = @matches_by_weight_id[weight.id] || [] %>
<% @wrestlers = Wrestler.where(weight_id: weight.id) %> <% @wrestlers = @wrestlers_by_weight_id[weight.id] || [] %>
<% @pools = weight.pool_rounds(@matches) %> <% @pools = weight.pool_rounds(@matches) %>
<% @weight = weight %> <% @weight = weight %>
<%= render 'bracket_partial' %> <%= render 'bracket_partial' %>
<% elsif @tournament.tournament_type.include? "Modified 16 Man Double Elimination" or @tournament.tournament_type.include? "Regular Double Elimination" %> <% 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 --> <!-- 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 %> <% @weight = weight %>
<%= render 'bracket_partial' %> <%= render 'bracket_partial' %>
<% end %> <% end %>

View File

@@ -55,10 +55,10 @@
</style> </style>
<% @matches.each do |match| %> <% @matches.each do |match| %>
<% if match.w1 && match.w2 %> <% w1 = @wrestlers_by_id[match.w1] %>
<% w1 = Wrestler.find(match.w1) %> <% w2 = @wrestlers_by_id[match.w2] %>
<% w2 = Wrestler.find(match.w2) %> <% w1_name = w1&.name || match.loser1_name %>
<% end %> <% w2_name = w2&.name || match.loser2_name %>
<div class="pagebreak"> <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> <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"> <tr class="small-row">
<th class="fixed-width">Circle Winner</th> <th class="fixed-width">Circle Winner</th>
<th> <th>
<p><%= match.w1_name %>-<%= w1&.school&.name %></p> <p><%= w1_name %>-<%= w1&.school&.name %></p>
</th> </th>
<th> <th>
<p><%= match.w2_name %>-<%= w2&.school&.name %></p> <p><%= w2_name %>-<%= w2&.school&.name %></p>
</th> </th>
</tr> </tr>
</thead> </thead>

View File

@@ -1,4 +1,4 @@
<% cache ["#{@weight.id}_bracket", @weight] do %> <% cache ["#{@weight.id}_bracket", @weight, params[:print].to_s] do %>
<%= render 'bracket_partial' %> <%= render 'bracket_partial' %>
<% end %> <% end %>
<% if @tournament.tournament_type == "Pool to bracket" %> <% if @tournament.tournament_type == "Pool to bracket" %>

View File

@@ -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": { "tournament": {
"attributes": <%= @tournament.attributes.to_json %>, "attributes": <%= @tournament.attributes.to_json %>,
@@ -14,13 +20,13 @@
"weight": wrestler.weight&.attributes "weight": wrestler.weight&.attributes
} }
) }.to_json %>, ) }.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, "w1_name": wrestlers_by_id[match.w1]&.name,
"w2_name": Wrestler.find_by(id: match.w2)&.name, "w2_name": wrestlers_by_id[match.w2]&.name,
"winner_name": Wrestler.find_by(id: match.winner_id)&.name, "winner_name": wrestlers_by_id[match.winner_id]&.name,
"weight": Weight.find_by(id: match.weight_id)&.attributes, "weight": weights_by_id[match.weight_id]&.attributes,
"mat": Mat.find_by(id: match.mat_id)&.attributes "mat": mats_by_id[match.mat_id]&.attributes
} }
) }.to_json %> ) }.to_json %>
} }

View 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 %>

View File

@@ -1,13 +1,15 @@
<h1>All <%= @tournament.name %> matches</h1> <h1>All <%= @tournament.name %> matches</h1>
<script>
$(document).ready(function() { <% matches_path = "/tournaments/#{@tournament.id}/matches" %>
$('#matchesList').dataTable();
pagingType: "bootstrap"; <%= form_tag(matches_path, method: :get, id: "search-form") do %>
} ); <%= text_field_tag :search, params[:search], placeholder: "Search wrestler, school, or bout #" %>
</script> <%= submit_tag "Search" %>
</br> <% end %>
<p>Search by wrestler name, school name, or bout number.</p>
<br>
<table class="table table-striped table-bordered table-condensed" id="matchesList"> <table class="table table-striped table-bordered table-condensed" id="matchesList">
<thead> <thead>
<tr> <tr>
@@ -35,6 +37,49 @@
<% end %> <% end %>
</tbody> </tbody>
</table> </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> <br>
<p>Total matches without byes: <%= @matches.select{|m| m.loser1_name != 'BYE' and m.loser2_name != 'BYE'}.size %></p> <p>Total matches without byes: <%= @matches_without_byes_count %></p>
<p>Unfinished matches: <%= @matches.select{|m| m.finished != 1 and m.loser1_name != 'BYE' and m.loser2_name != 'BYE'}.size %></p> <p>Unfinished matches: <%= @unfinished_matches_without_byes_count %></p>

View File

@@ -129,13 +129,24 @@
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>Current Match</th>
<th><%= link_to " New Mat" , "/mats/new?tournament=#{@tournament.id}", :class=>"fas fa-plus" %></th> <th><%= link_to " New Mat" , "/mats/new?tournament=#{@tournament.id}", :class=>"fas fa-plus" %></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% @mats.each do |mat| %> <% @mats.each do |mat| %>
<% current_match = mat.queue1_match %>
<tr> <tr>
<td><%= link_to "Mat #{mat.name}", mat %></td> <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 %> <% if can? :manage, @tournament %>
<td> <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 %> <%= link_to mat, data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete Mat #{mat.name}?" }, class: "text-decoration-none" do %>

View File

@@ -1,6 +1,5 @@
<% team_scores_last_updated = @schools.map(&:updated_at).compact.max&.utc&.to_fs(:nsec) %>
<% cache ["#{@tournament.id}_team_scores", @tournament] do %> <% cache ["team_scores", @tournament.id, @schools.size, team_scores_last_updated] do %>
<table class="pagebreak table table-striped table-bordered"> <table class="pagebreak table table-striped table-bordered">
<h3>Team Scores</h3> <h3>Team Scores</h3>
<thead> <thead>
@@ -11,11 +10,8 @@
</thead> </thead>
<tbody> <tbody>
<% @schools.each do |school| %> <% @schools.each_with_index do |school, index| %>
<tr> <%= render "tournaments/team_score_row", school: school, rank: index + 1 %>
<td><%= @schools.index(school) + 1 %>. <%= school.name %> (<%= school.abbreviation %>)</td>
<td><%= school.page_score_string %></td>
</tr>
<% end %> <% end %>
</tbody> </tbody>
</table> </table>

View File

@@ -1,75 +1,19 @@
<script> <div data-controller="up-matches-connection">
// $(document).ready(function() { <% if params[:print] != "true" %>
// $('#matchList').dataTable(); <div style="margin-bottom: 10px;">
// } ); <%= link_to "Show Bout Board in Full Screen", up_matches_path(@tournament, print: true), class: "btn btn-primary" %>
</script> </div>
<script> <% end %>
const setUpMatchesRefresh = () => {
if (window.__upMatchesRefreshTimeout) {
clearTimeout(window.__upMatchesRefreshTimeout);
}
window.__upMatchesRefreshTimeout = setTimeout(() => {
window.location.reload(true);
}, 30000);
};
document.addEventListener("turbo:load", setUpMatchesRefresh); <%= turbo_stream_from @tournament, data: { up_matches_connection_target: "stream" } %>
// turbo:before-cache stops the timer refresh from occurring if you navigate away from up_matches <div
document.addEventListener("turbo:before-cache", () => { id="up-matches-cable-status-indicator"
if (window.__upMatchesRefreshTimeout) { data-up-matches-connection-target="statusIndicator"
clearTimeout(window.__upMatchesRefreshTimeout); class="alert alert-secondary"
window.__upMatchesRefreshTimeout = null; style="padding: 5px; margin-bottom: 10px; border-radius: 4px;"
} >
}); Connecting to server for real-time up matches updates...
</script> </div>
<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&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</th>
<th>On Mat</th>
<th>On Deck</th>
<th>In The Hole</th>
<th>Warm Up</th>
</tr>
</thead>
<tbody> <%= render "up_matches_board", tournament: @tournament, mats: @mats, matches: @matches %>
<% @mats.each.map do |m| %> </div>
<%= render "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>
<% 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>

View File

@@ -12,9 +12,10 @@
height: 1in; height: 1in;
} }
</style> </style>
<% @tournament.schools.each do |school| %> <% @schools.each do |school| %>
<table class="table table-striped table-bordered table-condensed pagebreak"> <table class="table table-striped table-bordered table-condensed pagebreak">
<h5><%= school.name %></h4> <h5><%= school.name %></h4>
<p><strong>Weigh In Ref:</strong> <%= @tournament.weigh_in_ref %></p>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
@@ -27,7 +28,7 @@
<tr> <tr>
<td><%= wrestler.name %></td> <td><%= wrestler.name %></td>
<td><%= wrestler.weight.max %></td> <td><%= wrestler.weight.max %></td>
<td></td> <td><%= wrestler.offical_weight %></td>
</tr> </tr>
<% end %> <% end %>
</tbody> </tbody>

View File

@@ -9,7 +9,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <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| %> <% @wrestlers.order("original_seed asc").each do |wrestler| %>
<% if wrestler.weight_id == @weight.id %> <% if wrestler.weight_id == @weight.id %>
<tr> <tr>
@@ -19,7 +19,7 @@
<td><%= wrestler.weight.max %></td> <td><%= wrestler.weight.max %></td>
<td> <td>
<% if user_signed_in? %> <% 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' %> <%= w.number_field :offical_weight, :step => 'any' %>
<% end %> <% end %>
<% else %> <% else %>

View 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 %>

View File

@@ -14,35 +14,36 @@
</tr> </tr>
</thead> </thead>
<tbody> <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 wrestler.weight_id == @weight.id %> <% if can? :manage, @tournament %>
<tr> <% sorted_wrestlers.each do |wrestler| %>
<td><%= link_to "#{wrestler.name}", wrestler %></td> <% if wrestler.weight_id == @weight.id %>
<td><%= wrestler.school.name %></td> <tr>
<td> <td><%= link_to wrestler.name, wrestler %></td>
<% if can? :manage, @tournament %> <td><%= wrestler.school.name %></td>
<%= fields_for "wrestler[]", wrestler do |w| %> <td>
<%= w.text_field :original_seed %> <%= fields_for "wrestler[]", wrestler do |w| %>
<% end %> <%= w.text_field :original_seed %>
<% else %> <% end %>
<%= wrestler.original_seed %> </td>
<% end %> <td><%= wrestler.season_win %>-<%= wrestler.season_loss %></td>
</td> <td><%= wrestler.criteria %> Win <%= wrestler.season_win_percentage %>%</td>
<td><%= wrestler.season_win %>-<%= wrestler.season_loss %></td> <td><%= "Yes" if wrestler.extra? %></td>
<td><%= wrestler.criteria %> Win <%= wrestler.season_win_percentage %>%</td> <td>
<td><% if wrestler.extra? == true %> <%= 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 %>
Yes <span class="fas fa-trash-alt" aria-hidden="true"></span>
<% end %></td> <% end %>
<% if can? :manage, @tournament %> </td>
<td> </tr>
<%= 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 %> <% end %>
<span class="fas fa-trash-alt" aria-hidden="true"></span> <% end %>
<% end %> <% else %>
</td> <% sorted_wrestlers.each do |wrestler| %>
<% end %> <% if wrestler.weight_id == @weight.id %>
</tr> <%= render "weights/readonly_wrestler_row", wrestler: wrestler %>
<% end %> <% end %>
<% end %> <% end %>
<% end %>
</tbody> </tbody>
</table> </table>
<br><p>*All wrestlers without a seed (determined by tournament director) will be assigned a random bracket line.</p> <br><p>*All wrestlers without a seed (determined by tournament director) will be assigned a random bracket line.</p>

View File

@@ -2,6 +2,8 @@
project_dir="$(dirname $( dirname $(readlink -f ${BASH_SOURCE[0]})))" project_dir="$(dirname $( dirname $(readlink -f ${BASH_SOURCE[0]})))"
cd ${project_dir} cd ${project_dir}
npm install
npm run test:js
bundle exec rake db:migrate RAILS_ENV=test bundle exec rake db:migrate RAILS_ENV=test
CI=true brakeman CI=true brakeman
bundle audit bundle audit

View File

@@ -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 build -f ${project_dir}/deploy/rails-prod-Dockerfile -t wrestlingdevtests ${project_dir}/.
docker run --rm -it wrestlingdevtests bash /rails/bin/run-all-tests.sh 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

View File

@@ -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: development:
adapter: async adapter: solid_cable
connects_to:
database:
writing: cable
polling_interval: 0.1.seconds
message_retention: 1.day
test: test:
adapter: test adapter: test

View File

@@ -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" # and pin it directly, e.g., pin "jquery", to: "jquery.min.js"
pin "jquery", to: "jquery.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 "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/ # 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 "@popperjs/core", to: "popper.min.js" # Or the actual filename if different
# Pin controllers from app/assets/javascripts/controllers # Pin controllers from app/assets/javascripts/controllers
pin_all_from "app/assets/javascripts/controllers", under: "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 JS files from app/assets/javascripts directory
pin_all_from "app/assets/javascripts", under: "assets/javascripts" pin_all_from "app/assets/javascripts", under: "assets/javascripts"

View File

@@ -3,12 +3,19 @@ Wrestling::Application.routes.draw do
mount ActionCable.server => '/cable' mount ActionCable.server => '/cable'
mount MissionControl::Jobs::Engine, at: "/jobs" 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 post "mats/:id/assign_next_match" => "mats#assign_next_match", :as => :assign_next_match
resources :matches do resources :matches do
member do member do
get :stat get :stat
get :state
get :spectate get :spectate
get :edit_assignment get :edit_assignment
patch :update_assignment patch :update_assignment
@@ -74,6 +81,7 @@ Wrestling::Application.routes.draw do
get 'tournaments/:id/no_matches' => 'tournaments#no_matches' get 'tournaments/:id/no_matches' => 'tournaments#no_matches'
get 'tournaments/:id/matches' => 'tournaments#matches' get 'tournaments/:id/matches' => 'tournaments#matches'
get 'tournaments/:id/qrcode' => 'tournaments#qrcode' get 'tournaments/:id/qrcode' => 'tournaments#qrcode'
get 'tournaments/:id/live_scores' => 'tournaments#live_scores'
get 'tournaments/:id/delegate' => 'tournaments#delegate', :as => :tournament_delegate get 'tournaments/:id/delegate' => 'tournaments#delegate', :as => :tournament_delegate
post 'tournaments/:id/delegate' => 'tournaments#delegate', :as => :set_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 delete 'tournaments/:id/:delegate/remove_delegate' => 'tournaments#remove_delegate', :as => :delete_delegate_path

2162
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "wrestlingapp-js-tests",
"private": true,
"type": "module",
"scripts": {
"test:js": "vitest run",
"test:js:watch": "vitest"
},
"devDependencies": {
"vitest": "^3.2.4"
}
}

View File

@@ -0,0 +1,110 @@
require "test_helper"
class MatScoreboardChannelTest < ActionCable::Channel::TestCase
setup do
@mat = mats(:one)
@match = matches(:tournament_1_bout_1000)
@alternate_match = matches(:tournament_1_bout_1001)
Rails.cache.clear
@mat.update!(queue1: @match.id, queue2: @alternate_match.id, queue3: nil, queue4: nil)
@mat.set_selected_scoreboard_match!(@match)
@mat.set_last_match_result!("106 lbs - Example Winner Decision Example Loser 3-1")
end
test "subscribes to a valid mat stream and transmits scoreboard payload" do
subscribe(mat_id: @mat.id)
assert subscription.confirmed?
assert_has_stream_for @mat
assert_equal(
{
"mat_id" => @mat.id,
"queue1_bout_number" => @match.bout_number,
"queue1_match_id" => @match.id,
"selected_bout_number" => @match.bout_number,
"selected_match_id" => @match.id,
"last_match_result" => "106 lbs - Example Winner Decision Example Loser 3-1"
},
transmissions.last
)
end
test "rejects subscription for an invalid mat" do
subscribe(mat_id: -1)
assert subscription.rejected?
end
test "transmits payload with queue1 and no selected match" do
@mat.set_selected_scoreboard_match!(nil)
subscribe(mat_id: @mat.id)
assert_equal(
{
"mat_id" => @mat.id,
"queue1_bout_number" => @match.bout_number,
"queue1_match_id" => @match.id,
"selected_bout_number" => nil,
"selected_match_id" => nil,
"last_match_result" => "106 lbs - Example Winner Decision Example Loser 3-1"
},
transmissions.last
)
end
test "transmits payload when selected match differs from queue1" do
@mat.set_selected_scoreboard_match!(@alternate_match)
subscribe(mat_id: @mat.id)
assert_equal(
{
"mat_id" => @mat.id,
"queue1_bout_number" => @match.bout_number,
"queue1_match_id" => @match.id,
"selected_bout_number" => @alternate_match.bout_number,
"selected_match_id" => @alternate_match.id,
"last_match_result" => "106 lbs - Example Winner Decision Example Loser 3-1"
},
transmissions.last
)
end
test "transmits payload when no queue1 match exists" do
@mat.update!(queue1: nil, queue2: nil, queue3: nil, queue4: nil)
@mat.set_selected_scoreboard_match!(nil)
subscribe(mat_id: @mat.id)
assert_equal(
{
"mat_id" => @mat.id,
"queue1_bout_number" => nil,
"queue1_match_id" => nil,
"selected_bout_number" => nil,
"selected_match_id" => nil,
"last_match_result" => "106 lbs - Example Winner Decision Example Loser 3-1"
},
transmissions.last
)
end
test "transmits payload with blank last match result" do
@mat.set_last_match_result!(nil)
subscribe(mat_id: @mat.id)
assert_equal(
{
"mat_id" => @mat.id,
"queue1_bout_number" => @match.bout_number,
"queue1_match_id" => @match.id,
"selected_bout_number" => @match.bout_number,
"selected_match_id" => @match.id,
"last_match_result" => nil
},
transmissions.last
)
end
end

View File

@@ -1,8 +1,152 @@
require "test_helper" require "test_helper"
class MatchChannelTest < ActionCable::Channel::TestCase class MatchChannelTest < ActionCable::Channel::TestCase
# test "subscribes" do setup do
# subscribe @match = matches(:tournament_1_bout_1000)
# assert subscription.confirmed? Rails.cache.clear
# end end
test "subscribes to a valid match stream" do
subscribe(match_id: @match.id)
assert subscription.confirmed?
assert_has_stream_for @match
end
test "invalid match subscription confirms but does not stream" do
subscribe(match_id: -1)
assert subscription.confirmed?
assert_empty subscription.streams
end
test "send_stat updates the match and broadcasts stats" do
subscribe(match_id: @match.id)
assert_broadcast_on(@match, { w1_stat: "T3", w2_stat: "E1" }) do
perform :send_stat, {
new_w1_stat: "T3",
new_w2_stat: "E1"
}
end
@match.reload
assert_equal "T3", @match.w1_stat
assert_equal "E1", @match.w2_stat
end
test "send_stat updates only w1 stat when only w1 is provided" do
subscribe(match_id: @match.id)
assert_broadcast_on(@match, { w1_stat: "T3", w2_stat: nil }.compact) do
perform :send_stat, { new_w1_stat: "T3" }
end
@match.reload
assert_equal "T3", @match.w1_stat
assert_nil @match.w2_stat
end
test "send_stat updates only w2 stat when only w2 is provided" do
subscribe(match_id: @match.id)
assert_broadcast_on(@match, { w1_stat: nil, w2_stat: "E1" }.compact) do
perform :send_stat, { new_w2_stat: "E1" }
end
@match.reload
assert_nil @match.w1_stat
assert_equal "E1", @match.w2_stat
end
test "send_stat with empty payload does not update or broadcast" do
subscribe(match_id: @match.id)
stream = MatchChannel.broadcasting_for(@match)
ActionCable.server.pubsub.broadcasts(stream).clear
perform :send_stat, {}
@match.reload
assert_nil @match.w1_stat
assert_nil @match.w2_stat
assert_empty ActionCable.server.pubsub.broadcasts(stream)
end
test "send_scoreboard caches and broadcasts scoreboard state" do
subscribe(match_id: @match.id)
scoreboard_state = {
"participantScores" => { "w1" => 2, "w2" => 0 },
"metadata" => { "boutNumber" => @match.bout_number }
}
assert_broadcast_on(@match, { scoreboard_state: scoreboard_state }) do
perform :send_scoreboard, { scoreboard_state: scoreboard_state }
end
cached_state = Rails.cache.read("tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state")
assert_equal scoreboard_state, cached_state
end
test "send_scoreboard with blank payload does not cache or broadcast" do
subscribe(match_id: @match.id)
stream = MatchChannel.broadcasting_for(@match)
ActionCable.server.pubsub.broadcasts(stream).clear
perform :send_scoreboard, { scoreboard_state: nil }
assert_nil Rails.cache.read("tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state")
assert_empty ActionCable.server.pubsub.broadcasts(stream)
end
test "request_sync transmits match data and cached scoreboard state" do
@match.update!(
w1_stat: "T3",
w2_stat: "E1",
score: "3-1",
win_type: "Decision",
winner_id: @match.w1,
finished: 1
)
scoreboard_state = {
"participantScores" => { "w1" => 3, "w2" => 1 },
"metadata" => { "boutNumber" => @match.bout_number }
}
Rails.cache.write(
"tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state",
scoreboard_state
)
subscribe(match_id: @match.id)
perform :request_sync
assert_equal({
"w1_stat" => "T3",
"w2_stat" => "E1",
"score" => "3-1",
"win_type" => "Decision",
"winner_name" => @match.wrestler1.name,
"winner_id" => @match.w1,
"finished" => 1,
"scoreboard_state" => scoreboard_state
}, transmissions.last)
end
test "request_sync transmits unfinished match data without scoreboard cache" do
@match.update!(
w1_stat: "T3",
w2_stat: "E1",
score: nil,
win_type: nil,
winner_id: nil,
finished: nil
)
subscribe(match_id: @match.id)
perform :request_sync
assert_equal({
"w1_stat" => "T3",
"w2_stat" => "E1"
}, transmissions.last)
end
end end

View File

@@ -216,4 +216,20 @@ class MatAssignmentRulesControllerTest < ActionController::TestCase
assert_equal ['A1', 'B2'], rule.bracket_positions assert_equal ['A1', 'B2'], rule.bracket_positions
assert_equal [1, 2], rule.rounds assert_equal [1, 2], rule.rounds
end end
test "index lists created mat assignment rule once in html" do
sign_in_owner
unique_mat = Mat.create!(name: "Unique Mat #{SecureRandom.hex(4)}", tournament_id: @tournament.id)
MatAssignmentRule.create!(
mat_id: unique_mat.id,
tournament_id: @tournament.id,
weight_classes: [1],
bracket_positions: ['1/2'],
rounds: [2]
)
index
assert_response :success
assert_equal 1, response.body.scan(unique_mat.name).size
end
end end

View File

@@ -1,9 +1,11 @@
require 'test_helper' require 'test_helper'
require "json"
class MatchesControllerTest < ActionController::TestCase class MatchesControllerTest < ActionController::TestCase
# Remove Devise helpers since we're no longer using Devise # Remove Devise helpers since we're no longer using Devise
# include Devise::Test::ControllerHelpers # Needed to sign in # include Devise::Test::ControllerHelpers # Needed to sign in
include ActionView::Helpers::DateHelper # Needed for time ago in words include ActionView::Helpers::DateHelper # Needed for time ago in words
include ActionCable::TestHelper
setup do setup do
@tournament = Tournament.find(1) @tournament = Tournament.find(1)
@@ -34,6 +36,18 @@ class MatchesControllerTest < ActionController::TestCase
get :stat, params: { id: @match.id } get :stat, params: { id: @match.id }
end end
def get_state
get :state, params: { id: @match.id }
end
def get_state_with_params(extra_params = {})
get :state, params: { id: @match.id }.merge(extra_params)
end
def get_spectate(extra_params = {})
get :spectate, params: { id: @match.id }.merge(extra_params)
end
def get_edit_assignment(extra_params = {}) def get_edit_assignment(extra_params = {})
get :edit_assignment, params: { id: @match.id }.merge(extra_params) get :edit_assignment, params: { id: @match.id }.merge(extra_params)
end end
@@ -106,11 +120,44 @@ class MatchesControllerTest < ActionController::TestCase
redirect redirect
end end
test "logged in user should not get state match page if not owner" do
sign_in_non_owner
get_state
redirect
end
test "logged school delegate should not get state match page if not owner" do
sign_in_school_delegate
get_state
redirect
end
test "non logged in user should not get stat match page" do test "non logged in user should not get stat match page" do
get_stat get_stat
redirect redirect
end end
test "non logged in user should not get state match page" do
get_state
redirect
end
test "valid school permission key cannot get state match page" do
school = @tournament.schools.first
school.update!(permission_key: "valid-school-key")
get_state_with_params(school_permission_key: "valid-school-key")
assert_redirected_to "/static_pages/not_allowed"
end
test "invalid school permission key cannot get state match page" do
school = @tournament.schools.first
school.update!(permission_key: "valid-school-key")
get_state_with_params(school_permission_key: "invalid-school-key")
assert_redirected_to "/static_pages/not_allowed"
end
test "non logged in user should get post update match" do test "non logged in user should get post update match" do
post_update post_update
assert_redirected_to '/static_pages/not_allowed' assert_redirected_to '/static_pages/not_allowed'
@@ -140,6 +187,202 @@ class MatchesControllerTest < ActionController::TestCase
success success
end end
test "logged in tournament owner should get state match page" do
sign_in_owner
get_state
success
end
test "logged in tournament delegate should get state match page" do
sign_in_tournament_delegate
get_state
success
end
test "logged in school delegate cannot get spectate match page when tournament is not public" do
@tournament.update!(is_public: false)
sign_in_school_delegate
get_spectate
redirect
end
test "logged in user cannot get spectate match page when tournament is not public" do
@tournament.update!(is_public: false)
sign_in_non_owner
get_spectate
redirect
end
test "logged in tournament delegate can get spectate match page when tournament is not public" do
@tournament.update!(is_public: false)
sign_in_tournament_delegate
get_spectate
success
end
test "logged in tournament owner can get spectate match page when tournament is not public" do
@tournament.update!(is_public: false)
sign_in_owner
get_spectate
success
end
test "non logged in user cannot get spectate match page when tournament is not public" do
@tournament.update!(is_public: false)
get_spectate
redirect
end
test "valid school permission key cannot get spectate match page when tournament is not public" do
@tournament.update!(is_public: false)
school = @tournament.schools.first
school.update!(permission_key: "valid-school-key")
get_spectate(school_permission_key: "valid-school-key")
assert_redirected_to "/static_pages/not_allowed"
end
test "invalid school permission key cannot get spectate match page when tournament is not public" do
@tournament.update!(is_public: false)
school = @tournament.schools.first
school.update!(permission_key: "valid-school-key")
get_spectate(school_permission_key: "invalid-school-key")
assert_redirected_to "/static_pages/not_allowed"
end
test "logged in school delegate can get spectate match page when tournament is public" do
@tournament.update!(is_public: true)
sign_in_school_delegate
get_spectate
success
end
test "logged in user can get spectate match page when tournament is public" do
@tournament.update!(is_public: true)
sign_in_non_owner
get_spectate
success
end
test "logged in tournament delegate can get spectate match page when tournament is public" do
@tournament.update!(is_public: true)
sign_in_tournament_delegate
get_spectate
success
end
test "logged in tournament owner can get spectate match page when tournament is public" do
@tournament.update!(is_public: true)
sign_in_owner
get_spectate
success
end
test "non logged in user can get spectate match page when tournament is public" do
@tournament.update!(is_public: true)
get_spectate
success
end
test "spectate renders embedded scoreboard when match is unfinished" do
@tournament.update!(is_public: true)
@match.update!(finished: nil)
get_spectate
assert_response :success
assert_includes response.body, "data-match-spectate-target=\"scoreboardContainer\""
assert_includes response.body, "data-controller=\"match-scoreboard\""
assert_includes response.body, "data-match-scoreboard-source-mode-value=\"websocket\""
assert_includes response.body, "data-match-scoreboard-display-mode-value=\"embedded\""
assert_includes response.body, "data-match-scoreboard-target=\"greenTimerIndicator\""
assert_includes response.body, "data-match-scoreboard-target=\"redTimerIndicator\""
end
test "spectate hides embedded scoreboard when match is finished" do
@tournament.update!(is_public: true)
@match.update!(finished: 1, winner_id: @match.w1, win_type: "Decision", score: "3-1")
get_spectate
assert_response :success
assert_not_includes response.body, "data-match-spectate-target=\"scoreboardContainer\""
end
test "posting a match update from match state redirects to all matches" do
sign_in_owner
get :state, params: { id: @match.id }
patch :update, params: { id: @match.id, match: { score: "3-1", win_type: "Decision", winner_id: @match.w1, finished: 1 } }
assert_redirected_to "/tournaments/#{@tournament.id}/matches"
end
test "state page renders hidden stat fields for generated stats submission" do
sign_in_owner
get_state
assert_response :success
assert_includes response.body, 'name="match[w1_stat]"'
assert_includes response.body, 'name="match[w2_stat]"'
assert_includes response.body, 'data-match-state-target="w1StatField"'
assert_includes response.body, 'data-match-state-target="w2StatField"'
end
test "posting a match update from match state respects redirect_to param" do
sign_in_owner
get :state, params: { id: @match.id, redirect_to: "/mats/#{@match.mat_id}" }
patch :update, params: {
id: @match.id,
redirect_to: "/mats/#{@match.mat_id}",
match: { score: "3-1", win_type: "Decision", winner_id: @match.w1, finished: 1 }
}
assert_redirected_to "/mats/#{@match.mat_id}"
end
test "posting a match update broadcasts match data and cached scoreboard state" do
sign_in_owner
scoreboard_state = {
"participantScores" => { "w1" => 3, "w2" => 1 },
"metadata" => { "boutNumber" => @match.bout_number }
}
stream = MatchChannel.broadcasting_for(@match)
ActionCable.server.pubsub.clear
ActionCable.server.pubsub.broadcasts(stream).clear
Rails.cache.write(
"tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state",
scoreboard_state
)
patch :update, params: {
id: @match.id,
match: {
w1_stat: "T3",
w2_stat: "E1",
score: "3-1",
win_type: "Decision",
winner_id: @match.w1,
finished: 1
}
}
payload = JSON.parse(ActionCable.server.pubsub.broadcasts(stream).last)
assert_equal(
{
"w1_stat" => "T3",
"w2_stat" => "E1",
"score" => "3-1",
"win_type" => "Decision",
"winner_id" => @match.w1,
"winner_name" => @match.wrestler1.name,
"finished" => 1,
"scoreboard_state" => scoreboard_state
},
payload
)
end
test "logged in tournament delegate should post update match" do test "logged in tournament delegate should post update match" do
sign_in_tournament_delegate sign_in_tournament_delegate
post_update post_update

View File

@@ -31,6 +31,30 @@ class MatsControllerTest < ActionController::TestCase
get :show, params: { id: 1 } get :show, params: { id: 1 }
end end
def get_state
get :state, params: { id: @mat.id }
end
def get_state_with_params(extra_params = {})
get :state, params: { id: @mat.id }.merge(extra_params)
end
def get_scoreboard
get :scoreboard, params: { id: @mat.id }
end
def get_scoreboard_with_params(extra_params = {})
get :scoreboard, params: { id: @mat.id }.merge(extra_params)
end
def post_select_match(extra_params = {})
post :select_match, params: { id: @mat.id, match_id: @match.id, bout_number: @match.bout_number }.merge(extra_params)
end
def post_select_match_with_params(extra_params = {})
post :select_match, params: { id: @mat.id }.merge(extra_params)
end
def post_update def post_update
patch :update, params: { id: @mat.id, mat: {name: @mat.name, tournament_id: @mat.tournament_id} } patch :update, params: { id: @mat.id, mat: {name: @mat.name, tournament_id: @mat.tournament_id} }
end end
@@ -212,24 +236,253 @@ class MatsControllerTest < ActionController::TestCase
redirect redirect
end end
test "logged in user should not get state mat page" do
sign_in_non_owner
get_state
redirect
end
test "logged in user should not get scoreboard mat page" do
sign_in_non_owner
get_scoreboard
redirect
end
test "logged school delegate should not get show mat" do test "logged school delegate should not get show mat" do
sign_in_school_delegate sign_in_school_delegate
show show
redirect redirect
end end
test "logged school delegate should not get state mat page" do
sign_in_school_delegate
get_state
redirect
end
test "logged school delegate should not get scoreboard mat page" do
sign_in_school_delegate
get_scoreboard
redirect
end
test "non logged in user should not get state mat page" do
get_state
redirect
end
test "non logged in user should not get scoreboard mat page" do
get_scoreboard
redirect
end
test "valid school permission key cannot get state mat page" do
school = @tournament.schools.first
school.update!(permission_key: "valid-school-key")
get_state_with_params(school_permission_key: "valid-school-key")
assert_redirected_to "/static_pages/not_allowed"
end
test "invalid school permission key cannot get state mat page" do
school = @tournament.schools.first
school.update!(permission_key: "valid-school-key")
get_state_with_params(school_permission_key: "invalid-school-key")
assert_redirected_to "/static_pages/not_allowed"
end
test "valid school permission key cannot get scoreboard mat page" do
school = @tournament.schools.first
school.update!(permission_key: "valid-school-key")
get_scoreboard_with_params(school_permission_key: "valid-school-key")
assert_redirected_to "/static_pages/not_allowed"
end
test "invalid school permission key cannot get scoreboard mat page" do
school = @tournament.schools.first
school.update!(permission_key: "valid-school-key")
get_scoreboard_with_params(school_permission_key: "invalid-school-key")
assert_redirected_to "/static_pages/not_allowed"
end
test "logged in user should not post select_match on mat" do
sign_in_non_owner
post_select_match
redirect
end
test "logged school delegate should not post select_match on mat" do
sign_in_school_delegate
post_select_match
redirect
end
test "non logged in user should not post select_match on mat" do
post_select_match
redirect
end
test "valid school permission key cannot post select_match on mat" do
school = @tournament.schools.first
school.update!(permission_key: "valid-school-key")
post_select_match_with_params(school_permission_key: "valid-school-key")
assert_redirected_to "/static_pages/not_allowed"
end
test "invalid school permission key cannot post select_match on mat" do
school = @tournament.schools.first
school.update!(permission_key: "valid-school-key")
post_select_match_with_params(school_permission_key: "invalid-school-key")
assert_redirected_to "/static_pages/not_allowed"
end
test "logged in tournament owner should get show mat" do test "logged in tournament owner should get show mat" do
sign_in_owner sign_in_owner
show show
success success
end end
test "logged in tournament owner should get state mat page" do
sign_in_owner
get_state
success
end
test "logged in tournament owner should get scoreboard mat page" do
sign_in_owner
get_scoreboard
success
end
test "logged in tournament owner can post select_match on mat" do
sign_in_owner
post_select_match
assert_response :no_content
end
test "logged in tournament delegate should get show mat" do test "logged in tournament delegate should get show mat" do
sign_in_tournament_delegate sign_in_tournament_delegate
show show
success success
end end
test "logged in tournament delegate should get state mat page" do
sign_in_tournament_delegate
get_state
success
end
test "logged in tournament delegate should get scoreboard mat page" do
sign_in_tournament_delegate
get_scoreboard
success
end
test "state mat page renders queue buttons and mat-state controller" do
sign_in_owner
get_state
assert_response :success
assert_includes response.body, "data-controller=\"mat-state\""
assert_includes response.body, "Queue 1:"
assert_includes response.body, "Queue 2:"
assert_includes response.body, "Queue 3:"
assert_includes response.body, "Queue 4:"
end
test "scoreboard mat page renders match-scoreboard controller" do
sign_in_owner
get_scoreboard_with_params(print: true)
assert_response :success
assert_includes response.body, "data-controller=\"match-scoreboard\""
assert_includes response.body, "data-match-scoreboard-source-mode-value=\"localstorage\""
end
test "scoreboard mat page uses selected scoreboard match as initial bout" do
sign_in_owner
alternate_match = @mat.queue2_match
if alternate_match.nil?
alternate_match = @tournament.matches.where(mat_id: nil).first
@mat.assign_match_to_queue!(alternate_match, 2)
alternate_match = @mat.reload.queue2_match
end
@mat.set_selected_scoreboard_match!(alternate_match)
get_scoreboard
assert_response :success
assert_includes response.body, "data-match-scoreboard-initial-bout-number-value=\"#{alternate_match.bout_number}\""
end
test "state mat page renders no matches assigned when queue is empty" do
sign_in_owner
@mat.clear_queue!
get_state
assert_response :success
assert_includes response.body, "No matches assigned to this mat."
end
test "posting a match update from mat state redirects back to mat state" do
sign_in_owner
get :state, params: { id: @mat.id, bout_number: @match.bout_number }
old_controller = @controller
@controller = MatchesController.new
patch :update, params: { id: @match.id, match: { score: "3-1", win_type: "Decision", winner_id: @match.w1, finished: 1 } }
@controller = old_controller
assert_redirected_to "/mats/#{@mat.id}/state"
end
test "logged in tournament delegate can post select_match on mat" do
sign_in_tournament_delegate
post_select_match
assert_response :no_content
end
test "select_match updates selected scoreboard match" do
sign_in_owner
alternate_match = @mat.queue2_match
if alternate_match.nil?
alternate_match = @tournament.matches.where(mat_id: nil).first
@mat.assign_match_to_queue!(alternate_match, 2)
alternate_match = @mat.reload.queue2_match
end
post :select_match, params: { id: @mat.id, match_id: alternate_match.id, bout_number: alternate_match.bout_number }
assert_response :no_content
assert_equal alternate_match.id, @mat.selected_scoreboard_match&.id
end
test "select_match updates last match result without changing selected match" do
sign_in_owner
@mat.set_selected_scoreboard_match!(@match)
post :select_match, params: { id: @mat.id, last_match_result: "106 lbs - Winner Decision Loser 3-1" }
assert_response :no_content
assert_equal @match.id, @mat.selected_scoreboard_match&.id
assert_equal "106 lbs - Winner Decision Loser 3-1", @mat.last_match_result_text
end
test "select_match returns unprocessable entity for a non queued match without last result" do
sign_in_owner
non_queued_match = @tournament.matches.where(mat_id: nil).first
post :select_match, params: { id: @mat.id, match_id: non_queued_match.id, bout_number: non_queued_match.bout_number }
assert_response :unprocessable_entity
end
test "ads are hidden on mat show" do test "ads are hidden on mat show" do
sign_in_owner sign_in_owner
show show
@@ -259,6 +512,62 @@ class MatsControllerTest < ActionController::TestCase
assert_match /#{bout_number}/, response.body, "The bout_number should be rendered on the page" assert_match /#{bout_number}/, response.body, "The bout_number should be rendered on the page"
end end
test "mat show renders queue buttons for all four queue slots" do
sign_in_owner
available_matches = @tournament.matches.where(mat_id: nil).limit(3).to_a
@mat.assign_match_to_queue!(available_matches[0], 2) if available_matches[0]
@mat.assign_match_to_queue!(available_matches[1], 3) if available_matches[1]
@mat.assign_match_to_queue!(available_matches[2], 4) if available_matches[2]
get :show, params: { id: @mat.id }
assert_response :success
assert_includes response.body, "Queue 1: Bout"
assert_includes response.body, "Queue 2:"
assert_includes response.body, "Queue 3:"
assert_includes response.body, "Queue 4:"
end
test "mat show highlights selected queue button and keeps bout_number links working" do
sign_in_owner
queue2_match = @mat.queue2_match
unless queue2_match
assignable = @tournament.matches.where(mat_id: nil).first
@mat.assign_match_to_queue!(assignable, 2) if assignable
queue2_match = @mat.reload.queue2_match
end
assert queue2_match, "Expected queue2 match to be present"
get :show, params: { id: @mat.id, bout_number: queue2_match.bout_number, foo: "bar" }
assert_response :success
assert_includes response.body, "Queue 2: Bout #{queue2_match.bout_number}"
assert_match(/btn btn-success btn-sm/, response.body)
assert_includes response.body, "bout_number=#{queue2_match.bout_number}"
end
test "mat show falls back to queue1 when requested bout number is not currently queued" do
sign_in_owner
queue1 = @mat.reload.queue1_match
assert queue1, "Expected queue1 to be present"
get :show, params: { id: @mat.id, bout_number: 999999 }
assert_response :success
assert_includes response.body, "Bout <strong>#{queue1.bout_number}</strong>"
end
test "mat show renders no matches assigned when queue is empty" do
sign_in_owner
@mat.clear_queue!
get :show, params: { id: @mat.id }
assert_response :success
assert_includes response.body, "No matches assigned to this mat."
end
test "logged in tournament owner should redirect back to the first unfinished bout on a mat after submitting a match with a bout number param" do test "logged in tournament owner should redirect back to the first unfinished bout on a mat after submitting a match with a bout number param" do
sign_in_owner sign_in_owner
@@ -287,11 +596,12 @@ class MatsControllerTest < ActionController::TestCase
end end
#TESTS THAT NEED MATCHES PUT ABOVE THIS #TESTS THAT NEED MATCHES PUT ABOVE THIS
test "redirect show if no matches" do test "show renders when no matches" do
sign_in_owner sign_in_owner
wipe wipe
show show
no_matches success
assert_includes response.body, "No matches assigned to this mat."
end end
# Assign Next Match Permissions # Assign Next Match Permissions

View File

@@ -0,0 +1,143 @@
require "test_helper"
class SchoolShowCacheTest < ActionController::TestCase
tests SchoolsController
setup do
create_double_elim_tournament_single_weight_1_6(8)
@tournament.update!(user_id: users(:one).id)
@school = @tournament.schools.first
sign_in users(:one)
@original_perform_caching = ActionController::Base.perform_caching
ActionController::Base.perform_caching = true
Rails.cache.clear
end
teardown do
Rails.cache.clear
ActionController::Base.perform_caching = @original_perform_caching
end
test "school show wrestler cell fragments hit cache and invalidate after wrestler update" do
first_events = cache_events_for_school_show do
get :show, params: { id: @school.id }
assert_response :success
end
assert_operator cache_writes(first_events), :>, 0, "Expected initial school show render to write wrestler cell fragments"
second_events = cache_events_for_school_show do
get :show, params: { id: @school.id }
assert_response :success
end
assert_equal 0, cache_writes(second_events), "Expected repeat school show render to reuse wrestler cell fragments"
assert_operator cache_hits(second_events), :>, 0, "Expected repeat school show render to hit wrestler cell cache"
wrestler = @school.wrestlers.first
third_events = cache_events_for_school_show do
wrestler.touch
get :show, params: { id: @school.id }
assert_response :success
end
assert_operator cache_writes(third_events), :>, 0, "Expected wrestler update to invalidate school show wrestler cell cache"
end
test "school show does not leak manage-only controls from cache across users" do
get :show, params: { id: @school.id }
assert_response :success
assert_includes response.body, "New Wrestler"
assert_match(/fa-trash-alt/, response.body)
assert_match(/fa-edit/, response.body)
sign_out
spectator_events = cache_events_for_school_show do
get :show, params: { id: @school.id }
assert_response :success
end
assert_operator cache_hits(spectator_events), :>, 0, "Expected spectator request to hit wrestler cell cache warmed by owner"
assert_not_includes response.body, "New Wrestler"
assert_no_match(/fa-trash-alt/, response.body)
assert_no_match(/fa-edit/, response.body)
end
test "school show with school_permission_key bypasses cached wrestler cell fragments" do
@school.update!(permission_key: SecureRandom.uuid)
sign_out
key_request_events = cache_events_for_school_show do
get :show, params: { id: @school.id, school_permission_key: @school.permission_key }
assert_response :success
end
assert_equal 0, cache_writes(key_request_events), "Expected school_permission_key request to bypass cached wrestler cells"
assert_equal 0, cache_hits(key_request_events), "Expected school_permission_key request to avoid reading cached wrestler cells"
end
test "completing a match expires school show wrestler cell caches" do
warm_events = cache_events_for_school_show do
get :show, params: { id: @school.id }
assert_response :success
end
assert_operator cache_writes(warm_events), :>, 0, "Expected initial school show render to warm wrestler cell cache"
wrestler = @school.wrestlers.first
assert wrestler, "Expected a wrestler for match-completion cache test"
match = wrestler.unfinished_matches.first || wrestler.all_matches.first
assert match, "Expected a match involving school wrestler"
winner_id = match.w1 || match.w2
assert winner_id, "Expected match to have at least one wrestler slot"
match.update!(
finished: 1,
winner_id: winner_id,
win_type: "Decision",
score: "1-0"
)
post_action_events = cache_events_for_school_show do
get :show, params: { id: @school.id }
assert_response :success
end
assert_operator cache_writes(post_action_events), :>, 0, "Expected completed match to expire school show wrestler cell cache"
end
private
def sign_out
@request.session[:user_id] = nil
@controller.instance_variable_set(:@current_user, nil)
@controller.instance_variable_set(:@current_ability, nil)
end
def cache_events_for_school_show
events = []
subscriber = lambda do |name, _start, _finish, _id, payload|
key = payload[:key].to_s
next unless key.include?("school_show_wrestler_cells")
events << { name: name, hit: payload[:hit] }
end
ActiveSupport::Notifications.subscribed(
subscriber,
/cache_(read|write|fetch_hit|generate)\.active_support/
) do
yield
end
events
end
def cache_writes(events)
events.count { |event| event[:name] == "cache_write.active_support" }
end
def cache_hits(events)
events.count do |event|
event[:name] == "cache_fetch_hit.active_support" ||
(event[:name] == "cache_read.active_support" && event[:hit])
end
end
end

View File

@@ -36,4 +36,41 @@ class StaticPagesControllerTest < ActionController::TestCase
get :my_tournaments get :my_tournaments
success success
end end
test "my_tournaments page lists delegated tournament and delegated school once in html" do
user = users(:two)
sign_in_non_owner
delegated_tournament = Tournament.create!(
name: "Delegated Tournament #{SecureRandom.hex(4)}",
address: "123 Delegate St",
director: "Director",
director_email: "delegate_tournament_#{SecureRandom.hex(4)}@example.com",
tournament_type: "Pool to bracket",
date: Date.today,
is_public: true
)
TournamentDelegate.create!(tournament_id: delegated_tournament.id, user_id: user.id)
school_tournament = Tournament.create!(
name: "School Tournament #{SecureRandom.hex(4)}",
address: "456 School St",
director: "Director",
director_email: "delegate_school_#{SecureRandom.hex(4)}@example.com",
tournament_type: "Pool to bracket",
date: Date.today + 1,
is_public: true
)
delegated_school = School.create!(
name: "Delegated School #{SecureRandom.hex(4)}",
tournament_id: school_tournament.id
)
SchoolDelegate.create!(school_id: delegated_school.id, user_id: user.id)
get :my_tournaments
assert_response :success
assert_equal 1, response.body.scan(delegated_tournament.name).size
assert_equal 1, response.body.scan(delegated_school.name).size
assert_equal 1, response.body.scan(school_tournament.name).size
end
end end

View File

@@ -0,0 +1,164 @@
require "test_helper"
class TournamentPagesCacheTest < ActionController::TestCase
tests TournamentsController
setup do
create_double_elim_tournament_single_weight_1_6(8)
@tournament.update!(user_id: users(:one).id)
@weight = @tournament.weights.first
sign_in users(:one)
@original_perform_caching = ActionController::Base.perform_caching
ActionController::Base.perform_caching = true
Rails.cache.clear
end
teardown do
Rails.cache.clear
ActionController::Base.perform_caching = @original_perform_caching
end
test "team_scores cache hits on repeat render and rewrites after school update" do
first_events = cache_events_for(%w[team_scores team_score_row]) do
get :team_scores, params: { id: @tournament.id }
assert_response :success
end
assert_operator cache_writes(first_events), :>, 0, "Expected initial team_scores render to write fragments"
second_events = cache_events_for(%w[team_scores team_score_row]) do
get :team_scores, params: { id: @tournament.id }
assert_response :success
end
assert_equal 0, cache_writes(second_events), "Expected repeat team_scores render to reuse fragments"
assert_operator cache_hits(second_events), :>, 0, "Expected repeat team_scores render to hit cache"
school = @tournament.schools.first
third_events = cache_events_for(%w[team_scores team_score_row]) do
school.update!(score: (school.score || 0) + 1)
get :team_scores, params: { id: @tournament.id }
assert_response :success
end
assert_operator cache_writes(third_events), :>, 0, "Expected school score update to invalidate team_scores cache"
end
test "bracket cache hits on repeat render and rewrites after match update" do
key_markers = [@weight.id.to_s + "_bracket", "bracket_round_match", "bracket_final_match"]
first_events = cache_events_for(key_markers) do
get :bracket, params: { id: @tournament.id, weight: @weight.id }
assert_response :success
end
assert_operator cache_writes(first_events), :>, 0, "Expected initial bracket render to write fragments"
second_events = cache_events_for(key_markers) do
get :bracket, params: { id: @tournament.id, weight: @weight.id }
assert_response :success
end
assert_equal 0, cache_writes(second_events), "Expected repeat bracket render to reuse fragments"
assert_operator cache_hits(second_events), :>, 0, "Expected repeat bracket render to hit cache"
match = @weight.matches.first
third_events = cache_events_for(key_markers) do
match.touch
get :bracket, params: { id: @tournament.id, weight: @weight.id }
assert_response :success
end
assert_operator cache_writes(third_events), :>, 0, "Expected match update to invalidate bracket cache"
end
test "bracket cache separates print and non-print variants" do
key_markers = [@weight.id.to_s + "_bracket"]
non_print_events = cache_events_for(key_markers) do
get :bracket, params: { id: @tournament.id, weight: @weight.id }
assert_response :success
end
assert_operator cache_writes(non_print_events), :>, 0, "Expected non-print bracket render to write a page fragment"
assert_match(%r{\/matches\/\d+\/spectate}, response.body, "Expected non-print bracket view to include spectate links")
first_print_events = cache_events_for(key_markers) do
get :bracket, params: { id: @tournament.id, weight: @weight.id, print: true }
assert_response :success
end
assert_operator cache_writes(first_print_events), :>, 0, "Expected first print bracket render to write a separate page fragment"
assert_no_match(%r{\/matches\/\d+\/spectate}, response.body, "Expected print bracket view to omit spectate links")
second_print_events = cache_events_for(key_markers) do
get :bracket, params: { id: @tournament.id, weight: @weight.id, print: true }
assert_response :success
end
assert_equal 0, cache_writes(second_print_events), "Expected repeat print bracket render to reuse print cache fragment"
assert_operator cache_hits(second_print_events), :>, 0, "Expected repeat print bracket render to hit cache"
end
test "completing a match expires team_scores and bracket caches" do
team_warm_events = cache_events_for(%w[team_scores team_score_row]) do
get :team_scores, params: { id: @tournament.id }
assert_response :success
end
assert_operator cache_writes(team_warm_events), :>, 0, "Expected initial team_scores render to warm cache"
bracket_key_markers = [@weight.id.to_s + "_bracket", "bracket_round_match", "bracket_final_match"]
bracket_warm_events = cache_events_for(bracket_key_markers) do
get :bracket, params: { id: @tournament.id, weight: @weight.id }
assert_response :success
end
assert_operator cache_writes(bracket_warm_events), :>, 0, "Expected initial bracket render to warm cache"
match = @weight.matches.where(finished: [nil, 0]).first || @weight.matches.first
assert match, "Expected a match to complete for expiration test"
match.update!(
finished: 1,
winner_id: match.w1 || match.w2,
win_type: "Decision",
score: "1-0"
)
team_post_events = cache_events_for(%w[team_scores team_score_row]) do
get :team_scores, params: { id: @tournament.id }
assert_response :success
end
assert_operator cache_writes(team_post_events), :>, 0, "Expected completed match to expire team_scores cache"
bracket_post_events = cache_events_for(bracket_key_markers) do
get :bracket, params: { id: @tournament.id, weight: @weight.id }
assert_response :success
end
assert_operator cache_writes(bracket_post_events), :>, 0, "Expected completed match to expire bracket cache"
end
private
def cache_events_for(key_markers)
events = []
subscriber = lambda do |name, _start, _finish, _id, payload|
key = payload[:key].to_s
next unless key_markers.any? { |marker| key.include?(marker) }
events << { name: name, hit: payload[:hit] }
end
ActiveSupport::Notifications.subscribed(
subscriber,
/cache_(read|write|fetch_hit|generate)\.active_support/
) do
yield
end
events
end
def cache_writes(events)
events.count { |event| event[:name] == "cache_write.active_support" }
end
def cache_hits(events)
events.count do |event|
event[:name] == "cache_fetch_hit.active_support" ||
(event[:name] == "cache_read.active_support" && event[:hit])
end
end
end

View File

@@ -28,6 +28,10 @@ class TournamentsControllerTest < ActionController::TestCase
get :up_matches, params: { id: 1 } get :up_matches, params: { id: 1 }
end end
def get_live_scores
get :live_scores, params: { id: 1 }
end
def get_qrcode(params = {}) def get_qrcode(params = {})
get :qrcode, params: { id: 1 }.merge(params) get :qrcode, params: { id: 1 }.merge(params)
end end
@@ -117,6 +121,29 @@ class TournamentsControllerTest < ActionController::TestCase
sign_in_owner sign_in_owner
get :weigh_in, params: { id: 1 } get :weigh_in, params: { id: 1 }
success success
assert_not_includes response.body, "Weights were successfully recorded."
end
test "printable weigh in sheet includes wrestler name school weight class and actual weight" do
sign_in_owner
@tournament.update!(weigh_in_ref: "Ref Smith")
wrestler = @tournament.weights.first.wrestlers.first
wrestler.update!(
name: "Printable Test Wrestler",
offical_weight: 106.4
)
school = wrestler.school
get :weigh_in_sheet, params: { id: @tournament.id, print: true }
assert_response :success
assert_includes response.body, school.name
assert_includes response.body, "Printable Test Wrestler"
assert_includes response.body, wrestler.weight.max.to_s
assert_includes response.body, "106.4"
assert_includes response.body, "Actual Weight"
assert_includes response.body, "Weigh In Ref:"
assert_includes response.body, "Ref Smith"
end end
test "logged in non tournament owner cannot access weigh_ins" do test "logged in non tournament owner cannot access weigh_ins" do
@@ -155,6 +182,27 @@ class TournamentsControllerTest < ActionController::TestCase
success success
end end
test "logged in tournament owner can save wrestler actual weight on weigh in weight page" do
sign_in_owner
wrestler = @tournament.weights.first.wrestlers.first
post :weigh_in_weight, params: {
id: @tournament.id,
weight: wrestler.weight_id,
wrestler: {
wrestler.id.to_s => { offical_weight: "108.2" }
}
}
assert_redirected_to "/tournaments/#{@tournament.id}/weigh_in/#{wrestler.weight_id}"
assert_equal "Weights were successfully recorded.", flash[:notice]
assert_equal 108.2, wrestler.reload.offical_weight.to_f
get :weigh_in_weight, params: { id: @tournament.id, weight: wrestler.weight_id }
assert_response :success
assert_equal 1, response.body.scan("Weights were successfully recorded.").size
end
test "logged in non tournament owner cannot access post weigh_in_weight" do test "logged in non tournament owner cannot access post weigh_in_weight" do
sign_in_non_owner sign_in_non_owner
post :weigh_in_weight, params: { id: 1, weight: 1, wrestler: @wrestlers } post :weigh_in_weight, params: { id: 1, weight: 1, wrestler: @wrestlers }
@@ -515,8 +563,148 @@ class TournamentsControllerTest < ActionController::TestCase
get_up_matches get_up_matches
success success
end end
test "up matches uses turbo stream updates instead of timer refresh script" do
@tournament.is_public = true
@tournament.save
get_up_matches
success
assert_includes response.body, "turbo-cable-stream-source"
assert_includes response.body, "data-controller=\"up-matches-connection\""
assert_includes response.body, "up-matches-cable-status-indicator"
assert_not_includes response.body, "This page reloads every 30s"
end
test "up matches shows full screen button when print param is not true" do
@tournament.is_public = true
@tournament.save
get :up_matches, params: { id: @tournament.id }
assert_response :success
assert_includes response.body, "Show Bout Board in Full Screen"
assert_includes response.body, "print=true"
end
test "up matches hides full screen button when print param is true" do
@tournament.is_public = true
@tournament.save
get :up_matches, params: { id: @tournament.id, print: "true" }
assert_response :success
assert_not_includes response.body, "Show Bout Board in Full Screen"
end
# END UP MATCHES PAGE PERMISSIONS # END UP MATCHES PAGE PERMISSIONS
# LIVE SCORES PAGE PERMISSIONS WHEN TOURNAMENT IS NOT PUBLIC
test "logged in school delegate cannot get live scores page when tournament is not public" do
@tournament.is_public = false
@tournament.save
sign_in_school_delegate
get_live_scores
redirect
end
test "logged in user cannot get live scores page when tournament is not public" do
@tournament.is_public = false
@tournament.save
sign_in_non_owner
get_live_scores
redirect
end
test "logged in tournament delegate can get live scores page when tournament is not public" do
@tournament.is_public = false
@tournament.save
sign_in_delegate
get_live_scores
success
end
test "logged in tournament owner can get live scores page when tournament is not public" do
@tournament.is_public = false
@tournament.save
sign_in_owner
get_live_scores
success
end
test "non logged in user cannot get live scores page when tournament is not public" do
@tournament.is_public = false
@tournament.save
get_live_scores
redirect
end
test "non logged in user with valid school permission key cannot get live scores page when tournament is not public" do
@tournament.is_public = false
@tournament.save
@school.update(permission_key: "valid-key")
get :live_scores, params: { id: @tournament.id, school_permission_key: "valid-key" }
redirect
end
test "non logged in user with invalid school permission key cannot get live scores page when tournament is not public" do
@tournament.is_public = false
@tournament.save
@school.update(permission_key: "valid-key")
get :live_scores, params: { id: @tournament.id, school_permission_key: "invalid-key" }
redirect
end
# LIVE SCORES PAGE PERMISSIONS WHEN TOURNAMENT IS PUBLIC
test "logged in school delegate can get live scores page when tournament is public" do
@tournament.is_public = true
@tournament.save
sign_in_school_delegate
get_live_scores
success
end
test "logged in user can get live scores page when tournament is public" do
@tournament.is_public = true
@tournament.save
sign_in_non_owner
get_live_scores
success
end
test "logged in tournament delegate can get live scores page when tournament is public" do
@tournament.is_public = true
@tournament.save
sign_in_delegate
get_live_scores
success
end
test "logged in tournament owner can get live scores page when tournament is public" do
@tournament.is_public = true
@tournament.save
sign_in_owner
get_live_scores
success
end
test "non logged in user can get live scores page when tournament is public" do
@tournament.is_public = true
@tournament.save
get_live_scores
success
end
test "live scores page renders mat cards and scoreboard controller" do
@tournament.is_public = true
@tournament.save
get_live_scores
success
assert_includes response.body, "Live Scores"
assert_includes response.body, "data-controller=\"match-scoreboard\""
assert_includes response.body, "data-match-scoreboard-source-mode-value=\"mat_websocket\""
assert_includes response.body, "data-match-scoreboard-display-mode-value=\"embedded\""
assert_includes response.body, "Last Match Result"
assert_includes response.body, "Stats"
assert_not_includes response.body, "Result</h4>"
end
# ALL_RESULTS PAGE PERMISSIONS WHEN TOURNAMENT IS NOT PUBLIC # ALL_RESULTS PAGE PERMISSIONS WHEN TOURNAMENT IS NOT PUBLIC
test "logged in school delegate cannot get all_results page when tournament is not public" do test "logged in school delegate cannot get all_results page when tournament is not public" do
@tournament.is_public = false @tournament.is_public = false
@@ -599,11 +787,11 @@ class TournamentsControllerTest < ActionController::TestCase
# END ALL_RESULTS PAGE PERMISSIONS # END ALL_RESULTS PAGE PERMISSIONS
#TESTS THAT NEED MATCHES PUT ABOVE THIS #TESTS THAT NEED MATCHES PUT ABOVE THIS
test "redirect up_matches if no matches" do test "up_matches renders when no matches exist" do
sign_in_owner sign_in_owner
wipe wipe
get :up_matches, params: { id: 1 } get :up_matches, params: { id: 1 }
no_matches success
end end
test "redirect bracket if no matches" do test "redirect bracket if no matches" do
@@ -686,6 +874,33 @@ class TournamentsControllerTest < ActionController::TestCase
success success
end end
test "delegate page renders created tournament delegate in html" do
user = User.create!(
email: "tournament_delegate_render_#{SecureRandom.hex(4)}@example.com",
password: "password"
)
TournamentDelegate.create!(tournament_id: @tournament.id, user_id: user.id)
sign_in_owner
get :delegate, params: { id: @tournament.id }
assert_response :success
assert_includes response.body, user.email
end
test "school_delegate page renders created school delegate in html" do
user = User.create!(
email: "school_delegate_render_#{SecureRandom.hex(4)}@example.com",
password: "password"
)
SchoolDelegate.create!(school_id: @school.id, user_id: user.id)
sign_in_owner
get :school_delegate, params: { id: @tournament.id }
assert_response :success
assert_includes response.body, user.email
assert_includes response.body, @school.name
end
test 'logged in tournament owner can delete a school delegate' do test 'logged in tournament owner can delete a school delegate' do
sign_in_owner sign_in_owner
patch :remove_school_delegate, params: { id: 1, delegate: SchoolDelegate.find(1) } patch :remove_school_delegate, params: { id: 1, delegate: SchoolDelegate.find(1) }
@@ -722,6 +937,16 @@ class TournamentsControllerTest < ActionController::TestCase
success success
end end
test "teampointadjust page lists created point deduction once in html" do
sign_in_owner
school = School.create!(name: "Point Deduction School #{SecureRandom.hex(3)}", tournament_id: @tournament.id)
adjustment = Teampointadjust.create!(school_id: school.id, points: 9876.5)
get :teampointadjust, params: { id: @tournament.id }
assert_response :success
assert_equal 1, response.body.scan(adjustment.points.to_s).size
end
test 'logged in tournament delegate cannot adjust team points' do test 'logged in tournament delegate cannot adjust team points' do
sign_in_school_delegate sign_in_school_delegate
get :teampointadjust, params: { id: 1 } get :teampointadjust, params: { id: 1 }
@@ -758,6 +983,83 @@ class TournamentsControllerTest < ActionController::TestCase
success success
end end
test "matches page search finds by wrestler name, school name, and bout number" do
sign_in_owner
search_school = School.create!(name: "Search Prep Academy", tournament_id: @tournament.id)
search_wrestler = Wrestler.create!(
name: "Alpha Searchman",
school_id: search_school.id,
weight_id: @tournament.weights.first.id,
original_seed: 99,
bracket_line: 99,
season_loss: 0,
season_win: 0,
pool: 1
)
match = Match.create!(
tournament_id: @tournament.id,
weight_id: @tournament.weights.first.id,
bout_number: 888999,
w1: search_wrestler.id,
w2: @wrestlers.first.id,
bracket_position: "Pool",
round: 1
)
get :matches, params: { id: @tournament.id, search: "Searchman" }
assert_response :success
assert_includes response.body, match.bout_number.to_s
get :matches, params: { id: @tournament.id, search: "Search Prep" }
assert_response :success
assert_includes response.body, match.bout_number.to_s
get :matches, params: { id: @tournament.id, search: "888999" }
assert_response :success
assert_includes response.body, match.bout_number.to_s
end
test "matches page paginates filtered results" do
sign_in_owner
paging_school = School.create!(name: "Pager Academy", tournament_id: @tournament.id)
paging_wrestler = Wrestler.create!(
name: "Pager Wrestler",
school_id: paging_school.id,
weight_id: @tournament.weights.first.id,
original_seed: 100,
bracket_line: 100,
season_loss: 0,
season_win: 0,
pool: 1
)
55.times do |i|
Match.create!(
tournament_id: @tournament.id,
weight_id: @tournament.weights.first.id,
bout_number: 910000 + i,
w1: paging_wrestler.id,
w2: @wrestlers.first.id,
bracket_position: "Pool",
round: 1
)
end
get :matches, params: { id: @tournament.id, search: "Pager Academy" }
assert_response :success
assert_includes response.body, "Showing 1 - 50 of 55 matches"
assert_includes response.body, "910000"
assert_not_includes response.body, "910054"
get :matches, params: { id: @tournament.id, search: "Pager Academy", page: 2 }
assert_response :success
assert_includes response.body, "Showing 51 - 55 of 55 matches"
assert_includes response.body, "910054"
assert_not_includes response.body, "910000"
end
test "logged in tournament owner can calculate team scores" do test "logged in tournament owner can calculate team scores" do
sign_in_owner sign_in_owner
post :calculate_team_scores, params: { id: 1 } post :calculate_team_scores, params: { id: 1 }
@@ -950,6 +1252,25 @@ class TournamentsControllerTest < ActionController::TestCase
assert_equal "School permission keys generated successfully.", flash[:notice] assert_equal "School permission keys generated successfully.", flash[:notice]
end end
test "generated school permission keys are displayed on school delegate page" do
sign_in_owner
post :generate_school_keys, params: { id: @tournament.id }
assert_redirected_to school_delegate_path(@tournament)
@tournament.schools.reload.each do |school|
assert_not_nil school.permission_key, "Expected permission key for school #{school.id}"
assert_not_empty school.permission_key, "Expected non-empty permission key for school #{school.id}"
end
get :school_delegate, params: { id: @tournament.id }
assert_response :success
@tournament.schools.each do |school|
expected_link_fragment = "/schools/#{school.id}?school_permission_key=#{school.permission_key}"
assert_includes response.body, expected_link_fragment
end
end
test "tournament delegate can delete school keys" do test "tournament delegate can delete school keys" do
sign_in_delegate sign_in_delegate
post :delete_school_keys, params: { id: @tournament.id } post :delete_school_keys, params: { id: @tournament.id }
@@ -1180,4 +1501,52 @@ class TournamentsControllerTest < ActionController::TestCase
expected_page2_display = [expected_page2_size, 20].min expected_page2_display = [expected_page2_size, 20].min
assert_equal expected_page2_display, assigns(:tournaments).size, "second page should contain the remaining tournaments (or up to per_page)" assert_equal expected_page2_display, assigns(:tournaments).size, "second page should contain the remaining tournaments (or up to per_page)"
end end
test "bout_sheets renders wrestler names, school names, and round for selected round" do
tournament = create_double_elim_tournament_single_weight(8, "Regular Double Elimination 1-6")
tournament.update!(user_id: users(:one).id, is_public: true)
sign_in_owner
match = tournament.matches.where.not(w1: nil, w2: nil)
.where("loser1_name != ? OR loser1_name IS NULL", "BYE")
.where("loser2_name != ? OR loser2_name IS NULL", "BYE")
.order(:bout_number)
.first
assert_not_nil match, "Expected at least one fully populated non-BYE match"
round = match.round.to_s
w1 = Wrestler.find(match.w1)
w2 = Wrestler.find(match.w2)
get :bout_sheets, params: { id: tournament.id, round: round }
assert_response :success
assert_includes response.body, "Bout Number:</strong> #{match.bout_number}"
assert_includes response.body, "Round:</strong> #{match.round}"
assert_includes response.body, "#{w1.name}-#{w1.school.name}"
assert_includes response.body, "#{w2.name}-#{w2.school.name}"
end
test "bout_sheets filters out matches with BYE loser names" do
tournament = create_double_elim_tournament_single_weight(8, "Regular Double Elimination 1-6")
tournament.update!(user_id: users(:one).id, is_public: true)
sign_in_owner
bye_match = tournament.matches.order(:bout_number).first
assert_not_nil bye_match, "Expected at least one match to mark as BYE"
bye_match.update!(loser1_name: "BYE")
non_bye_match = tournament.matches.where.not(id: bye_match.id).where.not(w1: nil, w2: nil)
.where("loser1_name != ? OR loser1_name IS NULL", "BYE")
.where("loser2_name != ? OR loser2_name IS NULL", "BYE")
.order(:bout_number)
.first
assert_not_nil non_bye_match, "Expected at least one non-BYE match to remain"
get :bout_sheets, params: { id: tournament.id, round: "All" }
assert_response :success
assert_not_includes response.body, "Bout Number:</strong> #{bye_match.bout_number}"
assert_includes response.body, "Bout Number:</strong> #{non_bye_match.bout_number}"
end
end end

View File

@@ -40,9 +40,9 @@ class UpMatchesCacheTest < ActionController::TestCase
mat.reload mat.reload
movable_match = mat.queue2_match || mat.queue1_match movable_match = mat.queue2_match || mat.queue1_match
assert movable_match, "Expected at least one queued match to move" assert movable_match, "Expected at least one queued match to move"
mat.assign_match_to_queue!(movable_match, 4)
third_events = cache_events_for_up_matches do third_events = cache_events_for_up_matches do
mat.assign_match_to_queue!(movable_match, 4)
get :up_matches, params: { id: @tournament.id } get :up_matches, params: { id: @tournament.id }
assert_response :success assert_response :success
end end
@@ -50,13 +50,109 @@ class UpMatchesCacheTest < ActionController::TestCase
assert_operator cache_writes(third_events), :>, 0, "Expected queue change to invalidate and rewrite at least one row fragment" assert_operator cache_writes(third_events), :>, 0, "Expected queue change to invalidate and rewrite at least one row fragment"
end end
test "up_matches row fragments hit cache after queue clear rewrite" do
first_events = cache_events_for_up_matches do
get :up_matches, params: { id: @tournament.id }
assert_response :success
end
assert_operator cache_writes(first_events), :>, 0, "Expected initial render to write row fragments"
mat = @tournament.mats.first
clear_events = cache_events_for_up_matches do
mat.clear_queue!
get :up_matches, params: { id: @tournament.id }
assert_response :success
end
assert_operator cache_writes(clear_events), :>, 0, "Expected queue clear to invalidate and rewrite at least one row fragment"
repeat_events = cache_events_for_up_matches do
get :up_matches, params: { id: @tournament.id }
assert_response :success
end
assert_equal 0, cache_writes(repeat_events), "Expected subsequent render after queue clear rewrite to reuse cached row fragments"
assert_operator cache_hits(repeat_events), :>, 0, "Expected cache hits after queue clear rewrite"
end
test "up_matches unassigned row fragments hit cache and invalidate after unassigned match update" do
key_markers = %w[up_matches_unassigned_row]
first_events = cache_events_for_up_matches(key_markers) do
get :up_matches, params: { id: @tournament.id }
assert_response :success
end
assert_operator cache_writes(first_events), :>, 0, "Expected initial unassigned row render to write fragments"
second_events = cache_events_for_up_matches(key_markers) do
get :up_matches, params: { id: @tournament.id }
assert_response :success
end
assert_equal 0, cache_writes(second_events), "Expected repeat unassigned row render to reuse cached fragments"
assert_operator cache_hits(second_events), :>, 0, "Expected repeat unassigned row render to hit cache"
unassigned_match = @tournament.up_matches_unassigned_matches.first
assert unassigned_match, "Expected at least one unassigned match for cache invalidation test"
third_events = cache_events_for_up_matches(key_markers) do
unassigned_match.touch
get :up_matches, params: { id: @tournament.id }
assert_response :success
end
assert_operator cache_writes(third_events), :>, 0, "Expected unassigned match update to invalidate unassigned row fragment"
end
test "completing an on-mat match expires up_matches cached fragments" do
warm_events = cache_events_for_up_matches(%w[up_matches_mat_row up_matches_unassigned_row]) do
get :up_matches, params: { id: @tournament.id }
assert_response :success
end
assert_operator cache_writes(warm_events), :>, 0, "Expected initial up_matches render to warm caches"
mat = @tournament.mats.detect { |m| m.queue1_match.present? }
assert mat, "Expected a mat with a queued match"
match = mat.queue1_match
assert match, "Expected queue1 match to complete"
post_action_events = cache_events_for_up_matches(%w[up_matches_mat_row up_matches_unassigned_row]) do
match.update!(
finished: 1,
winner_id: match.w1 || match.w2,
win_type: "Decision",
score: "1-0"
)
get :up_matches, params: { id: @tournament.id }
assert_response :success
end
assert_operator cache_writes(post_action_events), :>, 0, "Expected completed match to expire and rewrite up_matches caches"
end
test "manually assigning an unassigned match to a mat queue expires up_matches caches" do
warm_events = cache_events_for_up_matches(%w[up_matches_mat_row up_matches_unassigned_row]) do
get :up_matches, params: { id: @tournament.id }
assert_response :success
end
assert_operator cache_writes(warm_events), :>, 0, "Expected initial up_matches render to warm caches"
unassigned_match = @tournament.up_matches_unassigned_matches.first
assert unassigned_match, "Expected at least one unassigned match to manually place on a mat"
target_mat = @tournament.mats.first
post_action_events = cache_events_for_up_matches(%w[up_matches_mat_row up_matches_unassigned_row]) do
target_mat.assign_match_to_queue!(unassigned_match, 4)
get :up_matches, params: { id: @tournament.id }
assert_response :success
end
assert_operator cache_writes(post_action_events), :>, 0, "Expected manual mat assignment to expire and rewrite up_matches caches"
end
private private
def cache_events_for_up_matches def cache_events_for_up_matches(key_markers = %w[up_matches_mat_row up_matches_unassigned_row])
events = [] events = []
subscriber = lambda do |name, _start, _finish, _id, payload| subscriber = lambda do |name, _start, _finish, _id, payload|
key = payload[:key].to_s key = payload[:key].to_s
next unless key.include?("up_matches_mat_row") next unless key_markers.any? { |marker| key.include?(marker) }
events << { name: name, hit: payload[:hit] } events << { name: name, hit: payload[:hit] }
end end

View File

@@ -0,0 +1,107 @@
require "test_helper"
class WeightShowCacheTest < ActionController::TestCase
tests WeightsController
setup do
create_a_tournament_with_single_weight("Regular Double Elimination 1-6", 8)
@tournament.update!(user_id: users(:one).id)
@weight = @tournament.weights.first
@original_perform_caching = ActionController::Base.perform_caching
ActionController::Base.perform_caching = true
Rails.cache.clear
end
teardown do
Rails.cache.clear
ActionController::Base.perform_caching = @original_perform_caching
end
test "weight show readonly row fragments hit cache and invalidate after wrestler update" do
first_events = cache_events_for_weight_show do
get :show, params: { id: @weight.id }
assert_response :success
end
assert_operator cache_writes(first_events), :>, 0, "Expected initial weight show render to write readonly row fragments"
second_events = cache_events_for_weight_show do
get :show, params: { id: @weight.id }
assert_response :success
end
assert_equal 0, cache_writes(second_events), "Expected repeat weight show render to reuse readonly row fragments"
assert_operator cache_hits(second_events), :>, 0, "Expected repeat weight show render to hit readonly row cache"
wrestler = @weight.wrestlers.first
third_events = cache_events_for_weight_show do
wrestler.touch
get :show, params: { id: @weight.id }
assert_response :success
end
assert_operator cache_writes(third_events), :>, 0, "Expected wrestler update to invalidate weight show readonly row cache"
end
test "weight show does not leak manage-only controls from cache across users" do
sign_in users(:one)
get :show, params: { id: @weight.id }
assert_response :success
assert_includes response.body, "Save Seeds"
assert_match(/fa-trash-alt/, response.body)
assert_match(/name="wrestler\[\d+\]\[original_seed\]"/, response.body)
sign_out
get :show, params: { id: @weight.id }
assert_response :success
assert_not_includes response.body, "Save Seeds"
assert_no_match(/fa-trash-alt/, response.body)
assert_no_match(/name="wrestler\[\d+\]\[original_seed\]"/, response.body)
spectator_cache_events = cache_events_for_weight_show do
get :show, params: { id: @weight.id }
assert_response :success
end
assert_operator cache_hits(spectator_cache_events), :>, 0, "Expected repeat spectator request to hit readonly wrestler row cache"
assert_not_includes response.body, "Save Seeds"
assert_no_match(/fa-trash-alt/, response.body)
assert_no_match(/name="wrestler\[\d+\]\[original_seed\]"/, response.body)
end
private
def sign_out
@request.session[:user_id] = nil
@controller.instance_variable_set(:@current_user, nil)
@controller.instance_variable_set(:@current_ability, nil)
end
def cache_events_for_weight_show
events = []
subscriber = lambda do |name, _start, _finish, _id, payload|
key = payload[:key].to_s
next unless key.include?("weight_show_wrestler_row")
events << { name: name, hit: payload[:hit] }
end
ActiveSupport::Notifications.subscribed(
subscriber,
/cache_(read|write|fetch_hit|generate)\.active_support/
) do
yield
end
events
end
def cache_writes(events)
events.count { |event| event[:name] == "cache_write.active_support" }
end
def cache_hits(events)
events.count do |event|
event[:name] == "cache_fetch_hit.active_support" ||
(event[:name] == "cache_read.active_support" && event[:hit])
end
end
end

View File

@@ -30,6 +30,15 @@ class DoubleEliminationWrestlerScore < ActionDispatch::IntegrationTest
return wrestler return wrestler
end end
def wrestle_other_match_in_round(reference_match, conso: false)
match = @tournament.matches.reload
.select { |m| m.round == reference_match.round && m.id != reference_match.id && m.is_consolation_match == conso }
.first
return if match.nil?
winner_by_name("Test2", match)
end
test "Wrestlers get points for byes in the championship rounds" do test "Wrestlers get points for byes in the championship rounds" do
matches = @tournament.matches.reload matches = @tournament.matches.reload
round1 = matches.select{|m| m.bracket_position == "Bracket Round of 16"}.first round1 = matches.select{|m| m.bracket_position == "Bracket Round of 16"}.first
@@ -37,9 +46,10 @@ class DoubleEliminationWrestlerScore < ActionDispatch::IntegrationTest
semi = matches.select{|m| m.bracket_position == "Semis"}.first semi = matches.select{|m| m.bracket_position == "Semis"}.first
winner_by_name_by_bye("Test1", round1) winner_by_name_by_bye("Test1", round1)
winner_by_name_by_bye("Test1", quarter) winner_by_name_by_bye("Test1", quarter)
wrestle_other_match_in_round(round1, conso: false)
winner_by_name("Test1", semi) winner_by_name("Test1", semi)
wrestler_points_calc = CalculateWrestlerTeamScore.new(get_wretler_by_name("Test1")) wrestler_points_calc = CalculateWrestlerTeamScore.new(get_wretler_by_name("Test1"))
assert wrestler_points_calc.byePoints == 4 assert wrestler_points_calc.byePoints == 2
end end
test "Wrestlers get points for byes in the consolation rounds" do test "Wrestlers get points for byes in the consolation rounds" do
@@ -49,9 +59,10 @@ class DoubleEliminationWrestlerScore < ActionDispatch::IntegrationTest
semi = matches.select{|m| m.bracket_position == "Conso Semis"}.first semi = matches.select{|m| m.bracket_position == "Conso Semis"}.first
winner_by_name_by_bye("Test1", conso_r8_1) winner_by_name_by_bye("Test1", conso_r8_1)
winner_by_name_by_bye("Test1", quarter) winner_by_name_by_bye("Test1", quarter)
wrestle_other_match_in_round(conso_r8_1, conso: true)
winner_by_name("Test1", semi) winner_by_name("Test1", semi)
wrestler_points_calc = CalculateWrestlerTeamScore.new(get_wretler_by_name("Test1")) wrestler_points_calc = CalculateWrestlerTeamScore.new(get_wretler_by_name("Test1"))
assert wrestler_points_calc.byePoints == 2 assert wrestler_points_calc.byePoints == 1
end end
test "Wrestlers do not get bye points if they get byes to 1st/2nd and win by bye" do test "Wrestlers do not get bye points if they get byes to 1st/2nd and win by bye" do
@@ -93,7 +104,19 @@ class DoubleEliminationWrestlerScore < ActionDispatch::IntegrationTest
winner_by_name_by_bye("Test1", semi) winner_by_name_by_bye("Test1", semi)
winner_by_name("Test1", final) winner_by_name("Test1", final)
wrestler_points_calc = CalculateWrestlerTeamScore.new(get_wretler_by_name("Test1")) wrestler_points_calc = CalculateWrestlerTeamScore.new(get_wretler_by_name("Test1"))
assert wrestler_points_calc.byePoints == 6 assert wrestler_points_calc.byePoints == 0
end
test "Wrestlers do not get championship bye points when no championship match is wrestled in those bye rounds" do
matches = @tournament.matches.reload
round1 = matches.select{|m| m.bracket_position == "Bracket Round of 16"}.first
quarter = matches.select{|m| m.bracket_position == "Quarter"}.first
semi = matches.select{|m| m.bracket_position == "Semis"}.first
winner_by_name_by_bye("Test1", round1)
winner_by_name_by_bye("Test1", quarter)
winner_by_name("Test1", semi)
wrestler_points_calc = CalculateWrestlerTeamScore.new(get_wretler_by_name("Test1"))
assert wrestler_points_calc.byePoints == 0
end end
test "Wrestlers do not get bye points if they get byes to 3rd/4th and win by decision" do test "Wrestlers do not get bye points if they get byes to 3rd/4th and win by decision" do
@@ -107,6 +130,20 @@ class DoubleEliminationWrestlerScore < ActionDispatch::IntegrationTest
winner_by_name_by_bye("Test1", semi) winner_by_name_by_bye("Test1", semi)
winner_by_name("Test1", final) winner_by_name("Test1", final)
wrestler_points_calc = CalculateWrestlerTeamScore.new(get_wretler_by_name("Test1")) wrestler_points_calc = CalculateWrestlerTeamScore.new(get_wretler_by_name("Test1"))
assert wrestler_points_calc.byePoints == 3 assert wrestler_points_calc.byePoints == 0
end
test "Wrestlers do not get conso bye points when no conso match is wrestled in those rounds" do
matches = @tournament.matches.reload
conso_r8_1 = matches.select{|m| m.bracket_position == "Conso Round of 8.1"}.first
quarter = matches.select{|m| m.bracket_position == "Conso Quarter"}.first
semi = matches.select{|m| m.bracket_position == "Conso Semis"}.first
final = matches.select{|m| m.bracket_position == "3/4"}.first
winner_by_name_by_bye("Test1", conso_r8_1)
winner_by_name_by_bye("Test1", quarter)
winner_by_name_by_bye("Test1", semi)
winner_by_name("Test1", final)
wrestler_points_calc = CalculateWrestlerTeamScore.new(get_wretler_by_name("Test1"))
assert wrestler_points_calc.byePoints == 0
end end
end end

Some files were not shown because too many files have changed in this diff Show More