diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bd2933..6eea92e 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.13.0 - 2026-09-10 + +### Added + +- Registering or editing a driver now warns if the transponder you entered is already used by a different driver (e.g. someone in another class) - naming who and which class - before saving, instead of silently allowing it. Saving anyway is still possible for genuine cases (e.g. a reused loaner transponder); the same physical transponder reused across one person's own multiple class entries is not flagged. + ## 1.12.2 - 2026-09-10 ### Fixed diff --git a/ROADMAP.md b/ROADMAP.md index 10b15d7..4ede934 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -308,6 +308,29 @@ 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. +## 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. + +- Only `open_practice` is treated as untimed (`isUntimedSession`) - its clock shows "Elapsed" and counts up with no limit. +- Every other session type (qualification, heat, final, team_race, and the event-scoped "practice"/"free_practice") gets a `targetMs` from `durationMin`, and the displayed clock is `targetMs - elapsedMs` labeled "Remaining" - a countdown to 0, consistently in Timing, Race Day, and every Overlay variant (they all share the same `sessionTiming.untimed` branch). +- Already covered by an existing test (`tests/race_timing_logic.test.js`, "session timing handles untimed practice, countdown, and follow-up"), and directly confirmed visually in the newly regenerated `leaderboard-overlay.jpg` screenshot above, where a running 8-minute final's clock reads "04:56" after roughly 3 minutes elapsed. + +## Wrong transponder registered to a driver (belongs to someone in a different class) + +Asked 2026-09-10 by the user: if a driver is registered with the wrong transponder - one that's actually assigned to someone else, e.g. a driver in a different class, because of a registration mistake - is it quick and easy to notice and fix? And confirmed already correct: a driver who isn't entered in a heat but drives anyway must not have their laps recorded in that heat at all. + +Investigated and confirmed already correct, no changes needed: +- A driver whose registered transponder is picked up in a session they are NOT entered in (`session.driverIds` does not include them) is matched to `{ ignore: true }` in `resolveCompetitor` (`src/decoder_runtime.js`) and the passing is dropped before anything is ever recorded (`processDecoderMessageHelper` returns immediately on `competitor.ignore`) - it never creates a competitor row, never appears in the leaderboard, nothing to clean up afterward. This also plays a "Unknown transponder" audio cue and logs an entry in Decoder Diagnostics in real time, so an attentive operator gets a live signal something is off. +- `src/race_day_logic.js` already has a `duplicate_transponders` preflight check that blocks starting a session if two of ITS OWN entrants share an identical transponder. + +Found a real gap while investigating, since the scenario as described (the OTHER driver holding that transponder is in a different class/event, not this session's entrant list) falls outside both of the above: nothing anywhere warned that a transponder being registered on one driver was already registered to a completely different driver, until it silently broke lap recording on race day. Editing a driver's transponder itself was always a plain, one-field form edit - trivial once you knew something was wrong - but nothing ever told you something was wrong in the first place. + +- [x] `findTransponderConflict` (`src/driver_profile_logic.js`, new) finds another driver already holding the same transponder, scoped correctly: the same person's own multiple class entries (same `profileId`, e.g. one racer running both 2WD and 4WD with one physical chip) are never flagged, since that's the normal, expected case - only a collision with a genuinely different profile counts. +- [x] Wired into both the driver create form and the driver edit form (`src/core_views.js`): saving with a colliding transponder now shows a confirm dialog naming the other driver and their class (e.g. "Transponder 1000009 is already used by Marcus Dahl (4WD Buggy). Save anyway?") before it's written - cancelling aborts the save so the mistake can be corrected on the spot, confirming still allows it through for genuine cases (e.g. a transponder handed down from a retired driver). + +Verified 2026-09-10: new unit tests (`tests/driver_profile_logic.test.js`) cover the conflict/no-conflict/self-edit/empty-transponder cases for `findTransponderConflict` directly. A full headless-browser run against a real, isolated server reproduced the exact reported scenario end to end through the real Drivers UI: created a 4WD driver with a transponder, then created a 2WD driver with the same transponder through the real create form - got the warning dialog naming the correct driver and class, confirmed through it, and confirmed the same warning fires from the driver edit form too when changing an existing driver's transponder into a collision. Also confirmed no false-positive warning when the same person's own second class entry legitimately reuses their own transponder. + ## Documentation maintenance - [ ] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index b02e3f3..91a7f37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.12.2", + "version": "1.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.12.2", + "version": "1.13.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index b3be5bc..8fb28a2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.12.2", + "version": "1.13.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/src/app.js b/src/app.js index c860ccb..dfd7d25 100644 --- a/src/app.js +++ b/src/app.js @@ -866,6 +866,7 @@ const TRANSLATIONS = { "drivers.brand_placeholder": "Team / märke (valfritt)", "drivers.brand_filter_placeholder": "Sök namn / transponder / märke / klubb / land", "drivers.transponder_placeholder": "Personlig transponder (valfritt)", + "drivers.transponder_conflict_confirm": "Transpondern {tp} används redan av {name} ({class}). Spara ändå?", "drivers.speech_name": "Uttalsnamn", "drivers.speech_name_placeholder": "Uttal för uppläsning (valfritt)", "drivers.speech_name_hint": "Skriv namnet som webbläsarrösten ska uttala det. Lämna tomt för vanligt förarnamn.", @@ -2381,6 +2382,7 @@ const TRANSLATIONS = { "drivers.brand_placeholder": "Team / brand (optional)", "drivers.brand_filter_placeholder": "Search name / transponder / brand / club / country", "drivers.transponder_placeholder": "Personal transponder (optional)", + "drivers.transponder_conflict_confirm": "Transponder {tp} is already used by {name} ({class}). Save anyway?", "drivers.speech_name": "Spoken name", "drivers.speech_name_placeholder": "Pronunciation for announcements (optional)", "drivers.speech_name_hint": "Enter the name as the browser voice should pronounce it. Leave blank to use the regular driver name.", diff --git a/src/core_views.js b/src/core_views.js index e9b02dd..04c979e 100644 --- a/src/core_views.js +++ b/src/core_views.js @@ -1,4 +1,4 @@ -import { getDriverProfileGroups, getDriverProfileId } from "./driver_profile_logic.js"; +import { getDriverProfileGroups, getDriverProfileId, findTransponderConflict } from "./driver_profile_logic.js"; function getScheduleDriftSummary(state) { const scheduledSessions = state.sessions @@ -515,13 +515,18 @@ export function renderDriversView(deps) { ? uid("profile") : sameNameProfile?.id || uid("profile"); const linkedProfile = profileGroups.find((profile) => profile.id === profileId); + const cleanedTransponder = String(form.get("transponder") || "").trim(); + const transponderConflict = findTransponderConflict(state.drivers, { transponder: cleanedTransponder, profileId }); + if (transponderConflict && !window.confirm(t("drivers.transponder_conflict_confirm", { tp: cleanedTransponder, name: transponderConflict.name, class: getClassName(transponderConflict.classId) }))) { + return; + } state.drivers.push( normalizeDriver({ id: driverId, name: driverName, classId: String(form.get("classId")), brand: String(form.get("brand") || "").trim(), - transponder: String(form.get("transponder") || "").trim(), + transponder: cleanedTransponder, speechName: String(form.get("speechName") || "").trim(), passSound: String(form.get("passSound") || "").trim(), profileId, @@ -634,6 +639,10 @@ export function renderDriversView(deps) { setFormError("driverEditError", t("validation.invalid_selection")); return; } + const transponderConflict = findTransponderConflict(state.drivers, { transponder: cleanedTp, profileId: cleanedProfileId, excludeDriverId: editingDriver.id }); + if (transponderConflict && !window.confirm(t("drivers.transponder_conflict_confirm", { tp: cleanedTp, name: transponderConflict.name, class: getClassName(transponderConflict.classId) }))) { + return; + } setFormError("driverEditError", ""); state.drivers = state.drivers.map((item) => { if (item.id === editingDriver.id) { diff --git a/src/driver_profile_logic.js b/src/driver_profile_logic.js index d58530a..7184cd5 100644 --- a/src/driver_profile_logic.js +++ b/src/driver_profile_logic.js @@ -44,3 +44,19 @@ export function getDriverProfileReferences(state, reference) { profile.driverIds.forEach((driverId) => references.add(driverId)); return references; } + +// A transponder is one physical chip, so the same value legitimately repeats across a +// single person's own multi-class driver entries (same profile) - that is not a +// conflict. It only means something went wrong when it also shows up on a driver +// belonging to a DIFFERENT profile, since two different people can't share one chip: +// most often a typo at registration that quietly grabbed someone else's transponder. +export function findTransponderConflict(drivers, { transponder, profileId, excludeDriverId } = {}) { + const value = String(transponder || "").trim(); + if (!value) return null; + const list = Array.isArray(drivers) ? drivers : []; + return list.find((driver) => ( + String(driver.id || "") !== String(excludeDriverId || "") + && String(driver.transponder || "").trim() === value + && getDriverProfileId(driver) !== String(profileId || "") + )) || null; +} diff --git a/tests/driver_profile_logic.test.js b/tests/driver_profile_logic.test.js new file mode 100644 index 0000000..cb7c422 --- /dev/null +++ b/tests/driver_profile_logic.test.js @@ -0,0 +1,38 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { findTransponderConflict } from "../src/driver_profile_logic.js"; + +// Regression coverage for a real scenario the user described: a driver registered with the +// wrong transponder number by mistake - one that's actually assigned to a completely +// different person (a driver in another class). Before this, nothing warned about it; the +// only symptom was that driver's laps silently never registering during their heat. +test("findTransponderConflict flags a transponder already used by a driver from a different profile", () => { + const drivers = [ + { id: "d1", name: "Erik Andersson", transponder: "1000001", profileId: "p1" }, + { id: "d2", name: "Marcus Dahl", transponder: "1000009", profileId: "p2" }, + ]; + const conflict = findTransponderConflict(drivers, { transponder: "1000009", profileId: "p1" }); + assert.equal(conflict?.id, "d2"); +}); + +test("findTransponderConflict does not flag the same person's own multi-class entries (same profile)", () => { + const drivers = [ + { id: "d1", name: "Erik Andersson", transponder: "1000001", profileId: "p1" }, + { id: "d2", name: "Erik Andersson", transponder: "1000001", profileId: "p1" }, + ]; + const conflict = findTransponderConflict(drivers, { transponder: "1000001", profileId: "p1" }); + assert.equal(conflict, null); +}); + +test("findTransponderConflict excludes the driver being edited from its own conflict check", () => { + const drivers = [ + { id: "d1", name: "Erik Andersson", transponder: "1000001", profileId: "p1" }, + ]; + const conflict = findTransponderConflict(drivers, { transponder: "1000001", profileId: "p1", excludeDriverId: "d1" }); + assert.equal(conflict, null); +}); + +test("findTransponderConflict is a no-op for an empty transponder", () => { + const drivers = [{ id: "d1", name: "Erik Andersson", transponder: "", profileId: "p1" }]; + assert.equal(findTransponderConflict(drivers, { transponder: "", profileId: "p2" }), null); +}); diff --git a/tests/run.js b/tests/run.js index 4c4829d..c4c44ad 100644 --- a/tests/run.js +++ b/tests/run.js @@ -1,3 +1,4 @@ +import "./driver_profile_logic.test.js"; import "./training_logic.test.js"; import "./public_training_profile.test.js"; import "./race_timing_logic.test.js";