1
0
mirror of https://github.com/jcwimer/wrestlingApp synced 2026-04-17 05:15:36 +00:00

New stats page, scoreboard, and live scores pages.

This commit is contained in:
2026-04-10 21:08:03 -04:00
parent 7526148ba5
commit 970f38ed14
60 changed files with 9650 additions and 148 deletions

View File

@@ -18,6 +18,9 @@ window.Stimulus = application;
import WrestlerColorController from "controllers/wrestler_color_controller";
import MatchScoreController from "controllers/match_score_controller";
import MatchDataController from "controllers/match_data_controller";
import MatchStateController from "controllers/match_state_controller";
import MatchScoreboardController from "controllers/match_scoreboard_controller";
import MatStateController from "controllers/mat_state_controller";
import MatchSpectateController from "controllers/match_spectate_controller";
import UpMatchesConnectionController from "controllers/up_matches_connection_controller";
@@ -25,6 +28,9 @@ import UpMatchesConnectionController from "controllers/up_matches_connection_con
application.register("wrestler-color", WrestlerColorController);
application.register("match-score", MatchScoreController);
application.register("match-data", MatchDataController);
application.register("match-state", MatchStateController);
application.register("match-scoreboard", MatchScoreboardController);
application.register("mat-state", MatStateController);
application.register("match-spectate", MatchSpectateController);
application.register("up-matches-connection", UpMatchesConnectionController);

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 {
static targets = [
"winType", "winnerSelect", "submitButton", "dynamicScoreInput",
"winType", "overtimeSelect", "winnerSelect", "submitButton", "dynamicScoreInput",
"finalScoreField", "validationAlerts", "pinTimeTip"
]
static values = {
winnerScore: { type: String, default: "0" },
loserScore: { type: String, default: "0" }
loserScore: { type: String, default: "0" },
pinMinutes: { type: String, default: "0" },
pinSeconds: { type: String, default: "00" },
manualOverride: { type: Boolean, default: false },
finished: { type: Boolean, default: false }
}
connect() {
console.log("Match score controller connected")
// Use setTimeout to ensure the DOM is fully loaded
this.boundMarkManualOverride = this.markManualOverride.bind(this)
this.element.addEventListener("input", this.boundMarkManualOverride)
this.element.addEventListener("change", this.boundMarkManualOverride)
if (this.finishedValue) {
this.validateForm()
return
}
setTimeout(() => {
this.updateScoreInput()
this.validateForm()
}, 50)
}
disconnect() {
this.element.removeEventListener("input", this.boundMarkManualOverride)
this.element.removeEventListener("change", this.boundMarkManualOverride)
}
winTypeChanged() {
if (this.finishedValue) {
this.validateForm()
return
}
this.updateScoreInput()
this.validateForm()
}
@@ -30,6 +49,7 @@ export default class extends Controller {
}
updateScoreInput() {
if (this.finishedValue) return
const winType = this.winTypeTarget.value
this.dynamicScoreInputTarget.innerHTML = ""
@@ -47,6 +67,9 @@ export default class extends Controller {
this.dynamicScoreInputTarget.appendChild(minuteInput)
this.dynamicScoreInputTarget.appendChild(secondInput)
minuteInput.querySelector("input").value = this.pinMinutesValue || "0"
secondInput.querySelector("input").value = this.pinSecondsValue || "00"
// Add event listeners to the new inputs
const inputs = this.dynamicScoreInputTarget.querySelectorAll("input")
@@ -111,6 +134,43 @@ export default class extends Controller {
this.validateForm()
}
applyDefaultResults(defaults = {}) {
if (this.manualOverrideValue || this.finishedValue) return
if (Object.prototype.hasOwnProperty.call(defaults, "winnerId") && this.hasWinnerSelectTarget) {
this.winnerSelectTarget.value = defaults.winnerId ? String(defaults.winnerId) : ""
}
if (Object.prototype.hasOwnProperty.call(defaults, "overtimeType") && this.hasOvertimeSelectTarget) {
const allowedValues = Array.from(this.overtimeSelectTarget.options).map((option) => option.value)
this.overtimeSelectTarget.value = allowedValues.includes(defaults.overtimeType) ? defaults.overtimeType : ""
}
if (Object.prototype.hasOwnProperty.call(defaults, "winnerScore")) {
this.winnerScoreValue = String(defaults.winnerScore)
}
if (Object.prototype.hasOwnProperty.call(defaults, "loserScore")) {
this.loserScoreValue = String(defaults.loserScore)
}
if (Object.prototype.hasOwnProperty.call(defaults, "pinMinutes")) {
this.pinMinutesValue = String(defaults.pinMinutes)
}
if (Object.prototype.hasOwnProperty.call(defaults, "pinSeconds")) {
this.pinSecondsValue = String(defaults.pinSeconds).padStart(2, "0")
}
this.updateScoreInput()
this.validateForm()
}
markManualOverride(event) {
if (!event.isTrusted) return
this.manualOverrideValue = true
}
updatePinTimeScore() {
const minuteInput = this.dynamicScoreInputTarget.querySelector("#minutes")
const secondInput = this.dynamicScoreInputTarget.querySelector("#seconds")
@@ -234,4 +294,4 @@ export default class extends Controller {
event.preventDefault();
}
}
}
}

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

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,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
SCOREBOARD_CACHE_TTL = 1.hours
def subscribed
@match = Match.find_by(id: params[:match_id])
Rails.logger.info "[MatchChannel] Client subscribed with match_id: #{params[:match_id]}. Match found: #{@match.present?}"
@@ -11,6 +13,19 @@ class MatchChannel < ApplicationCable::Channel
end
end
def send_scoreboard(data)
unless @match
Rails.logger.error "[MatchChannel] Error: send_scoreboard called but @match is nil. Client params on sub: #{params[:match_id]}"
return
end
scoreboard_state = data["scoreboard_state"]
return if scoreboard_state.blank?
Rails.cache.write(scoreboard_cache_key, scoreboard_state, expires_in: SCOREBOARD_CACHE_TTL)
MatchChannel.broadcast_to(@match, { scoreboard_state: scoreboard_state })
end
def unsubscribed
Rails.logger.info "[MatchChannel] Client unsubscribed for match #{@match&.id}"
end
@@ -75,7 +90,8 @@ class MatchChannel < ApplicationCable::Channel
win_type: @match.win_type,
winner_name: @match.winner&.name,
winner_id: @match.winner_id,
finished: @match.finished
finished: @match.finished,
scoreboard_state: Rails.cache.read(scoreboard_cache_key)
}.compact
if payload.present?
@@ -85,4 +101,10 @@ class MatchChannel < ApplicationCable::Channel
Rails.logger.info "[MatchChannel] request_sync payload empty for match #{@match.id}, not transmitting."
end
end
private
def scoreboard_cache_key
"tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state"
end
end

