Refresh stale screenshots and add one for Event Manager's new tabs

guide.jpg was captured before this session's Schedule feature and video guides existed,
so it was missing both. Event Manager's tabbed layout (also from this session) had never
been pictured in the README at all. Added scripts/capture-screenshots.mjs as a reusable
tool for this instead of another throwaway script, since screenshot refreshes are clearly
a recurring need - reuses the same isolated-server/headless-Chrome pattern as the video
recorder and test suite, with one shared seeded demo dataset so future captures stay
visually consistent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-12 17:30:44 +02:00
parent 3e02ba9aad
commit d76b0e8eef
9 changed files with 211 additions and 5 deletions

View File

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

View File

@@ -80,7 +80,7 @@ Current layouts for race operation, live presentation, training and system confi
</tr>
<tr>
<td width="50%"><a href="docs/screenshots/guide.jpg"><img src="docs/screenshots/guide.jpg" alt="In-app setup guide" /></a><br><strong>In-app setup guide</strong></td>
<td width="50%"></td>
<td width="50%"><a href="docs/screenshots/race-manage-tabs.jpg"><img src="docs/screenshots/race-manage-tabs.jpg" alt="Event Manager's Live & results tab" /></a><br><strong>Event Manager's Live & results tab</strong></td>
</tr>
</table>

View File

@@ -78,7 +78,7 @@ Aktuella layouter för racedrift, livevisning, träning och systemkonfiguration.
</tr>
<tr>
<td width="50%"><a href="docs/screenshots/guide.jpg"><img src="docs/screenshots/guide.jpg" alt="Inbyggd uppstartsguide" /></a><br><strong>Inbyggd uppstartsguide</strong></td>
<td width="50%"></td>
<td width="50%"><a href="docs/screenshots/race-manage-tabs.jpg"><img src="docs/screenshots/race-manage-tabs.jpg" alt="Event Managers flik Live & resultat" /></a><br><strong>Event Managers flik Live & resultat</strong></td>
</tr>
</table>

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 579 KiB

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

4
package-lock.json generated
View File

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

View File

@@ -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": {

View File

@@ -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/<name>.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);
}