diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2c132..11e5a1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.20.1 - 2026-09-12 + +### Changed + +- Refreshed the in-app Guide screenshot (`docs/screenshots/guide.jpg`) to show its current content (the new Schedule and Video guides sections, missing from the old capture) and added a new screenshot of Event Manager's tabbed layout (`race-manage-tabs.jpg`), linked from both READMEs. Added `scripts/capture-screenshots.mjs`, a reusable capture tool built on the same isolated-server pattern as the video/test tooling. + ## 1.20.0 - 2026-09-11 ### Added diff --git a/README.md b/README.md index 555720c..a0e9d97 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Current layouts for race operation, live presentation, training and system confi In-app setup guide
In-app setup guide - + Event Manager's Live & results tab
Event Manager's Live & results tab diff --git a/README.sv.md b/README.sv.md index 72914a1..7d20191 100644 --- a/README.sv.md +++ b/README.sv.md @@ -78,7 +78,7 @@ Aktuella layouter för racedrift, livevisning, träning och systemkonfiguration. Inbyggd uppstartsguide
Inbyggd uppstartsguide - + Event Managers flik Live & resultat
Event Managers flik Live & resultat diff --git a/ROADMAP.md b/ROADMAP.md index f27ed45..75aabd0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -308,6 +308,16 @@ Requested 2026-09-10 by the user: "create screenshots and update the images in t - [x] Redacted incidental local-machine details that don't belong in a public screenshot before capturing (the dev machine's resolved AMMC executable path, and the ephemeral test server's random localhost ports) by overwriting just those specific form fields/text nodes in the DOM right before each shot - cosmetic only, never saved back to any backend. - [x] While building this data, found and fixed a real, previously-invisible formatting bug (see the entry directly above) purely as a side effect of finally exercising the app with real results for the first time. +### Follow-up 2026-09-12: `guide.jpg` had gone stale again, and Event Manager's new tabs were never pictured + +Requested 2026-09-12 by the user alongside the documentation pass below: make sure the screenshots match the layouts shipped this session. Checked every screenshot against the current UI first rather than assuming - `decoder-settings.jpg`, `race-audio-settings.jpg` and `managed-ammc.jpg` (all Settings-tab shots) were already current, since Settings' tabs predate this session and were captured fresh for the entry above. Only two were actually stale/missing: + +- [x] `guide.jpg` still showed the pre-Schedule-feature, pre-video-guides Guide page (6 overview cards, no "Video guides" section) - recaptured. +- [x] Event Manager's new tabbed layout (this session's own work) had never been pictured at all - added `race-manage-tabs.jpg`, scrolled to the tab bar with "Live & results" active (grid editor + qualifying standings visible), and a new row in both README screenshot tables. +- [x] Built `scripts/capture-screenshots.mjs` (new, reusable) rather than a throwaway script this time, since screenshot refreshes are clearly a recurring need - same isolated-server-plus-headless-Chrome pattern as the video recorder and test suite, sharing one seeded demo dataset (`JMK RB`, "Klubbmästare Race 4", matching the existing screenshots' branding) across every shot so future captures stay visually consistent with each other. + +Verified 2026-09-12: visually inspected both new captures at full resolution (3200x1800, matching every other screenshot in the folder) - `guide.jpg` shows all 8 overview cards and the Video guides section with all 7 working links; `race-manage-tabs.jpg` clearly shows the 4-tab bar with real grid/standings content beneath it. Waited for the settings save the seeded decoder connection triggers to finish before capturing (`syncStatus` reaching `"saved"`), so neither shot shows a stray "Changes waiting to save" banner mid-capture the way an early attempt did. + ## Race clock direction: counts down for timed sessions, up for open practice Asked 2026-09-10 by the user: when running a race, should the clock count down from the target duration to 0 (not up from 0)? Only open practice should just roll on with no limit. Checked against the actual code (`getSessionTiming` in `src/timing_logic.js`, and its use in the Timing view, Race Day view, and all Overlay pages): already correct, no changes made. diff --git a/docs/screenshots/guide.jpg b/docs/screenshots/guide.jpg index 878f721..9ff16e3 100644 Binary files a/docs/screenshots/guide.jpg and b/docs/screenshots/guide.jpg differ diff --git a/docs/screenshots/race-manage-tabs.jpg b/docs/screenshots/race-manage-tabs.jpg new file mode 100644 index 0000000..0a7ec4d Binary files /dev/null and b/docs/screenshots/race-manage-tabs.jpg differ diff --git a/package-lock.json b/package-lock.json index 151ee51..c3d42cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.20.0", + "version": "1.20.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.20.0", + "version": "1.20.1", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 3fdd85f..7a63234 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.20.0", + "version": "1.20.1", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs new file mode 100644 index 0000000..9081e07 --- /dev/null +++ b/scripts/capture-screenshots.mjs @@ -0,0 +1,190 @@ +// Captures README screenshots (docs/screenshots/*.jpg) against a real, isolated instance +// of the app - own temp SQLite dir, own headless Chrome profile, exactly like the +// tests/browser_*.js suite and scripts/record-video-guides.mjs. Nothing here ever touches +// a real installation's data. +// +// Usage: node scripts/capture-screenshots.mjs [shot-name ...] +// With no arguments, captures every shot below. Pass one or more names (e.g. "guide") +// to capture only those. Output overwrites docs/screenshots/.jpg. +// +// Captured at 1600x900 CSS with deviceScaleFactor 2 (3200x1800 JPEG), matching every +// existing screenshot in that folder - a plain viewport capture, not a full-page stitch, +// so a shot's setup should scroll to whatever should be visible before capturing. + +import { spawn, execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { startFakeDecoder } from "../tests/browser_fake_decoder.js"; + +const projectDir = dirname(dirname(fileURLToPath(import.meta.url))); +const outputDir = join(projectDir, "docs", "screenshots"); +const chrome = [process.env.CHROME_PATH, "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"].find((p) => p && existsSync(p)); +if (!chrome) throw new Error("Chrome/Chromium not found. Set CHROME_PATH to the browser executable."); + +const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(check, label, timeout = 15000) { + const until = Date.now() + timeout; + while (Date.now() < until) { + try { if (await check()) return; } catch {} + await pause(100); + } + throw new Error("Timed out: " + label); +} + +async function freePort() { + const server = createServer(); + await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); + const port = server.address().port; + await new Promise((resolve) => server.close(resolve)); + return port; +} + +// Demo data shared by every shot, so screenshots taken at different times still look like +// one consistent club/session - matching the existing screenshots' "JMK RB" branding and +// active-session header. +function seedDemoState(state, decoderPort) { + state.settings.wsUrl = `ws://127.0.0.1:${decoderPort}`; + state.settings.autoReconnect = true; + const classId = "shot_class"; + const driverA = { id: "shot_driver_a", name: "Alex Karlsson", classId, transponder: "7960001" }; + const driverB = { id: "shot_driver_b", name: "Sam Nilsson", classId, transponder: "7960002" }; + const driverC = { id: "shot_driver_c", name: "Jamie Lee", classId, transponder: "7960003" }; + const driverD = { id: "shot_driver_d", name: "Robin Berg", classId, transponder: "7960004" }; + const raceEvent = { + id: "shot_event", name: "Klubbmästare Race 4", classId, mode: "race", + raceConfig: { participantsConfigured: true, driverIds: [driverA.id, driverB.id, driverC.id, driverD.id], teams: [], minLapMs: 3000, maxLapMs: 60000 }, + }; + const practice = { id: "shot_practice", eventId: raceEvent.id, name: "Practice 1", type: "practice", durationMin: 10, status: "finished", startedAt: Date.now() - 3600000, endedAt: Date.now() - 3000000 }; + const qualifying = { id: "shot_quali", eventId: raceEvent.id, name: "Qualification 1", type: "qualification", startMode: "position", durationMin: 5, status: "finished", startedAt: Date.now() - 2400000, endedAt: Date.now() - 2100000 }; + state.classes.push({ id: classId, name: "2WD Buggy" }); + state.drivers.push(driverA, driverB, driverC, driverD); + state.events.push(raceEvent); + state.sessions.push(practice, qualifying); + state.activeSessionId = qualifying.id; + state.settings.theme = "light"; + state.settings.clubName = "JMK RB"; + state.settings.clubTagline = "Live Timing & Race Control"; +} + +async function captureShot(name, { setup }) { + const temporary = mkdtempSync(join(tmpdir(), "racecore-shot-")); + const children = []; + let socket; + const decoder = await startFakeDecoder(); + const launch = (command, args, options = {}) => { + const child = spawn(command, args, { cwd: projectDir, stdio: "ignore", ...options }); + children.push(child); + child.on("error", (e) => console.error(e.message)); + return child; + }; + try { + const port = await freePort(); + const debugPort = await freePort(); + const base = `http://127.0.0.1:${port}`; + launch(process.execPath, ["server.js"], { env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: join(temporary, "data"), RC_TIMING_PID_FILE: "" } }); + await waitFor(async () => (await fetch(base + "/api/health")).ok, "isolated server"); + + const initial = { + classes: [], drivers: [], cars: [], events: [], sessions: [], resultsBySession: {}, + settings: { language: "en", autoReconnect: false, backendUrl: base, raceAudio: { enabled: false } }, + training: {}, + }; + seedDemoState(initial, decoder.port); + await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(initial) }); + + launch(chrome, [ + "--headless", "--disable-gpu", "--no-first-run", "--no-default-browser-check", "--disable-background-networking", + ...(process.env.RC_BROWSER_NO_SANDBOX === "1" ? ["--no-sandbox"] : []), + "--window-size=1600,900", `--user-data-dir=${join(temporary, "browser")}`, `--remote-debugging-port=${debugPort}`, "about:blank", + ]); + let target; + await waitFor(async () => { + target = (await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json()).find((p) => p.type === "page"); + return target; + }, "headless browser"); + + socket = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { socket.addEventListener("open", resolve, { once: true }); socket.addEventListener("error", reject, { once: true }); }); + let nextId = 0; + const requests = new Map(); + socket.addEventListener("message", (event) => { + const message = JSON.parse(event.data); + if (message.id) { + const request = requests.get(message.id); + requests.delete(message.id); + if (message.error) request?.reject(new Error(message.error.message)); else request?.resolve(message.result); + } + }); + function send(method, params = {}) { + return new Promise((resolve, reject) => { const id = ++nextId; requests.set(id, { resolve, reject }); socket.send(JSON.stringify({ id, method, params })); }); + } + async function evaluate(expression) { + const result = await send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise: true }); + if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || JSON.stringify(result.exceptionDetails)); + return result.result.value; + } + async function navigate(view, readySelector) { + await send("Page.navigate", { url: base + "/?view=" + view }); + await waitFor(async () => + Boolean(await evaluate(`document.querySelector(${JSON.stringify(readySelector)})`)) && + (await evaluate(`document.getElementById("syncStatusDetail")?.textContent`) || "").includes("Data loaded"), + view + " hydrated"); + // Match the "Decoder Online" state every other screenshot already shows, and let any + // save the connection triggers finish so the sidebar doesn't show a stray "Changes + // waiting to save" banner in the capture. + await waitFor(async () => await evaluate('document.getElementById("connectionBadge")?.classList.contains("badge-online")'), "decoder connected"); + await waitFor(async () => (await evaluate('document.getElementById("syncStatus")?.dataset.state')) === "saved", "settings saved"); + } + const click = (selector) => evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`); + const scrollTo = (selector, block = "start") => evaluate(`document.querySelector(${JSON.stringify(selector)})?.scrollIntoView({block: ${JSON.stringify(block)}})`); + + await send("Runtime.enable"); + await send("Page.enable"); + await send("Emulation.setDeviceMetricsOverride", { width: 1600, height: 900, deviceScaleFactor: 2, mobile: false }); + + await setup({ evaluate, navigate, click, scrollTo, pause, waitFor }); + await pause(300); // let any final CSS transition/re-render settle + + const { data } = await send("Page.captureScreenshot", { format: "jpeg", quality: 90 }); + mkdirSync(outputDir, { recursive: true }); + const outputPath = join(outputDir, `${name}.jpg`); + writeFileSync(outputPath, Buffer.from(data, "base64")); + console.log(`[${name}] wrote ${outputPath}`); + } finally { + try { socket?.close(); } catch {} + for (const child of children.reverse()) { try { child.kill(); } catch {} } + await pause(300); + try { await decoder.close(); } catch {} + rmSync(temporary, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } +} + +const shots = { + guide: { + async setup({ navigate }) { + await navigate("guide", ".guide-list"); + }, + }, + "race-manage-tabs": { + async setup({ navigate, click, scrollTo, waitFor: waitForFn, evaluate, pause: pauseFn }) { + await navigate("race_setup", "#raceWizardStepForm"); + await click("#event-manage-shot_event"); + await waitForFn(async () => Boolean(await evaluate('document.querySelector(".manage-tabs")')), "manage tabs rendered"); + await click('[data-manage-tab="live"]'); + await scrollTo(".manage-tabs"); + await pauseFn(200); + }, + }, +}; + +const requested = process.argv.slice(2); +const names = requested.length ? requested : Object.keys(shots); +for (const name of names) { + const shot = shots[name]; + if (!shot) { console.error(`Unknown shot "${name}". Available: ${Object.keys(shots).join(", ")}`); process.exitCode = 1; continue; } + await captureShot(name, shot); +}