View File

@@ -1,6 +1,7 @@
class MatchesController < ApplicationController
before_action :set_match, only: [:show, :edit, :update, :stat, :spectate, :edit_assignment, :update_assignment]
before_action :check_access, only: [:edit, :update, :stat, :edit_assignment, :update_assignment]
before_action :set_match, only: [:show, :edit, :update, :stat, :state, :spectate, :edit_assignment, :update_assignment]
before_action :check_access, only: [:edit, :update, :stat, :state, :edit_assignment, :update_assignment]
before_action :check_read_access, only: [:spectate]
# GET /matches/1
# GET /matches/1.json
@@ -22,49 +23,12 @@ class MatchesController < ApplicationController
end
def stat
# @show_next_bout_button = false
if params[:match]
@match = Match.where(:id => params[:match]).includes(:wrestlers).first
end
@wrestlers = []
if @match
if @match.w1
@wrestler1_name = @match.wrestler1.name
@wrestler1_school_name = @match.wrestler1.school.name
@wrestler1_last_match = @match.wrestler1.last_match
@wrestlers.push(@match.wrestler1)
else
@wrestler1_name = "Not assigned"
@wrestler1_school_name = "N/A"
@wrestler1_last_match = nil
end
if @match.w2
@wrestler2_name = @match.wrestler2.name
@wrestler2_school_name = @match.wrestler2.school.name
@wrestler2_last_match = @match.wrestler2.last_match
@wrestlers.push(@match.wrestler2)
else
@wrestler2_name = "Not assigned"
@wrestler2_school_name = "N/A"
@wrestler2_last_match = nil
end
@tournament = @match.tournament
end
if @match&.mat
@mat = @match.mat
queue_position = @mat.queue_position_for_match(@match)
@next_match = queue_position == 1 ? @mat.queue2_match : nil
@show_next_bout_button = queue_position == 1
if request.referer&.include?("/tournaments/#{@tournament.id}/matches")
session[:return_path] = "/tournaments/#{@tournament.id}/matches"
else
session[:return_path] = mat_path(@mat)
end
session[:error_return_path] = "/matches/#{@match.id}/stat"
else
session[:return_path] = "/tournaments/#{@tournament.id}/matches"
session[:error_return_path] = "/matches/#{@match.id}/stat"
end
load_match_stat_context
end
def state
load_match_stat_context
@match_state_ruleset = "folkstyle_usa"
end
# GET /matches/:id/spectate
@@ -142,26 +106,19 @@ class MatchesController < ApplicationController
win_type: @match.win_type,
winner_id: @match.winner_id,
winner_name: @match.winner&.name,
finished: @match.finished
finished: @match.finished,
scoreboard_state: Rails.cache.read("tournament:#{@match.tournament_id}:match:#{@match.id}:scoreboard_state")
}
)
if session[:return_path]
sanitized_return_path = sanitize_return_path(session[:return_path])
format.html { redirect_to sanitized_return_path, notice: 'Match was successfully updated.' }
session.delete(:return_path) # Remove the session variable
else
format.html { redirect_to "/tournaments/#{@match.tournament.id}", notice: 'Match was successfully updated.' }
end
redirect_path = resolve_match_redirect_path(session[:return_path]) || "/tournaments/#{@match.tournament.id}"
format.html { redirect_to redirect_path, notice: 'Match was successfully updated.' }
session.delete(:return_path)
format.json { head :no_content }
else
if session[:error_return_path]
format.html { redirect_to session.delete(:error_return_path), alert: "Match did not save because: #{@match.errors.full_messages.to_s}" }
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
error_path = resolve_match_redirect_path(session[:error_return_path]) || "/tournaments/#{@match.tournament.id}"
format.html { redirect_to error_path, alert: "Match did not save because: #{@match.errors.full_messages.to_s}" }
format.json { render json: @match.errors, status: :unprocessable_entity }
end
end
end
@@ -182,11 +139,66 @@ class MatchesController < ApplicationController
authorize! :manage, @match.tournament
end
def sanitize_return_path(path)
def check_read_access
authorize! :read, @match.tournament
end
def sanitize_redirect_path(path)
return nil if path.blank?
uri = URI.parse(path)
params = Rack::Utils.parse_nested_query(uri.query)
params.delete("bout_number") # Remove the bout_number param
uri.query = params.to_query.presence # Rebuild the query string or set it to nil if empty
uri.to_s # Return the full path as a string
end
return nil if uri.scheme.present? || uri.host.present?
uri.to_s
rescue URI::InvalidURIError
nil
end
def resolve_match_redirect_path(fallback_path)
sanitize_redirect_path(params[:redirect_to].presence) || sanitize_redirect_path(fallback_path)
end
def load_match_stat_context
if params[:match]
@match = Match.where(:id => params[:match]).includes(:wrestlers).first
end
@wrestlers = []
if @match
if @match.w1
@wrestler1_name = @match.wrestler1.name
@wrestler1_school_name = @match.wrestler1.school.name
@wrestler1_last_match = @match.wrestler1.last_match
@wrestlers.push(@match.wrestler1)
else
@wrestler1_name = "Not assigned"
@wrestler1_school_name = "N/A"
@wrestler1_last_match = nil
end
if @match.w2
@wrestler2_name = @match.wrestler2.name
@wrestler2_school_name = @match.wrestler2.school.name
@wrestler2_last_match = @match.wrestler2.last_match
@wrestlers.push(@match.wrestler2)
else
@wrestler2_name = "Not assigned"
@wrestler2_school_name = "N/A"
@wrestler2_last_match = nil
end
@tournament = @match.tournament
end
if @match&.mat
@mat = @match.mat
queue_position = @mat.queue_position_for_match(@match)
@next_match = queue_position == 1 ? @mat.queue2_match : nil
@show_next_bout_button = queue_position == 1
end
@match_results_redirect_path = sanitize_redirect_path(params[:redirect_to].presence) || "/tournaments/#{@tournament.id}/matches"
session[:return_path] = @match_results_redirect_path
session[:error_return_path] = request.original_fullpath
end
end

