1
0
mirror of https://github.com/jcwimer/wrestlingApp synced 2026-04-13 08:32:47 +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

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