From 125bb6c367c6eb991e859008199c84e0f28afcdf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:39:00 +0000 Subject: [PATCH 1/4] Initial plan From 796f377e77edc0f469503e035c33979dbee8e771 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:46:55 +0000 Subject: [PATCH 2/4] feat: add Digital Jass Scoreboard (Schiefertafel Z/Z) app Co-authored-by: LFDave <36726874+LFDave@users.noreply.github.com> --- README.md | 14 + jass-scoreboard/README.md | 74 +++++ jass-scoreboard/css/styles.css | 534 +++++++++++++++++++++++++++++++++ jass-scoreboard/index.html | 124 ++++++++ jass-scoreboard/js/app.js | 35 +++ jass-scoreboard/js/renderer.js | 114 +++++++ jass-scoreboard/js/scoring.js | 55 ++++ jass-scoreboard/js/state.js | 63 ++++ jass-scoreboard/js/storage.js | 41 +++ jass-scoreboard/js/ui.js | 169 +++++++++++ 10 files changed, 1223 insertions(+) create mode 100644 jass-scoreboard/README.md create mode 100644 jass-scoreboard/css/styles.css create mode 100644 jass-scoreboard/index.html create mode 100644 jass-scoreboard/js/app.js create mode 100644 jass-scoreboard/js/renderer.js create mode 100644 jass-scoreboard/js/scoring.js create mode 100644 jass-scoreboard/js/state.js create mode 100644 jass-scoreboard/js/storage.js create mode 100644 jass-scoreboard/js/ui.js diff --git a/README.md b/README.md index 2dd86b3..d0701ae 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,20 @@ small apps for various use cases ## Apps Available +### πŸƒ Jass Scoreboard (Schiefertafel Z/Z) +A browser-based digital Jass scoreboard that replicates a traditional Swiss chalk slate board with the classic Z/Z layout. + +**Features:** +- Classic Z/Z slate board look with chalk tally marks (e.g. `||||\ ||||\ |`) +- Two teams, editable names and target score (default 2500) +- Win detection with animated overlay (gold glow, chalk particles) +- Undo last entry, reset game, flip board orientation +- Game state persisted to localStorage β€” resume on page reload +- Responsive: works on mobile (320px+), tablet, and desktop +- No external frameworks β€” pure HTML, CSS, Vanilla JS + +**Play:** [jass-scoreboard/index.html](jass-scoreboard/index.html) + ### 🌍 GeoTriad - Geography Quiz Game A fun, educational geography quiz game for kids around 10 years old! diff --git a/jass-scoreboard/README.md b/jass-scoreboard/README.md new file mode 100644 index 0000000..87e9b6b --- /dev/null +++ b/jass-scoreboard/README.md @@ -0,0 +1,74 @@ +# Jass Scoreboard β€” Schiefertafel Z/Z + +A browser-based digital Jass scoreboard that replicates the look and feel of a traditional Swiss chalk slate board (Jasstafeln) with the classic Z/Z layout. + +## Features + +- 🎯 **Z/Z Board Layout** β€” Visual division of two mirrored team areas with red chalk guide lines +- πŸ“Š **Tally Mark Display** β€” Points shown as chalk tally marks (`||||\ ||||\ |`) instead of numbers +- πŸ”’ **Numeric Totals** β€” Running totals displayed at the bottom of each panel +- πŸ† **Win Detection** β€” Animated win overlay when a team exceeds the target score +- ↩ **Undo** β€” Remove the last recorded entry +- πŸ”„ **Reset** β€” Clear all scores while keeping team names and target score +- ⇄ **Flip Board** β€” Swap panel positions for players on opposite sides of the table +- πŸ’Ύ **Persistence** β€” Game state saved to `localStorage` and restored on page reload +- πŸ“± **Responsive** β€” Works on mobile (320px+), tablet, and desktop + +## Usage + +1. Open `index.html` in any modern browser (requires ES module support) +2. Edit team names by clicking on the name fields at the top +3. Set your target score (default: 2500) +4. Select a team, enter points (1–500), and click **Eintragen** +5. Use **↩ RΓΌckgΓ€ngig** to undo the last entry +6. Use **πŸ”„ Neu** to reset the game +7. Use **⇄** to flip the board orientation + +## Project Structure + +``` +jass-scoreboard/ +β”œβ”€β”€ index.html # Root HTML structure +β”œβ”€β”€ css/ +β”‚ └── styles.css # Chalk board styling, Z/Z grid, animations +β”œβ”€β”€ js/ +β”‚ β”œβ”€β”€ app.js # Application bootstrap +β”‚ β”œβ”€β”€ state.js # Global state definition and mutations +β”‚ β”œβ”€β”€ storage.js # localStorage persistence +β”‚ β”œβ”€β”€ scoring.js # Score entry, totals, win check +β”‚ β”œβ”€β”€ renderer.js # Board, tallies, totals, win state rendering +β”‚ └── ui.js # Event binding and input validation +β”œβ”€β”€ assets/ +β”‚ β”œβ”€β”€ textures/ # (placeholder for optional slate texture) +β”‚ └── fonts/ # (placeholder for optional chalk fonts) +└── README.md +``` + +## Technologies + +- HTML5 +- CSS3 +- Vanilla JavaScript (ES6 modules) + +No external frameworks or libraries are used. + +## Game Rules + +- Two teams record scores round by round +- A team wins when its total **exceeds** the target score +- Default target: **2500 points** +- Valid point entries: integers from 1 to 500 +- After a win, score entry is disabled but undo remains available + +## Tally Mark Rendering + +Points are displayed as tally marks: + +| Points | Display | +|--------|-----------------------| +| 3 | `\| \| \|` | +| 5 | `\|\|\|\|\ ` | +| 7 | `\|\|\|\|\ \| \|` | +| 10 | `\|\|\|\|\ \|\|\|\|\ ` | + +Groups of 5 are rendered as `||||\ ` and remainders as `| `. diff --git a/jass-scoreboard/css/styles.css b/jass-scoreboard/css/styles.css new file mode 100644 index 0000000..5b9d632 --- /dev/null +++ b/jass-scoreboard/css/styles.css @@ -0,0 +1,534 @@ +/* styles.css β€” Jass Scoreboard chalk slate styling */ + +/* ─── Reset & Base ────────────────────────────────────────────── */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --slate-bg: #1a2a1a; + --slate-surface: #1e321e; + --chalk-white: #f0ede0; + --chalk-dim: #c8c4b0; + --chalk-red: #e05050; + --panel-border: rgba(255, 255, 255, 0.12); + --input-bg: #243224; + --input-border: rgba(240, 237, 224, 0.3); + --btn-primary: #4a7c59; + --btn-primary-h: #5a9c6e; + --btn-secondary: #3a5a4a; + --btn-danger: #7a3535; + --btn-danger-h: #9a4545; + --winner-glow: #ffd700; + --font-main: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + --radius: 6px; +} + +html, body { + height: 100%; +} + +body { + background-color: var(--slate-bg); + background-image: + radial-gradient(ellipse at 20% 30%, rgba(255,255,255,0.02) 0%, transparent 60%), + radial-gradient(ellipse at 80% 70%, rgba(255,255,255,0.015) 0%, transparent 60%); + color: var(--chalk-white); + font-family: var(--font-main); + min-height: 100vh; + min-width: 320px; + display: flex; + flex-direction: column; + align-items: center; +} + +/* ─── Screen-reader only utility ─────────────────────────────── */ +.sr-only { + position: absolute; + width: 1px; height: 1px; + padding: 0; margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; +} + +/* ─── App wrapper ─────────────────────────────────────────────── */ +.app-wrapper { + width: 100%; + max-width: 760px; + padding: 12px 12px 24px; + display: flex; + flex-direction: column; + gap: 12px; +} + +/* ─── Header ──────────────────────────────────────────────────── */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 4px; + border-bottom: 2px solid var(--chalk-red); +} + +.header-title { + font-size: 1.6rem; + font-weight: 700; + letter-spacing: 0.08em; + color: var(--chalk-white); + text-shadow: 0 0 12px rgba(240,237,224,0.35); +} + +.header-settings { + display: flex; + align-items: center; + gap: 8px; +} + +.setting-label { + font-size: 0.85rem; + color: var(--chalk-dim); +} + +.setting-input { + width: 80px; + padding: 4px 8px; + background: var(--input-bg); + border: 1px solid var(--input-border); + border-radius: var(--radius); + color: var(--chalk-white); + font-size: 0.9rem; + text-align: center; +} + +.setting-input:focus { + outline: 2px solid var(--chalk-dim); + outline-offset: 1px; +} + +/* ─── Name editors ────────────────────────────────────────────── */ +.name-editors { + display: flex; + align-items: center; + gap: 8px; +} + +.name-editor-group { + flex: 1; +} + +.team-name-input { + width: 100%; + padding: 6px 10px; + background: transparent; + border: none; + border-bottom: 1px dashed rgba(240,237,224,0.3); + color: var(--chalk-white); + font-size: 1rem; + font-weight: 600; + text-align: center; + letter-spacing: 0.04em; + transition: border-color 0.2s; +} + +.team-name-input:focus { + outline: none; + border-bottom-color: var(--chalk-white); +} + +.name-editor-divider { + color: var(--chalk-dim); + font-size: 0.8rem; + flex-shrink: 0; +} + +/* ─── Board (Z/Z grid) ────────────────────────────────────────── */ +.board { + display: grid; + grid-template-columns: 1fr 60px 1fr; + min-height: 340px; + background: var(--slate-surface); + border: 1px solid var(--panel-border); + border-radius: var(--radius); + overflow: hidden; + /* Subtle noise texture overlay via box-shadow */ + box-shadow: + inset 0 0 60px rgba(0,0,0,0.35), + 0 4px 20px rgba(0,0,0,0.5); +} + +/* ─── Board panels ────────────────────────────────────────────── */ +.board-panel { + padding: 16px 14px 14px; + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; + transition: box-shadow 0.4s ease; +} + +.panel-left { + border-right: none; +} + +.panel-right { + border-left: none; +} + +/* Winner highlight */ +.winner-panel { + box-shadow: + inset 0 0 40px rgba(255, 215, 0, 0.18), + inset 0 0 10px rgba(255, 215, 0, 0.08); + animation: panelPulse 1.6s ease-in-out infinite alternate; +} + +@keyframes panelPulse { + from { box-shadow: inset 0 0 40px rgba(255,215,0,0.12), inset 0 0 10px rgba(255,215,0,0.06); } + to { box-shadow: inset 0 0 60px rgba(255,215,0,0.28), inset 0 0 20px rgba(255,215,0,0.14); } +} + +/* ─── Chalk text ──────────────────────────────────────────────── */ +.chalk-text { + color: var(--chalk-white); + text-rendering: geometricPrecision; +} + +/* ─── Team name inside panel ─────────────────────────────────── */ +.panel-team-name { + font-size: 1.1rem; + font-weight: 700; + letter-spacing: 0.06em; + text-align: center; + padding-bottom: 6px; + border-bottom: 1px solid rgba(224, 80, 80, 0.45); + text-shadow: 0 0 8px rgba(240,237,224,0.3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ─── Entries area (tally marks) ─────────────────────────────── */ +.entries-area { + flex: 1; + font-family: 'Courier New', Courier, monospace; + font-size: 0.95rem; + line-height: 1.7; + letter-spacing: 0.05em; + overflow-y: auto; + word-break: break-word; + min-height: 120px; + padding: 4px 2px; + color: var(--chalk-dim); +} + +.tally-row { + padding: 1px 0; + white-space: nowrap; +} + +.no-entries { + color: rgba(240,237,224,0.2); + font-size: 1.2rem; +} + +/* ─── Total row ───────────────────────────────────────────────── */ +.panel-total-row { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 8px; + border-top: 1px solid rgba(224, 80, 80, 0.45); +} + +.total-label { + font-size: 0.8rem; + color: var(--chalk-dim); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.total-value { + font-size: 1.4rem; + font-weight: 700; + letter-spacing: 0.04em; + text-shadow: 0 0 10px rgba(240,237,224,0.4); +} + +/* ─── Z/Z Divider ─────────────────────────────────────────────── */ +.board-divider { + display: flex; + align-items: stretch; + position: relative; +} + +.z-divider-svg { + width: 100%; + height: 100%; +} + +/* ─── Score input area ────────────────────────────────────────── */ +.score-input-area { + background: var(--input-bg); + border: 1px solid var(--panel-border); + border-radius: var(--radius); + padding: 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.score-input-area.disabled { + opacity: 0.55; + pointer-events: none; +} + +.input-row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.select-team { + flex: 1; + min-width: 100px; + padding: 10px 10px; + background: var(--slate-bg); + border: 1px solid var(--input-border); + border-radius: var(--radius); + color: var(--chalk-white); + font-size: 0.95rem; + cursor: pointer; +} + +.select-team:focus { + outline: 2px solid var(--chalk-dim); + outline-offset: 1px; +} + +.points-input { + flex: 2; + min-width: 80px; + padding: 10px 12px; + background: var(--slate-bg); + border: 1px solid var(--input-border); + border-radius: var(--radius); + color: var(--chalk-white); + font-size: 1rem; + text-align: center; + transition: border-color 0.2s; +} + +.points-input:focus { + outline: none; + border-color: var(--chalk-white); +} + +/* ─── Buttons ─────────────────────────────────────────────────── */ +.btn { + padding: 10px 18px; + border: none; + border-radius: var(--radius); + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: background 0.18s, transform 0.1s; + white-space: nowrap; +} + +.btn:active { + transform: scale(0.97); +} + +.btn:focus-visible { + outline: 2px solid var(--chalk-white); + outline-offset: 2px; +} + +.btn-primary { + background: var(--btn-primary); + color: #fff; + flex-shrink: 0; +} + +.btn-primary:hover { + background: var(--btn-primary-h); +} + +.btn-secondary { + background: var(--btn-secondary); + color: var(--chalk-white); +} + +.btn-secondary:hover { + background: #4a7a6a; +} + +.btn-danger { + background: var(--btn-danger); + color: var(--chalk-white); +} + +.btn-danger:hover { + background: var(--btn-danger-h); +} + +.btn-icon { + background: transparent; + border: 1px solid var(--input-border); + color: var(--chalk-white); + padding: 6px 12px; + font-size: 1.1rem; +} + +.btn-icon:hover { + background: rgba(255,255,255,0.08); +} + +/* ─── Action row ──────────────────────────────────────────────── */ +.action-row { + display: flex; + gap: 8px; + justify-content: flex-end; + flex-wrap: wrap; +} + +/* ─── Error message ───────────────────────────────────────────── */ +.error-msg { + min-height: 1.2em; + font-size: 0.85rem; + color: #ff8080; + opacity: 0; + transition: opacity 0.25s; + text-align: center; +} + +.error-msg.visible { + opacity: 1; +} + +/* ─── Win overlay ─────────────────────────────────────────────── */ +#win-overlay { + position: fixed; + inset: 0; + background: rgba(10, 25, 10, 0.88); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + opacity: 0; + pointer-events: none; + transition: opacity 0.4s ease; + backdrop-filter: blur(3px); +} + +#win-overlay.active { + opacity: 1; + pointer-events: all; +} + +.win-content { + text-align: center; + animation: winPop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both; +} + +@keyframes winPop { + from { transform: scale(0.5); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} + +.win-icon { + font-size: 4rem; + margin-bottom: 12px; + animation: winSpin 0.8s ease-out; +} + +@keyframes winSpin { + from { transform: rotate(-20deg) scale(0.6); } + to { transform: rotate(0deg) scale(1); } +} + +#win-overlay h2 { + font-size: 2.8rem; + color: var(--winner-glow); + text-shadow: 0 0 24px rgba(255,215,0,0.7); + margin-bottom: 10px; + letter-spacing: 0.1em; +} + +.win-team { + font-size: 1.8rem; + color: var(--chalk-white); + text-shadow: 0 0 16px rgba(240,237,224,0.6); + margin-bottom: 16px; +} + +.win-hint { + font-size: 0.8rem; + color: var(--chalk-dim); + opacity: 0.7; + margin-top: 18px; +} + +/* ─── Chalk dust particles ────────────────────────────────────── */ +.chalk-particles { + position: absolute; + inset: 0; + pointer-events: none; + overflow: hidden; +} + +.chalk-particles span { + position: absolute; + display: block; + width: 6px; + height: 6px; + background: rgba(240, 237, 224, 0.6); + border-radius: 50%; + animation: float 3s ease-in-out infinite; +} + +.chalk-particles span:nth-child(1) { left: 10%; top: 20%; animation-delay: 0s; animation-duration: 2.8s; width: 4px; height: 4px; } +.chalk-particles span:nth-child(2) { left: 25%; top: 60%; animation-delay: 0.4s; animation-duration: 3.2s; } +.chalk-particles span:nth-child(3) { left: 45%; top: 15%; animation-delay: 0.8s; animation-duration: 2.5s; width: 8px; height: 8px; } +.chalk-particles span:nth-child(4) { left: 65%; top: 70%; animation-delay: 1.2s; animation-duration: 3.6s; width: 5px; height: 5px; } +.chalk-particles span:nth-child(5) { left: 80%; top: 30%; animation-delay: 0.2s; animation-duration: 2.9s; } +.chalk-particles span:nth-child(6) { left: 90%; top: 80%; animation-delay: 1.6s; animation-duration: 3.4s; width: 3px; height: 3px; } +.chalk-particles span:nth-child(7) { left: 55%; top: 45%; animation-delay: 0.6s; animation-duration: 2.6s; width: 7px; height: 7px; } +.chalk-particles span:nth-child(8) { left: 35%; top: 85%; animation-delay: 1.0s; animation-duration: 3.0s; } +.chalk-particles span:nth-child(9) { left: 72%; top: 10%; animation-delay: 1.4s; animation-duration: 2.7s; width: 5px; height: 5px; } + +@keyframes float { + 0% { transform: translateY(0) rotate(0deg); opacity: 0.8; } + 50% { transform: translateY(-30px) rotate(180deg); opacity: 0.4; } + 100% { transform: translateY(-60px) rotate(360deg); opacity: 0; } +} + +/* ─── Responsive ──────────────────────────────────────────────── */ +@media (max-width: 480px) { + .app-wrapper { padding: 8px 8px 20px; } + .header-title { font-size: 1.3rem; } + .board { grid-template-columns: 1fr 44px 1fr; } + .panel-team-name { font-size: 0.95rem; } + .entries-area { font-size: 0.82rem; } + .total-value { font-size: 1.15rem; } + .input-row { flex-direction: column; } + .select-team, .points-input, .btn-primary { width: 100%; } + #win-overlay h2 { font-size: 2rem; } + .win-team { font-size: 1.3rem; } +} + +@media (min-width: 600px) { + .board { min-height: 400px; } + .entries-area { font-size: 1rem; } +} + +/* ─── Scrollbar styling ───────────────────────────────────────── */ +.entries-area::-webkit-scrollbar { width: 4px; } +.entries-area::-webkit-scrollbar-track { background: transparent; } +.entries-area::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; } diff --git a/jass-scoreboard/index.html b/jass-scoreboard/index.html new file mode 100644 index 0000000..250f723 --- /dev/null +++ b/jass-scoreboard/index.html @@ -0,0 +1,124 @@ + + + + + + Jass Scoreboard β€” Schiefertafel Z/Z + + + + + + +
+ +
+