View File

@@ -1,6 +1,6 @@
class MatsController < ApplicationController
before_action :set_mat, only: [:show, :edit, :update, :destroy, :assign_next_match]
before_action :check_access, only: [:new,:create,:update,:destroy,:edit,:show, :assign_next_match]
before_action :set_mat, only: [:show, :state, :scoreboard, :edit, :update, :destroy, :assign_next_match, :select_match]
before_action :check_access, only: [:new,:create,:update,:destroy,:edit,:show, :state, :scoreboard, :assign_next_match, :select_match]
# GET /mats/1
# GET /mats/1.json
@@ -44,10 +44,33 @@ class MatsController < ApplicationController
@tournament = @match.tournament
end
session[:return_path] = request.original_fullpath
@match_results_redirect_path = sanitize_mat_redirect_path(params[:redirect_to].presence || request.original_fullpath)
session[:return_path] = @match_results_redirect_path
session[:error_return_path] = request.original_fullpath
end
def scoreboard
@match = @mat.selected_scoreboard_match || @mat.queue1_match
@tournament = @mat.tournament
end
def state
load_mat_match_context
@match_state_ruleset = "folkstyle_usa"
end
def select_match
selected_match = @mat.queue_matches.compact.find do |match|
match.id == params[:match_id].to_i || match.bout_number == params[:bout_number].to_i
end
return head :unprocessable_entity unless selected_match || params[:last_match_result].present?
@mat.set_selected_scoreboard_match!(selected_match) if selected_match
@mat.set_last_match_result!(params[:last_match_result]) if params.key?(:last_match_result)
head :no_content
end
# GET /mats/new
def new
@mat = Mat.new
@@ -139,6 +162,66 @@ class MatsController < ApplicationController
end
authorize! :manage, @tournament
end
def sanitize_mat_redirect_path(path)
return nil if path.blank?
uri = URI.parse(path)
return nil if uri.scheme.present? || uri.host.present?
params = Rack::Utils.parse_nested_query(uri.query)
params.delete("bout_number")
uri.query = params.to_query.presence
uri.to_s
rescue URI::InvalidURIError
nil
end
def load_mat_match_context
bout_number_param = params[:bout_number]
@queue_matches = @mat.queue_matches
@match = if bout_number_param
@queue_matches.compact.find { |match| match.bout_number == bout_number_param.to_i }
else
@queue_matches[0]
end
@match ||= @queue_matches[0]
@next_match = @queue_matches[1]
@show_next_bout_button = false
@wrestlers = []
if @match
if @match.w1
@wrestler1_name = @match.wrestler1.name
@wrestler1_school_name = @match.wrestler1.school.name
@wrestler1_last_match = @match.wrestler1.last_match
@wrestlers.push(@match.wrestler1)
else
@wrestler1_name = "Not assigned"
@wrestler1_school_name = "N/A"
@wrestler1_last_match = nil
end
if @match.w2
@wrestler2_name = @match.wrestler2.name
@wrestler2_school_name = @match.wrestler2.school.name
@wrestler2_last_match = @match.wrestler2.last_match
@wrestlers.push(@match.wrestler2)
else
@wrestler2_name = "Not assigned"
@wrestler2_school_name = "N/A"
@wrestler2_last_match = nil
end
@tournament = @match.tournament
else
@tournament = @mat.tournament
end
@match_results_redirect_path = sanitize_mat_redirect_path(params[:redirect_to].presence || request.original_fullpath)
session[:return_path] = @match_results_redirect_path
session[:error_return_path] = request.original_fullpath
end
end

