2025-12-21 21:57:31 +00:00
|
|
|
import { Controller } from "https://unpkg.com/@hotwired/stimulus/dist/stimulus.js";
|
|
|
|
|
import { Scorecard, getStoreDAO } from "./models.js";
|
|
|
|
|
|
|
|
|
|
const storeDao = getStoreDAO();
|
|
|
|
|
|
|
|
|
|
export class FinskaScorecardController extends Controller {
|
2026-04-04 03:47:57 +00:00
|
|
|
static targets = ["score1Input", "score2Input", "scoreTable", "team1Header", "team2Header"];
|
2025-12-21 21:57:31 +00:00
|
|
|
static values = {
|
|
|
|
|
maxScore: Number,
|
|
|
|
|
overflowScoreTo: Number
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
connect() {
|
|
|
|
|
let rules = {
|
|
|
|
|
maxScore: Math.floor(this.maxScoreValue),
|
|
|
|
|
overflowScoreTo: Math.floor(this.overflowScoreToValue)
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this._undoStack = [];
|
|
|
|
|
this._scorecard = storeDao.loadOrCreate(rules);
|
2026-04-04 03:47:57 +00:00
|
|
|
this._loadTeamNames();
|
2025-12-21 21:57:31 +00:00
|
|
|
this.updateTable();
|
|
|
|
|
}
|
2026-04-04 03:47:57 +00:00
|
|
|
|
|
|
|
|
_loadTeamNames() {
|
|
|
|
|
const stored1 = localStorage.getItem('finska-team1-name');
|
|
|
|
|
const stored2 = localStorage.getItem('finska-team2-name');
|
|
|
|
|
this._team1Name = stored1 || 'Team A';
|
|
|
|
|
this._team2Name = stored2 || 'Team B';
|
|
|
|
|
this._renderTeamHeader(this.team1HeaderTarget, this._team1Name, 'editTeam1');
|
|
|
|
|
this._renderTeamHeader(this.team2HeaderTarget, this._team2Name, 'editTeam2');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_renderTeamHeader(td, name, editAction) {
|
|
|
|
|
td.innerHTML = `<span class="team-header">
|
|
|
|
|
<span class="team-name">${this._escapeHtml(name)}</span>
|
|
|
|
|
<button class="edit-btn" data-action="finska-scorecard#${editAction}" aria-label="Edit ${this._escapeHtml(name)} name">✎</button>
|
|
|
|
|
</span>`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_renderEditHeader(td, currentName, teamNum) {
|
|
|
|
|
td.innerHTML = `<span class="team-header-edit">
|
|
|
|
|
<input type="text" value="${this._escapeAttr(currentName)}" data-action="keydown->finska-scorecard#editKeyDown${teamNum}">
|
|
|
|
|
<button class="confirm-btn" data-action="finska-scorecard#confirmTeam${teamNum}" aria-label="Confirm">✓</button>
|
|
|
|
|
<button class="cancel-btn" data-action="finska-scorecard#cancelTeam${teamNum}" aria-label="Cancel">✗</button>
|
|
|
|
|
</span>`;
|
|
|
|
|
td.querySelector('input').focus();
|
|
|
|
|
td.querySelector('input').select();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
editTeam1() {
|
|
|
|
|
this._renderEditHeader(this.team1HeaderTarget, this._team1Name, '1');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
editTeam2() {
|
|
|
|
|
this._renderEditHeader(this.team2HeaderTarget, this._team2Name, '2');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
confirmTeam1() {
|
|
|
|
|
const input = this.team1HeaderTarget.querySelector('input');
|
|
|
|
|
const newName = input.value.trim() || 'Team A';
|
|
|
|
|
this._team1Name = newName;
|
|
|
|
|
localStorage.setItem('finska-team1-name', newName);
|
|
|
|
|
this._renderTeamHeader(this.team1HeaderTarget, this._team1Name, 'editTeam1');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
confirmTeam2() {
|
|
|
|
|
const input = this.team2HeaderTarget.querySelector('input');
|
|
|
|
|
const newName = input.value.trim() || 'Team B';
|
|
|
|
|
this._team2Name = newName;
|
|
|
|
|
localStorage.setItem('finska-team2-name', newName);
|
|
|
|
|
this._renderTeamHeader(this.team2HeaderTarget, this._team2Name, 'editTeam2');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cancelTeam1() {
|
|
|
|
|
this._renderTeamHeader(this.team1HeaderTarget, this._team1Name, 'editTeam1');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cancelTeam2() {
|
|
|
|
|
this._renderTeamHeader(this.team2HeaderTarget, this._team2Name, 'editTeam2');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
editKeyDown1(e) {
|
|
|
|
|
this._editKeyDown(e, this.confirmTeam1.bind(this), this.cancelTeam1.bind(this));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
editKeyDown2(e) {
|
|
|
|
|
this._editKeyDown(e, this.confirmTeam2.bind(this), this.cancelTeam2.bind(this));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_editKeyDown(e, confirmFn, cancelFn) {
|
|
|
|
|
if (e.key === 'Enter') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
confirmFn();
|
|
|
|
|
} else if (e.key === 'Escape') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
cancelFn();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_escapeHtml(str) {
|
|
|
|
|
const div = document.createElement('div');
|
|
|
|
|
div.textContent = str;
|
|
|
|
|
return div.innerHTML;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_escapeAttr(str) {
|
|
|
|
|
return str.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
|
|
|
}
|
2025-12-21 21:57:31 +00:00
|
|
|
|
|
|
|
|
updateTable() {
|
|
|
|
|
let tableBody = this.scoreTableTarget;
|
|
|
|
|
let tableRows = tableBody.querySelectorAll("tr");
|
|
|
|
|
let pairs = this._scorecard.pairs();
|
|
|
|
|
|
|
|
|
|
for (let pairIndex = 0; pairIndex < pairs.length; pairIndex++) {
|
|
|
|
|
let tableRow;
|
|
|
|
|
if (pairIndex >= tableRows.length) {
|
|
|
|
|
tableRow = this._appendRow();
|
|
|
|
|
} else {
|
|
|
|
|
tableRow = tableRows[pairIndex];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._updateRow(tableRow, pairs[pairIndex])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove any extra rows
|
|
|
|
|
for (let i = pairs.length; i < tableRows.length; i++) {
|
|
|
|
|
tableBody.removeChild(tableRows[i]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(JSON.stringify(this._scorecard.toJson()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_updateRow(tableRow, pair) {
|
|
|
|
|
let tds = tableRow.querySelectorAll("td");
|
|
|
|
|
|
|
|
|
|
this._updateCell(pair.p1, tds[0], tds[1]);
|
|
|
|
|
this._updateCell(pair.p2, tds[2], tds[3]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_updateCell(score, scoreCell, totalCell) {
|
|
|
|
|
scoreCell.classList.value = "";
|
|
|
|
|
totalCell.classList.value = "";
|
|
|
|
|
|
|
|
|
|
if (score != null) {
|
|
|
|
|
scoreCell.textContent = score.score;
|
|
|
|
|
totalCell.textContent = score.total;
|
|
|
|
|
|
|
|
|
|
if (score.score === 0) {
|
|
|
|
|
scoreCell.classList.add("score-foul");
|
|
|
|
|
totalCell.classList.add("score-foul");
|
|
|
|
|
} else if (score.wasOverflow) {
|
|
|
|
|
scoreCell.classList.add("score-overflow");
|
|
|
|
|
totalCell.classList.add("score-overflow");
|
|
|
|
|
} else if (score.wasWin) {
|
|
|
|
|
scoreCell.classList.add("score-win");
|
|
|
|
|
totalCell.classList.add("score-win");
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
scoreCell.textContent = "";
|
|
|
|
|
totalCell.textContent = "";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_appendRow() {
|
|
|
|
|
let newRow = document.createElement("tr");
|
|
|
|
|
newRow.classList.add("score-entry");
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < 4; i++) {
|
|
|
|
|
newRow.appendChild(document.createElement("td"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.scoreTableTarget.appendChild(newRow);
|
|
|
|
|
return newRow;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addScore1() {
|
|
|
|
|
this._addScore(this.score1InputTarget, this.score2InputTarget,
|
|
|
|
|
this._scorecard.addPlayer1Score.bind(this._scorecard),
|
|
|
|
|
this._scorecard.removeLastPlayer1Score.bind(this._scorecard));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addScore2() {
|
|
|
|
|
this._addScore(this.score2InputTarget, this.score1InputTarget,
|
|
|
|
|
this._scorecard.addPlayer2Score.bind(this._scorecard),
|
|
|
|
|
this._scorecard.removeLastPlayer2Score.bind(this._scorecard));
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_addScore(inputElem, focusToInputElem, addScoreFn, queueUndoFn) {
|
|
|
|
|
let score = parseInt(inputElem.value);
|
|
|
|
|
if (isNaN(score)) {
|
|
|
|
|
score = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
addScoreFn(score);
|
|
|
|
|
this._undoStack.push(queueUndoFn);
|
|
|
|
|
|
|
|
|
|
inputElem.value = "";
|
|
|
|
|
|
|
|
|
|
storeDao.save(this._scorecard);
|
|
|
|
|
this.updateTable();
|
|
|
|
|
|
|
|
|
|
focusToInputElem.focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
score1KeyDown(e) {
|
|
|
|
|
this._handleKeyDown(e, this.addScore1.bind(this));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
score2KeyDown(e) {
|
|
|
|
|
this._handleKeyDown(e, this.addScore2.bind(this));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_handleKeyDown(e, addScoreFn) {
|
|
|
|
|
if (e.key === "Enter") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
addScoreFn();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
undoLast() {
|
|
|
|
|
if (this._undoStack.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!confirm("Really undo last move?")) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
(this._undoStack.pop())();
|
|
|
|
|
|
|
|
|
|
storeDao.save(this._scorecard);
|
|
|
|
|
this.updateTable();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resetAll() {
|
|
|
|
|
if (!confirm("Really reset?")) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this._scorecard.reset();
|
|
|
|
|
storeDao.clear();
|
|
|
|
|
this._undoStack = [];
|
2026-04-04 03:47:57 +00:00
|
|
|
this._team1Name = 'Team A';
|
|
|
|
|
this._team2Name = 'Team B';
|
|
|
|
|
localStorage.removeItem('finska-team1-name');
|
|
|
|
|
localStorage.removeItem('finska-team2-name');
|
|
|
|
|
this._renderTeamHeader(this.team1HeaderTarget, this._team1Name, 'editTeam1');
|
|
|
|
|
this._renderTeamHeader(this.team2HeaderTarget, this._team2Name, 'editTeam2');
|
2025-12-21 21:57:31 +00:00
|
|
|
this.updateTable();
|
|
|
|
|
}
|
|
|
|
|
}
|