Jass Tafel

+
+ + + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+ +
+

+
+
+ Total: + 0 +
+
+ + + + + +
+

+
+
+ Total: + 0 +
+
+
+ + +
+ +
+ + + + + + + +
+ +
+ + +
+
+
+ + + + diff --git a/jass-scoreboard/js/app.js b/jass-scoreboard/js/app.js new file mode 100644 index 0000000..e83dc97 --- /dev/null +++ b/jass-scoreboard/js/app.js @@ -0,0 +1,35 @@ +// app.js β€” application bootstrap + +import { getState, setState } from "./state.js"; +import { loadState } from "./storage.js"; +import { recalcTotals, checkWinner } from "./scoring.js"; +import { render } from "./renderer.js"; +import { bindEvents, initInputValues } from "./ui.js"; + +function bootstrap() { + // Attempt to restore persisted state + const saved = loadState(); + if (saved) { + // Merge saved data into state carefully + const current = getState(); + const restored = { + teams: saved.teams || current.teams, + entries: saved.entries || [], + totals: { A: 0, B: 0 }, + targetScore: + typeof saved.targetScore === "number" ? saved.targetScore : current.targetScore, + flipped: typeof saved.flipped === "boolean" ? saved.flipped : false, + winner: saved.winner || null, + gameFinished: typeof saved.gameFinished === "boolean" ? saved.gameFinished : false + }; + setState(restored); + recalcTotals(); + checkWinner(); + } + + initInputValues(); + bindEvents(); + render(); +} + +document.addEventListener("DOMContentLoaded", bootstrap); diff --git a/jass-scoreboard/js/renderer.js b/jass-scoreboard/js/renderer.js new file mode 100644 index 0000000..de79c2c --- /dev/null +++ b/jass-scoreboard/js/renderer.js @@ -0,0 +1,114 @@ +// renderer.js β€” render board, tallies, totals, win state + +import { getState } from "./state.js"; + +/** + * Converts a point value into tally mark strings. + * Groups of 5 become "||||\" and remainders are "|". + */ +function renderTallies(points) { + const groups = Math.floor(points / 5); + const remainder = points % 5; + let result = ""; + for (let i = 0; i < groups; i++) { + result += "||||\\ "; + } + for (let i = 0; i < remainder; i++) { + result += "| "; + } + return result.trim(); +} + +/** + * Build the HTML for one team's entry list. + */ +function buildEntriesHTML(teamId) { + const state = getState(); + const teamEntries = state.entries.filter(e => e.teamId === teamId); + if (teamEntries.length === 0) { + return 'β€”'; + } + return teamEntries + .map(e => `
${renderTallies(e.points)}
`) + .join(""); +} + +/** + * Full render of the scoreboard. + */ +function render() { + const state = getState(); + + const [leftTeam, rightTeam] = state.flipped + ? [state.teams[1], state.teams[0]] + : [state.teams[0], state.teams[1]]; + + // Team name headers + document.getElementById("team-a-name").textContent = leftTeam.name; + document.getElementById("team-b-name").textContent = rightTeam.name; + + // Entries (tally marks) + document.getElementById("entries-a").innerHTML = buildEntriesHTML(leftTeam.id); + document.getElementById("entries-b").innerHTML = buildEntriesHTML(rightTeam.id); + + // Totals + document.getElementById("total-a").textContent = state.totals[leftTeam.id] || 0; + document.getElementById("total-b").textContent = state.totals[rightTeam.id] || 0; + + // Target score display + const targetEl = document.getElementById("target-score-display"); + if (targetEl) targetEl.textContent = state.targetScore; + + // Win state + renderWinState(leftTeam, rightTeam); + + // Update team select options to reflect current names + const teamSelect = document.getElementById("select-team"); + if (teamSelect) { + for (const opt of teamSelect.options) { + const team = state.teams.find(t => t.id === opt.value); + if (team) opt.textContent = team.name; + } + } + + // Input area enable/disable (undo and reset always stay enabled) + const inputArea = document.getElementById("score-input-area"); + if (inputArea) { + inputArea.classList.toggle("disabled", state.gameFinished); + const inputs = inputArea.querySelectorAll("input, select"); + inputs.forEach(el => { el.disabled = state.gameFinished; }); + const addBtn = document.getElementById("btn-add"); + if (addBtn) addBtn.disabled = state.gameFinished; + } + + // Highlight winning panel + const panelA = document.getElementById("panel-a"); + const panelB = document.getElementById("panel-b"); + if (panelA && panelB) { + panelA.classList.remove("winner-panel"); + panelB.classList.remove("winner-panel"); + if (state.gameFinished && state.winner) { + const winnerId = state.winner; + const winnerIsLeft = leftTeam.id === winnerId; + if (winnerIsLeft) panelA.classList.add("winner-panel"); + else panelB.classList.add("winner-panel"); + } + } +} + +function renderWinState(leftTeam, rightTeam) { + const state = getState(); + const overlay = document.getElementById("win-overlay"); + if (!overlay) return; + + if (state.gameFinished && state.winner) { + const winningTeam = + state.winner === leftTeam.id ? leftTeam : rightTeam; + document.getElementById("win-team-name").textContent = winningTeam.name; + overlay.classList.add("active"); + } else { + overlay.classList.remove("active"); + } +} + +export { render, renderTallies }; diff --git a/jass-scoreboard/js/scoring.js b/jass-scoreboard/js/scoring.js new file mode 100644 index 0000000..1cb5451 --- /dev/null +++ b/jass-scoreboard/js/scoring.js @@ -0,0 +1,55 @@ +// scoring.js β€” add score, calculate totals, check winner + +import { getState } from "./state.js"; + +function addEntry(teamId, points) { + const state = getState(); + if (state.gameFinished) return false; + + const p = parseInt(points, 10); + if (isNaN(p) || p <= 0 || p > 500) return false; + if (!state.teams.find(t => t.id === teamId)) return false; + + state.entries.push({ + teamId, + points: p, + timestamp: Date.now() + }); + + recalcTotals(); + checkWinner(); + return true; +} + +function undoLastEntry() { + const state = getState(); + if (state.entries.length === 0) return false; + state.entries.pop(); + state.winner = null; + state.gameFinished = false; + recalcTotals(); + checkWinner(); + return true; +} + +function recalcTotals() { + const state = getState(); + state.totals = { A: 0, B: 0 }; + for (const entry of state.entries) { + state.totals[entry.teamId] = (state.totals[entry.teamId] || 0) + entry.points; + } +} + +function checkWinner() { + const state = getState(); + if (state.gameFinished) return; + for (const team of state.teams) { + if ((state.totals[team.id] || 0) > state.targetScore) { + state.winner = team.id; + state.gameFinished = true; + return; + } + } +} + +export { addEntry, undoLastEntry, recalcTotals, checkWinner }; diff --git a/jass-scoreboard/js/state.js b/jass-scoreboard/js/state.js new file mode 100644 index 0000000..758b6cd --- /dev/null +++ b/jass-scoreboard/js/state.js @@ -0,0 +1,63 @@ +// state.js β€” global state definition and mutations + +const DEFAULT_STATE = { + teams: [ + { id: "A", name: "Team A" }, + { id: "B", name: "Team B" } + ], + entries: [], + totals: { + A: 0, + B: 0 + }, + targetScore: 2500, + flipped: false, + winner: null, + gameFinished: false +}; + +let state = JSON.parse(JSON.stringify(DEFAULT_STATE)); + +function getState() { + return state; +} + +function setState(newState) { + state = newState; +} + +function resetState() { + const preserved = { + teams: state.teams.map(t => ({ ...t })), + targetScore: state.targetScore, + flipped: state.flipped + }; + state = { + ...JSON.parse(JSON.stringify(DEFAULT_STATE)), + teams: preserved.teams, + targetScore: preserved.targetScore, + flipped: preserved.flipped + }; +} + +function setTeamName(teamId, name) { + const trimmed = name.trim(); + if (trimmed.length < 1 || trimmed.length > 30) return false; + const team = state.teams.find(t => t.id === teamId); + if (!team) return false; + team.name = trimmed; + return true; +} + +function setTargetScore(value) { + const n = parseInt(value, 10); + if (isNaN(n) || n < 100 || n > 10000) return false; + state.targetScore = n; + return true; +} + +function flipBoard() { + state.flipped = !state.flipped; +} + +export { getState, setState, resetState, setTeamName, setTargetScore, flipBoard }; diff --git a/jass-scoreboard/js/storage.js b/jass-scoreboard/js/storage.js new file mode 100644 index 0000000..baa8857 --- /dev/null +++ b/jass-scoreboard/js/storage.js @@ -0,0 +1,41 @@ +// storage.js β€” localStorage persistence + +const STORAGE_KEY = "jassScoreboardState"; + +function saveState(state) { + const toSave = { + teams: state.teams, + entries: state.entries, + targetScore: state.targetScore, + flipped: state.flipped, + winner: state.winner, + gameFinished: state.gameFinished + }; + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)); + } catch (e) { + // storage may be unavailable (e.g. private mode quota) + console.warn("Could not save state:", e); + } +} + +function loadState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch (e) { + console.warn("Could not load state:", e); + return null; + } +} + +function clearState() { + try { + localStorage.removeItem(STORAGE_KEY); + } catch (e) { + console.warn("Could not clear state:", e); + } +} + +export { saveState, loadState, clearState }; diff --git a/jass-scoreboard/js/ui.js b/jass-scoreboard/js/ui.js new file mode 100644 index 0000000..a74a883 --- /dev/null +++ b/jass-scoreboard/js/ui.js @@ -0,0 +1,169 @@ +// ui.js β€” handle user input, bind event listeners, validate inputs + +import { getState, setTeamName, setTargetScore, flipBoard, resetState } from "./state.js"; +import { addEntry, undoLastEntry } from "./scoring.js"; +import { render } from "./renderer.js"; +import { saveState } from "./storage.js"; + +let errorTimeout = null; + +function persist() { + saveState(getState()); +} + +function bindEvents() { + // Score entry form + const addBtn = document.getElementById("btn-add"); + if (addBtn) { + addBtn.addEventListener("click", handleAddScore); + } + + // Allow pressing Enter in the points input + const pointsInput = document.getElementById("input-points"); + if (pointsInput) { + pointsInput.addEventListener("keydown", e => { + if (e.key === "Enter") handleAddScore(); + }); + } + + // Undo button + const undoBtn = document.getElementById("btn-undo"); + if (undoBtn) { + undoBtn.addEventListener("click", () => { + undoLastEntry(); + render(); + persist(); + }); + } + + // Reset button + const resetBtn = document.getElementById("btn-reset"); + if (resetBtn) { + resetBtn.addEventListener("click", () => { + if (confirm("Reset the game? Team names and target score will be kept.")) { + resetState(); + render(); + persist(); + // Re-enable score input + const inputArea = document.getElementById("score-input-area"); + if (inputArea) { + const inputs = inputArea.querySelectorAll("input, button, select"); + inputs.forEach(el => { el.disabled = false; }); + inputArea.classList.remove("disabled"); + } + document.getElementById("win-overlay")?.classList.remove("active"); + } + }); + } + + // Flip button + const flipBtn = document.getElementById("btn-flip"); + if (flipBtn) { + flipBtn.addEventListener("click", () => { + flipBoard(); + render(); + persist(); + }); + } + + // Team name edits (blur to save) + const nameA = document.getElementById("edit-name-a"); + if (nameA) { + nameA.addEventListener("change", () => { + const ok = setTeamName("A", nameA.value); + if (!ok) nameA.value = getState().teams.find(t => t.id === "A").name; + render(); + persist(); + }); + } + + const nameB = document.getElementById("edit-name-b"); + if (nameB) { + nameB.addEventListener("change", () => { + const ok = setTeamName("B", nameB.value); + if (!ok) nameB.value = getState().teams.find(t => t.id === "B").name; + render(); + persist(); + }); + } + + // Target score edit + const targetInput = document.getElementById("input-target"); + if (targetInput) { + targetInput.addEventListener("change", () => { + const ok = setTargetScore(targetInput.value); + if (!ok) { + targetInput.value = getState().targetScore; + showError("Target score must be between 100 and 10000."); + } else { + render(); + persist(); + } + }); + } + + // Close win overlay (click anywhere on it) + const winOverlay = document.getElementById("win-overlay"); + if (winOverlay) { + winOverlay.addEventListener("click", () => { + winOverlay.classList.remove("active"); + }); + } +} + +function handleAddScore() { + const state = getState(); + if (state.gameFinished) return; + + const teamSelect = document.getElementById("select-team"); + const pointsInput = document.getElementById("input-points"); + + const teamId = teamSelect ? teamSelect.value : null; + const pointsRaw = pointsInput ? pointsInput.value : ""; + + if (!teamId) { + showError("Please select a team."); + return; + } + + const points = parseInt(pointsRaw, 10); + if (isNaN(points) || points <= 0 || points > 500) { + showError("Points must be a whole number between 1 and 500."); + pointsInput && pointsInput.focus(); + return; + } + + const ok = addEntry(teamId, points); + if (ok) { + if (pointsInput) { + pointsInput.value = ""; + pointsInput.focus(); + } + render(); + persist(); + } +} + +function showError(msg) { + const errEl = document.getElementById("error-msg"); + if (!errEl) return; + errEl.textContent = msg; + errEl.classList.add("visible"); + clearTimeout(errorTimeout); + errorTimeout = setTimeout(() => { + errEl.classList.remove("visible"); + }, 3000); +} + +function initInputValues() { + const state = getState(); + const nameA = document.getElementById("edit-name-a"); + const nameB = document.getElementById("edit-name-b"); + const targetInput = document.getElementById("input-target"); + + if (nameA) nameA.value = state.teams[0].name; + if (nameB) nameB.value = state.teams[1].name; + if (targetInput) targetInput.value = state.targetScore; +} + +export { bindEvents, initInputValues }; From f59767782953275974788280a0841aa159b481a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:27:45 +0000 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20Z/Z=20layout=20=E2=80=94=20two=20Z?= =?UTF-8?q?=20shapes,=20one=20per=20player=20(top/bottom)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: LFDave <36726874+LFDave@users.noreply.github.com> --- jass-scoreboard/css/styles.css | 55 ++++++++++++++++++++-------------- jass-scoreboard/index.html | 39 ++++++++++++++---------- jass-scoreboard/js/renderer.js | 24 +++++++-------- 3 files changed, 68 insertions(+), 50 deletions(-) diff --git a/jass-scoreboard/css/styles.css b/jass-scoreboard/css/styles.css index 5b9d632..5e474ab 100644 --- a/jass-scoreboard/css/styles.css +++ b/jass-scoreboard/css/styles.css @@ -146,16 +146,15 @@ body { flex-shrink: 0; } -/* ─── Board (Z/Z grid) ────────────────────────────────────────── */ +/* ─── Board (Z/Z grid β€” two stacked panels) ──────────────────── */ .board { display: grid; - grid-template-columns: 1fr 60px 1fr; - min-height: 340px; + grid-template-rows: 1fr auto 1fr; + min-height: 420px; background: var(--slate-surface); border: 1px solid var(--panel-border); border-radius: var(--radius); overflow: hidden; - /* Subtle noise texture overlay via box-shadow */ box-shadow: inset 0 0 60px rgba(0,0,0,0.35), 0 4px 20px rgba(0,0,0,0.5); @@ -171,12 +170,21 @@ body { transition: box-shadow 0.4s ease; } -.panel-left { - border-right: none; +/* Bottom panel rotated 180Β° so the opposing player can read it */ +.panel-bottom { + transform: rotate(180deg); } -.panel-right { - border-left: none; +/* Horizontal red chalk line between the two Z panels */ +.board-h-divider { + height: 3px; + background: linear-gradient( + to right, + transparent 0%, + rgba(224, 80, 80, 0.7) 15%, + rgba(224, 80, 80, 0.7) 85%, + transparent 100% + ); } /* Winner highlight */ @@ -219,11 +227,11 @@ body { font-size: 0.95rem; line-height: 1.7; letter-spacing: 0.05em; - overflow-y: auto; word-break: break-word; - min-height: 120px; - padding: 4px 2px; + padding: 6px 4px; color: var(--chalk-dim); + position: relative; + z-index: 1; } .tally-row { @@ -259,16 +267,20 @@ body { text-shadow: 0 0 10px rgba(240,237,224,0.4); } -/* ─── Z/Z Divider ─────────────────────────────────────────────── */ -.board-divider { - display: flex; - align-items: stretch; +/* ─── Z wrapper β€” contains SVG guide lines + tally entries ───── */ +.z-wrapper { position: relative; + flex: 1; + min-height: 110px; + overflow-y: auto; } -.z-divider-svg { +.z-lines { + position: absolute; + inset: 0; width: 100%; height: 100%; + pointer-events: none; } /* ─── Score input area ────────────────────────────────────────── */ @@ -513,7 +525,6 @@ body { @media (max-width: 480px) { .app-wrapper { padding: 8px 8px 20px; } .header-title { font-size: 1.3rem; } - .board { grid-template-columns: 1fr 44px 1fr; } .panel-team-name { font-size: 0.95rem; } .entries-area { font-size: 0.82rem; } .total-value { font-size: 1.15rem; } @@ -524,11 +535,11 @@ body { } @media (min-width: 600px) { - .board { min-height: 400px; } + .board { min-height: 480px; } .entries-area { font-size: 1rem; } } -/* ─── Scrollbar styling ───────────────────────────────────────── */ -.entries-area::-webkit-scrollbar { width: 4px; } -.entries-area::-webkit-scrollbar-track { background: transparent; } -.entries-area::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; } +/* ─── Scrollbar styling (z-wrapper) ──────────────────────────── */ +.z-wrapper::-webkit-scrollbar { width: 4px; } +.z-wrapper::-webkit-scrollbar-track { background: transparent; } +.z-wrapper::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; } diff --git a/jass-scoreboard/index.html b/jass-scoreboard/index.html index 250f723..3c0b6dd 100644 --- a/jass-scoreboard/index.html +++ b/jass-scoreboard/index.html @@ -37,7 +37,7 @@

Jass Tafel

step="50" aria-label="Target score" /> - + @@ -56,30 +56,37 @@

Jass Tafel

- -
+ +

-
+
+ +
+
Total: 0
- - + + - -
+ +

-
+
+ +
+
Total: 0 diff --git a/jass-scoreboard/js/renderer.js b/jass-scoreboard/js/renderer.js index de79c2c..5285ee3 100644 --- a/jass-scoreboard/js/renderer.js +++ b/jass-scoreboard/js/renderer.js @@ -39,28 +39,28 @@ function buildEntriesHTML(teamId) { function render() { const state = getState(); - const [leftTeam, rightTeam] = state.flipped + const [topTeam, bottomTeam] = state.flipped ? [state.teams[1], state.teams[0]] : [state.teams[0], state.teams[1]]; // Team name headers - document.getElementById("team-a-name").textContent = leftTeam.name; - document.getElementById("team-b-name").textContent = rightTeam.name; + document.getElementById("team-a-name").textContent = topTeam.name; + document.getElementById("team-b-name").textContent = bottomTeam.name; // Entries (tally marks) - document.getElementById("entries-a").innerHTML = buildEntriesHTML(leftTeam.id); - document.getElementById("entries-b").innerHTML = buildEntriesHTML(rightTeam.id); + document.getElementById("entries-a").innerHTML = buildEntriesHTML(topTeam.id); + document.getElementById("entries-b").innerHTML = buildEntriesHTML(bottomTeam.id); // Totals - document.getElementById("total-a").textContent = state.totals[leftTeam.id] || 0; - document.getElementById("total-b").textContent = state.totals[rightTeam.id] || 0; + document.getElementById("total-a").textContent = state.totals[topTeam.id] || 0; + document.getElementById("total-b").textContent = state.totals[bottomTeam.id] || 0; // Target score display const targetEl = document.getElementById("target-score-display"); if (targetEl) targetEl.textContent = state.targetScore; // Win state - renderWinState(leftTeam, rightTeam); + renderWinState(topTeam, bottomTeam); // Update team select options to reflect current names const teamSelect = document.getElementById("select-team"); @@ -89,21 +89,21 @@ function render() { panelB.classList.remove("winner-panel"); if (state.gameFinished && state.winner) { const winnerId = state.winner; - const winnerIsLeft = leftTeam.id === winnerId; - if (winnerIsLeft) panelA.classList.add("winner-panel"); + const winnerIsTop = topTeam.id === winnerId; + if (winnerIsTop) panelA.classList.add("winner-panel"); else panelB.classList.add("winner-panel"); } } } -function renderWinState(leftTeam, rightTeam) { +function renderWinState(topTeam, bottomTeam) { const state = getState(); const overlay = document.getElementById("win-overlay"); if (!overlay) return; if (state.gameFinished && state.winner) { const winningTeam = - state.winner === leftTeam.id ? leftTeam : rightTeam; + state.winner === topTeam.id ? topTeam : bottomTeam; document.getElementById("win-team-name").textContent = winningTeam.name; overlay.classList.add("active"); } else { From 5c01474d9b14b89d1adbd55f9e56d8d0a4d003bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 14 Mar 2026 21:54:41 +0000 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20SVG=20Z=20marks=20on=20lines=20?= =?UTF-8?q?=E2=80=94=20top=3D100,=20diagonal=3D50,=20bottom=3D20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: LFDave <36726874+LFDave@users.noreply.github.com> --- jass-scoreboard/css/styles.css | 126 +++++++++++++++++------------- jass-scoreboard/index.html | 55 +++++--------- jass-scoreboard/js/renderer.js | 135 ++++++++++++++++++++++----------- jass-scoreboard/js/scoring.js | 16 ++-- jass-scoreboard/js/ui.js | 83 +++++++++----------- 5 files changed, 231 insertions(+), 184 deletions(-) diff --git a/jass-scoreboard/css/styles.css b/jass-scoreboard/css/styles.css index 5e474ab..a57d9b5 100644 --- a/jass-scoreboard/css/styles.css +++ b/jass-scoreboard/css/styles.css @@ -220,28 +220,17 @@ body { white-space: nowrap; } -/* ─── Entries area (tally marks) ─────────────────────────────── */ -.entries-area { +/* ─── Z SVG area (filled by renderer.js) ─────────────────────── */ +.z-svg-area { flex: 1; - font-family: 'Courier New', Courier, monospace; - font-size: 0.95rem; - line-height: 1.7; - letter-spacing: 0.05em; - word-break: break-word; - padding: 6px 4px; - color: var(--chalk-dim); - position: relative; - z-index: 1; -} - -.tally-row { - padding: 1px 0; - white-space: nowrap; + min-height: 110px; + overflow: hidden; } -.no-entries { - color: rgba(240,237,224,0.2); - font-size: 1.2rem; +.z-svg-area svg { + display: block; + width: 100%; + height: 100%; } /* ─── Total row ───────────────────────────────────────────────── */ @@ -282,7 +271,6 @@ body { height: 100%; pointer-events: none; } - /* ─── Score input area ────────────────────────────────────────── */ .score-input-area { background: var(--input-bg); @@ -299,46 +287,88 @@ body { pointer-events: none; } -.input-row { +/* ─── Team toggle row ─────────────────────────────────────────── */ +.team-toggle-row { display: flex; gap: 8px; - align-items: center; - flex-wrap: wrap; } -.select-team { +.btn-team { flex: 1; - min-width: 100px; - padding: 10px 10px; - background: var(--slate-bg); + padding: 10px 8px; + background: var(--input-bg); border: 1px solid var(--input-border); border-radius: var(--radius); - color: var(--chalk-white); + color: var(--chalk-dim); font-size: 0.95rem; + font-weight: 600; cursor: pointer; + transition: background 0.18s, color 0.18s, border-color 0.18s; + letter-spacing: 0.04em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -.select-team:focus { - outline: 2px solid var(--chalk-dim); - outline-offset: 1px; +.btn-team.is-active { + background: var(--btn-primary); + color: #fff; + border-color: var(--btn-primary-h); } -.points-input { - flex: 2; - min-width: 80px; - padding: 10px 12px; - background: var(--slate-bg); - border: 1px solid var(--input-border); +.btn-team:hover:not(.is-active) { + background: rgba(255,255,255,0.06); + color: var(--chalk-white); +} + +/* ─── Mark buttons (+100 / +50 / +20) ────────────────────────── */ +.mark-row { + display: flex; + gap: 8px; +} + +.btn-mark { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + padding: 12px 6px; + background: var(--slate-surface); + border: 1px solid var(--panel-border); border-radius: var(--radius); + cursor: pointer; + transition: background 0.18s, transform 0.1s, border-color 0.18s; +} + +.btn-mark:hover { + background: rgba(255,255,255,0.07); + border-color: rgba(240,237,224,0.3); +} + +.btn-mark:active { + transform: scale(0.95); + background: rgba(255,255,255,0.12); +} + +.btn-mark:focus-visible { + outline: 2px solid var(--chalk-white); + outline-offset: 2px; +} + +.mark-pts { + font-size: 1.3rem; + font-weight: 700; color: var(--chalk-white); - font-size: 1rem; - text-align: center; - transition: border-color 0.2s; + text-shadow: 0 0 8px rgba(240,237,224,0.3); + letter-spacing: 0.04em; } -.points-input:focus { - outline: none; - border-color: var(--chalk-white); +.mark-lbl { + font-size: 0.7rem; + color: var(--chalk-dim); + text-transform: uppercase; + letter-spacing: 0.07em; } /* ─── Buttons ─────────────────────────────────────────────────── */ @@ -526,20 +556,12 @@ body { .app-wrapper { padding: 8px 8px 20px; } .header-title { font-size: 1.3rem; } .panel-team-name { font-size: 0.95rem; } - .entries-area { font-size: 0.82rem; } .total-value { font-size: 1.15rem; } - .input-row { flex-direction: column; } - .select-team, .points-input, .btn-primary { width: 100%; } + .mark-pts { font-size: 1.1rem; } #win-overlay h2 { font-size: 2rem; } .win-team { font-size: 1.3rem; } } @media (min-width: 600px) { .board { min-height: 480px; } - .entries-area { font-size: 1rem; } } - -/* ─── Scrollbar styling (z-wrapper) ──────────────────────────── */ -.z-wrapper::-webkit-scrollbar { width: 4px; } -.z-wrapper::-webkit-scrollbar-track { background: transparent; } -.z-wrapper::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 4px; } diff --git a/jass-scoreboard/index.html b/jass-scoreboard/index.html index 3c0b6dd..15f27ec 100644 --- a/jass-scoreboard/index.html +++ b/jass-scoreboard/index.html @@ -59,14 +59,7 @@

Jass Tafel

-
- -
-
+
Total: 0 @@ -79,14 +72,7 @@

-
- -
-
+
Total: 0 @@ -97,26 +83,27 @@

-
- - - - + +
+ + +
- + +
+ + +
diff --git a/jass-scoreboard/js/renderer.js b/jass-scoreboard/js/renderer.js index 5285ee3..f003b29 100644 --- a/jass-scoreboard/js/renderer.js +++ b/jass-scoreboard/js/renderer.js @@ -2,35 +2,88 @@ import { getState } from "./state.js"; +// ── SVG Z geometry constants ────────────────────────────────────── +const Z_W = 400; // viewBox width +const Z_H = 170; // viewBox height +const X_L = 12; // left x bound +const X_R = 388; // right x bound +const TOP_Y = 42; // top bar y +const BOT_Y = 128; // bottom bar y +// Diagonal: (X_R, TOP_Y) β†’ (X_L, BOT_Y) + +// Mark spacing limits (SVG viewBox units) +const MIN_SPACING = 6; +const MAX_SPACING = 13; +const DIAG_MARGIN = 14; // gap to leave at each end of the diagonal + /** - * Converts a point value into tally mark strings. - * Groups of 5 become "||||\" and remainders are "|". + * Build perpendicular tick marks along the top or bottom horizontal bar. */ -function renderTallies(points) { - const groups = Math.floor(points / 5); - const remainder = points % 5; - let result = ""; - for (let i = 0; i < groups; i++) { - result += "||||\\ "; +function hMarks(count, y) { + if (!count) return ""; + const usable = X_R - X_L - 16; // ~360 px + const spacing = Math.max(MIN_SPACING, Math.min(MAX_SPACING, count > 1 ? usable / (count - 1) : usable)); + const totalSpan = spacing * (count - 1); + const startX = X_L + 8 + (usable - totalSpan) / 2; + + let out = ""; + for (let i = 0; i < count; i++) { + const x = +(startX + i * spacing).toFixed(1); + out += ``; } - for (let i = 0; i < remainder; i++) { - result += "| "; + return out; +} + +/** + * Build perpendicular tick marks along the diagonal of the Z. + * Marks are drawn as short vertical lines since preserveAspectRatio="none" + * would skew true perpendiculars; vertical ticks look authentic on a chalk board. + */ +function diagMarks(count) { + if (!count) return ""; + const dx = X_L - X_R; // -376 + const dy = BOT_Y - TOP_Y; // 86 + const len = Math.sqrt(dx * dx + dy * dy); + const ux = dx / len; const uy = dy / len; // unit along diagonal + + const usable = len - 2 * DIAG_MARGIN; + const spacing = Math.max(MIN_SPACING, Math.min(MAX_SPACING, count > 1 ? usable / (count - 1) : usable)); + const totalSpan = spacing * (count - 1); + const tStart = DIAG_MARGIN + (usable - totalSpan) / 2; + + let out = ""; + for (let i = 0; i < count; i++) { + const t = tStart + i * spacing; + const cx = +(X_R + ux * t).toFixed(1); + const cy = +(TOP_Y + uy * t).toFixed(1); + // Vertical tick crossing the diagonal line + out += ``; } - return result.trim(); + return out; } /** - * Build the HTML for one team's entry list. + * Build the complete SVG Z shape with marks on lines for one team. */ -function buildEntriesHTML(teamId) { +function buildZsvg(teamId) { const state = getState(); - const teamEntries = state.entries.filter(e => e.teamId === teamId); - if (teamEntries.length === 0) { - return 'β€”'; - } - return teamEntries - .map(e => `
${renderTallies(e.points)}
`) - .join(""); + const entries = state.entries.filter(e => e.teamId === teamId); + + // Count marks per bar (support legacy entries that only have points/no barType) + const topN = entries.filter(e => e.barType === "top").length; + const diagN = entries.filter(e => e.barType === "diagonal").length; + const botN = entries.filter(e => e.barType === "bottom").length; + + return ``; } /** @@ -43,13 +96,15 @@ function render() { ? [state.teams[1], state.teams[0]] : [state.teams[0], state.teams[1]]; - // Team name headers + // Team name headers inside panels document.getElementById("team-a-name").textContent = topTeam.name; document.getElementById("team-b-name").textContent = bottomTeam.name; - // Entries (tally marks) - document.getElementById("entries-a").innerHTML = buildEntriesHTML(topTeam.id); - document.getElementById("entries-b").innerHTML = buildEntriesHTML(bottomTeam.id); + // SVG Z marks + const svgA = document.getElementById("z-svg-a"); + const svgB = document.getElementById("z-svg-b"); + if (svgA) svgA.innerHTML = buildZsvg(topTeam.id); + if (svgB) svgB.innerHTML = buildZsvg(bottomTeam.id); // Totals document.getElementById("total-a").textContent = state.totals[topTeam.id] || 0; @@ -59,26 +114,22 @@ function render() { const targetEl = document.getElementById("target-score-display"); if (targetEl) targetEl.textContent = state.targetScore; + // Update team toggle button labels + const btnTeamA = document.getElementById("btn-team-a"); + const btnTeamB = document.getElementById("btn-team-b"); + if (btnTeamA) btnTeamA.textContent = state.teams[0].name; + if (btnTeamB) btnTeamB.textContent = state.teams[1].name; + // Win state renderWinState(topTeam, bottomTeam); - // Update team select options to reflect current names - const teamSelect = document.getElementById("select-team"); - if (teamSelect) { - for (const opt of teamSelect.options) { - const team = state.teams.find(t => t.id === opt.value); - if (team) opt.textContent = team.name; - } - } - // Input area enable/disable (undo and reset always stay enabled) const inputArea = document.getElementById("score-input-area"); if (inputArea) { inputArea.classList.toggle("disabled", state.gameFinished); - const inputs = inputArea.querySelectorAll("input, select"); - inputs.forEach(el => { el.disabled = state.gameFinished; }); - const addBtn = document.getElementById("btn-add"); - if (addBtn) addBtn.disabled = state.gameFinished; + inputArea.querySelectorAll(".btn-mark, .btn-team").forEach(el => { + el.disabled = state.gameFinished; + }); } // Highlight winning panel @@ -88,10 +139,9 @@ function render() { panelA.classList.remove("winner-panel"); panelB.classList.remove("winner-panel"); if (state.gameFinished && state.winner) { - const winnerId = state.winner; - const winnerIsTop = topTeam.id === winnerId; + const winnerIsTop = topTeam.id === state.winner; if (winnerIsTop) panelA.classList.add("winner-panel"); - else panelB.classList.add("winner-panel"); + else panelB.classList.add("winner-panel"); } } } @@ -102,8 +152,7 @@ function renderWinState(topTeam, bottomTeam) { if (!overlay) return; if (state.gameFinished && state.winner) { - const winningTeam = - state.winner === topTeam.id ? topTeam : bottomTeam; + const winningTeam = state.winner === topTeam.id ? topTeam : bottomTeam; document.getElementById("win-team-name").textContent = winningTeam.name; overlay.classList.add("active"); } else { @@ -111,4 +160,4 @@ function renderWinState(topTeam, bottomTeam) { } } -export { render, renderTallies }; +export { render }; diff --git a/jass-scoreboard/js/scoring.js b/jass-scoreboard/js/scoring.js index 1cb5451..6467b08 100644 --- a/jass-scoreboard/js/scoring.js +++ b/jass-scoreboard/js/scoring.js @@ -2,17 +2,19 @@ import { getState } from "./state.js"; -function addEntry(teamId, points) { +// Each bar type and its point value per mark +const BAR_VALUES = { top: 100, diagonal: 50, bottom: 20 }; + +function addEntry(teamId, barType) { const state = getState(); if (state.gameFinished) return false; - - const p = parseInt(points, 10); - if (isNaN(p) || p <= 0 || p > 500) return false; + if (!BAR_VALUES[barType]) return false; if (!state.teams.find(t => t.id === teamId)) return false; state.entries.push({ teamId, - points: p, + barType, + value: BAR_VALUES[barType], timestamp: Date.now() }); @@ -36,7 +38,9 @@ function recalcTotals() { const state = getState(); state.totals = { A: 0, B: 0 }; for (const entry of state.entries) { - state.totals[entry.teamId] = (state.totals[entry.teamId] || 0) + entry.points; + // Support both new (value) and legacy (points) saved entries + const pts = entry.value || entry.points || 0; + state.totals[entry.teamId] = (state.totals[entry.teamId] || 0) + pts; } } diff --git a/jass-scoreboard/js/ui.js b/jass-scoreboard/js/ui.js index a74a883..8e63b69 100644 --- a/jass-scoreboard/js/ui.js +++ b/jass-scoreboard/js/ui.js @@ -5,6 +5,8 @@ import { addEntry, undoLastEntry } from "./scoring.js"; import { render } from "./renderer.js"; import { saveState } from "./storage.js"; +// Currently-active team for mark entry +let activeTeamId = "A"; let errorTimeout = null; function persist() { @@ -12,18 +14,21 @@ function persist() { } function bindEvents() { - // Score entry form - const addBtn = document.getElementById("btn-add"); - if (addBtn) { - addBtn.addEventListener("click", handleAddScore); - } - - // Allow pressing Enter in the points input - const pointsInput = document.getElementById("input-points"); - if (pointsInput) { - pointsInput.addEventListener("keydown", e => { - if (e.key === "Enter") handleAddScore(); - }); + // Team toggle buttons + const btnTeamA = document.getElementById("btn-team-a"); + const btnTeamB = document.getElementById("btn-team-b"); + if (btnTeamA) btnTeamA.addEventListener("click", () => setActiveTeam("A")); + if (btnTeamB) btnTeamB.addEventListener("click", () => setActiveTeam("B")); + + // Mark entry buttons (+100 / +50 / +20) + const markDefs = [ + { id: "btn-mark-top", barType: "top" }, + { id: "btn-mark-diag", barType: "diagonal" }, + { id: "btn-mark-bot", barType: "bottom" } + ]; + for (const { id, barType } of markDefs) { + const btn = document.getElementById(id); + if (btn) btn.addEventListener("click", () => handleMarkAdd(barType)); } // Undo button @@ -44,13 +49,7 @@ function bindEvents() { resetState(); render(); persist(); - // Re-enable score input - const inputArea = document.getElementById("score-input-area"); - if (inputArea) { - const inputs = inputArea.querySelectorAll("input, button, select"); - inputs.forEach(el => { el.disabled = false; }); - inputArea.classList.remove("disabled"); - } + document.getElementById("score-input-area")?.classList.remove("disabled"); document.getElementById("win-overlay")?.classList.remove("active"); } }); @@ -66,7 +65,7 @@ function bindEvents() { }); } - // Team name edits (blur to save) + // Team name edits (save on blur/change) const nameA = document.getElementById("edit-name-a"); if (nameA) { nameA.addEventListener("change", () => { @@ -94,7 +93,7 @@ function bindEvents() { const ok = setTargetScore(targetInput.value); if (!ok) { targetInput.value = getState().targetScore; - showError("Target score must be between 100 and 10000."); + showError("Ziel muss zwischen 100 und 10000 liegen."); } else { render(); persist(); @@ -102,7 +101,7 @@ function bindEvents() { }); } - // Close win overlay (click anywhere on it) + // Close win overlay on click const winOverlay = document.getElementById("win-overlay"); if (winOverlay) { winOverlay.addEventListener("click", () => { @@ -111,34 +110,19 @@ function bindEvents() { } } -function handleAddScore() { +function setActiveTeam(teamId) { + activeTeamId = teamId; + // Update visual state of toggle buttons + document.getElementById("btn-team-a")?.classList.toggle("is-active", teamId === "A"); + document.getElementById("btn-team-b")?.classList.toggle("is-active", teamId === "B"); +} + +function handleMarkAdd(barType) { const state = getState(); if (state.gameFinished) return; - const teamSelect = document.getElementById("select-team"); - const pointsInput = document.getElementById("input-points"); - - const teamId = teamSelect ? teamSelect.value : null; - const pointsRaw = pointsInput ? pointsInput.value : ""; - - if (!teamId) { - showError("Please select a team."); - return; - } - - const points = parseInt(pointsRaw, 10); - if (isNaN(points) || points <= 0 || points > 500) { - showError("Points must be a whole number between 1 and 500."); - pointsInput && pointsInput.focus(); - return; - } - - const ok = addEntry(teamId, points); + const ok = addEntry(activeTeamId, barType); if (ok) { - if (pointsInput) { - pointsInput.value = ""; - pointsInput.focus(); - } render(); persist(); } @@ -150,9 +134,7 @@ function showError(msg) { errEl.textContent = msg; errEl.classList.add("visible"); clearTimeout(errorTimeout); - errorTimeout = setTimeout(() => { - errEl.classList.remove("visible"); - }, 3000); + errorTimeout = setTimeout(() => errEl.classList.remove("visible"), 3000); } function initInputValues() { @@ -164,6 +146,9 @@ function initInputValues() { if (nameA) nameA.value = state.teams[0].name; if (nameB) nameB.value = state.teams[1].name; if (targetInput) targetInput.value = state.targetScore; + + // Sync active team button visual + setActiveTeam(activeTeamId); } export { bindEvents, initInputValues };