View File

@@ -1,10 +1,10 @@
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_destroy, only: [:destroy,:delegate,:remove_delegate]
before_action :check_tournament_errors, only: [:generate_matches]
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
@schools = @tournament.schools.includes(wrestlers: :weight)
@@ -242,6 +242,10 @@ class TournamentsController < ApplicationController
@bracket_position = nil
end
def live_scores
@mats = @tournament.mats.sort_by(&:name)
end
def generate_matches
GenerateTournamentMatches.new(@tournament).generate
end

View File

@@ -7,6 +7,8 @@ class Mat < ApplicationRecord
validates :name, presence: true
QUEUE_SLOTS = %w[queue1 queue2 queue3 queue4].freeze
SCOREBOARD_SELECTION_CACHE_TTL = 1.hours
LAST_MATCH_RESULT_CACHE_TTL = 1.hours
after_save :clear_queue_matches_cache
after_commit :broadcast_up_matches_board, on: :update, if: :up_matches_queue_changed?
@@ -191,6 +193,56 @@ class Mat < ApplicationRecord
matches.select{|m| m.finished != 1}.sort_by{|m| m.bout_number}
end
def scoreboard_payload
selected_match = selected_scoreboard_match
{
mat_id: id,
queue1_bout_number: queue1_match&.bout_number,
queue1_match_id: queue1_match&.id,
selected_bout_number: selected_match&.bout_number,
selected_match_id: selected_match&.id,
last_match_result: last_match_result_text
}
end
def set_selected_scoreboard_match!(match)
if match
Rails.cache.write(
scoreboard_selection_cache_key,
{ match_id: match.id, bout_number: match.bout_number },
expires_in: SCOREBOARD_SELECTION_CACHE_TTL
)
else
Rails.cache.delete(scoreboard_selection_cache_key)
end
broadcast_current_match
end
def selected_scoreboard_match
selection = Rails.cache.read(scoreboard_selection_cache_key)
return nil unless selection
match_id = selection[:match_id] || selection["match_id"]
selected_match = queue_matches.compact.find { |match| match.id == match_id }
return selected_match if selected_match
Rails.cache.delete(scoreboard_selection_cache_key)
nil
end
def set_last_match_result!(text)
if text.present?
Rails.cache.write(last_match_result_cache_key, text, expires_in: LAST_MATCH_RESULT_CACHE_TTL)
else
Rails.cache.delete(last_match_result_cache_key)
end
broadcast_current_match
end
def last_match_result_text
Rails.cache.read(last_match_result_cache_key)
end
private
def clear_queue_matches_cache
@@ -276,6 +328,15 @@ class Mat < ApplicationRecord
show_next_bout_button: true
}
)
MatScoreboardChannel.broadcast_to(self, scoreboard_payload)
end
def scoreboard_selection_cache_key
"tournament:#{tournament_id}:mat:#{id}:scoreboard_selection"
end
def last_match_result_cache_key
"tournament:#{tournament_id}:mat:#{id}:last_match_result"
end
def broadcast_up_matches_board

View File

@@ -27,9 +27,10 @@
<% end %>
<li><%= link_to "All Brackets (Printable)", "/tournaments/#{@tournament.id}/all_brackets?print=true", target: :_blank %></li>
</ul>
</li>
<li><%= link_to " Bout Board" , "/tournaments/#{@tournament.id}/up_matches", class: "fas fa-list-alt" %></li>
<% end %>
</li>
<li><%= link_to " Bout Board" , "/tournaments/#{@tournament.id}/up_matches", class: "fas fa-list-alt" %></li>
<li><%= link_to " Live Scores" , "/tournaments/#{@tournament.id}/live_scores", class: "fas fa-tv" %></li>
<% end %>
<% if can? :manage, @tournament %>
<li class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" href="#director"><i class="fas fa-tools"> Director Links</i>

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

@@ -130,65 +130,6 @@
<br>
<br>
<br>
<h4>Match Results</h4>
<br>
<div data-controller="match-score">
<div class="field">
<%= f.label "Win type" %><br>
<%= f.select :win_type, Match::WIN_TYPES, { include_blank: false }, {
data: {
match_score_target: "winType",
action: "change->match-score#winTypeChanged"
}
} %>
</div>
<br>
<div class="field">
<%= f.label "Overtime Type" %> Leave blank if not overtime. For High School the 1st overtime is SV-1, second overtime is TB-1, third overtime is UTB.<br>
<%= f.select(:overtime_type, Match::OVERTIME_TYPES) %>
</div>
<br>
<div class="field">
<%= f.label "Winner" %> Please choose the winner<br>
<%= f.collection_select :winner_id, @wrestlers, :id, :name_with_school,
{ include_blank: true },
{
data: {
match_score_target: "winnerSelect",
action: "change->match-score#winnerChanged"
}
}
%>
</div>
<br>
<div class="field">
<%= f.label "Final Score" %>
<br>
<span id="score-help-text">
The input will adjust based on the selected win type.
</span>
<br>
<div id="dynamic-score-input" data-match-score-target="dynamicScoreInput"></div>
<p id="pin-time-tip" class="text-muted mt-2" style="display: none;" data-match-score-target="pinTimeTip">
<strong>Tip:</strong> Pin time is an accumulation over the match, not how much time was left in the current period.
<br>For example, if all 3 periods are 2 minutes and a pin happened with 1:27 left in the second period,
the pin time would be <strong>2:33</strong> (2 minutes for the first period + 33 seconds elapsed in the second period).
</p>
<div id="validation-alerts" class="text-danger mt-2" data-match-score-target="validationAlerts"></div>
<%= f.hidden_field :score, id: "final-score-field", data: { match_score_target: "finalScoreField" } %>
<br>
<%= f.submit "Update Match", id: "update-match-btn",
data: {
match_score_target: "submitButton",
action: "click->match-score#confirmWinner"
},
class: "btn btn-success" %>
</div>
</div><!-- End of match-score controller -->
<%= render "matches/match_results_fields", f: f, redirect_path: @match_results_redirect_path %>
</div><!-- End of match-data controller div -->
<br>
<%= f.hidden_field :finished, :value => 1 %>
<%= f.hidden_field :round, :value => @match.round %>
<% end %><!-- End of form_for -->

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"
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="wrestler-info wrestler1">
<h4><%= @wrestler1_name %> (<%= @wrestler1_school_name %>)</h4>
@@ -83,4 +94,4 @@
color: white;
}
*/
</style>
</style>

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

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

@@ -129,13 +129,24 @@
<thead>
<tr>
<th>Name</th>
<th>Current Match</th>
<th><%= link_to " New Mat" , "/mats/new?tournament=#{@tournament.id}", :class=>"fas fa-plus" %></th>
</tr>
</thead>
<tbody>
<% @mats.each do |mat| %>
<% current_match = mat.queue1_match %>
<tr>
<td><%= link_to "Mat #{mat.name}", mat %></td>
<td>
<% if current_match %>
<%= link_to "Stat Match", stat_match_path(current_match), class: "btn btn-primary btn-sm" %>
<%= link_to "State Match", state_mat_path(mat), class: "btn btn-success btn-sm" %>
<%= link_to "Scoreboard", scoreboard_mat_path(mat, print: true), class: "btn btn-warning btn-sm", target: "_blank", rel: "noopener" %>
<% else %>
<%= link_to "Scoreboard", scoreboard_mat_path(mat, print: true), class: "btn btn-warning btn-sm", target: "_blank", rel: "noopener" %>
<% end %>
</td>
<% if can? :manage, @tournament %>
<td>
<%= link_to mat, data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete Mat #{mat.name}?" }, class: "text-decoration-none" do %>