diff --git a/CHANGELOG.md b/CHANGELOG.md
index d9b5305..1547e12 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.10.2 - 2026-09-10
+
+### Fixed
+
+- Moving a driver from one Team Race team to another (by ticking them on the new team's edit form) used to leave them on both teams at once, so their laps only ever counted for one of them. Team edit/create now shows a warning naming the driver's current team before you tick them elsewhere, and saving genuinely moves them. Also fixed: the team driver picker was showing every driver in the club instead of just the event's own participants, and deleting a driver or car left them behind in team rosters forever.
+
## 1.10.1 - 2026-09-10
### Added
diff --git a/ROADMAP.md b/ROADMAP.md
index b62d311..dee4df1 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -189,6 +189,25 @@ Fixed 2026-09-09, reported while exporting an old race from the new Results arch
Verified 2026-09-09: a new integration test (`tests/server_persistence.integration.test.js`) exports a PDF with a Swedish title and a table row containing å/ä/ö/Å/Ä/Ö, asserts the font declares WinAnsiEncoding, asserts the correct single-byte WinAnsi sequence is present in the raw PDF bytes, and explicitly asserts the old UTF-8 byte sequence is *not* present - confirmed this test genuinely fails against the pre-fix code (not a tautology) by re-running it with the fix reverted.
+## Team Race roster duplicate-membership bug
+
+Reported 2026-09-10 by the user: creating a team, adding a driver to it by mistake, then "moving" that driver to a different team by ticking them on the new team's edit form left the driver on *both* teams at once. Investigated before fixing, per the user's own account of the exact steps that triggered it.
+
+Root cause: there was never a "move driver to another team" action - the only way to reassign someone was to tick their checkbox on the *new* team's edit form (`event_manager_controller.js`'s `teamForm`/`teamEditForm` submit handlers). That handler only ever wrote the team being saved; it never checked whether the driver/car was already listed on a different team, so ticking them there added them without ever removing them from wherever they already were. Consequence found while investigating: `findEventTeamForPassing` (`event_race_logic.js`) resolves a team by the *first* array match, so a driver on two teams had every one of their live laps silently credited to only one of them - the other team got nothing, with no error surfaced anywhere except a misleading "duplicate transponders" preflight warning (and only when the driver actually had a transponder set).
+
+Two related bugs surfaced in the same code while investigating, at the user's request to fix everything found:
+- `getTeamDriverPool` (`event_race_logic.js`) had been reduced, in an earlier change, to unconditionally return every driver in the club regardless of the event's class or configured participants - the class/participant-scoped filtering (with a documented fallback to "show everyone" only when the scoped list is empty) existed in an earlier version of this function and had been silently dropped.
+- Deleting a driver or car (`core_views.js`) cleaned up session assignments but never removed them from any team's roster, so a deleted driver/car stayed a permanent "member" of whichever teams they'd been added to, inflating team member counts forever.
+
+Fixed:
+- [x] `reassignTeamMembers` (`event_race_logic.js`, new) strips a driver/car being saved onto one team out of every *other* team in the same event - both the create-team and edit-team submit handlers now call it before writing, so ticking a driver onto a new team genuinely moves them rather than duplicating them.
+- [x] The team edit/create checkbox list now shows "Redan i <team> - kryssar du i flyttas den hit" next to a driver/car already on a different team, so the move is visible *before* saving, not just after.
+- [x] `getTeamDriverPool` restored to scope by the event's class/configured participants first, falling back to every driver only when that scoped list is empty (so a team can still be set up before participants exist, without silently showing the whole club's roster once they do).
+- [x] Deleting a driver or car now also removes them from every team's `driverIds`/`carIds`, and from a session's `driverIds`/`manualGridIds`, alongside the existing assignment cleanup.
+- [x] Added a `duplicate_team_members` Race Day preflight check specifically for `team_race` sessions, naming the driver/car by name - defense in depth for any state saved before this fix, so it's diagnosed accurately instead of surfacing as a confusing "duplicate transponders" error (or nothing at all, if the shared driver has no transponder).
+
+Verified 2026-09-10: new unit tests (`tests/race_timing_logic.test.js`) cover `reassignTeamMembers` (strips from other teams, leaves the team being saved and non-overlapping teams untouched) and `getTeamDriverPool`'s scoped-then-fallback behavior; new tests (`tests/race_day_logic.test.js`) cover the `duplicate_team_members` preflight check both tripping (naming the shared driver) and staying clear when teams don't overlap. A new permanent browser test (`tests/browser_team_membership.js`) reproduces the user's exact reported scenario end to end against a real race-mode event with a team_race session: creates Team A with Driver A, creates Team B with Driver B, opens Team B's edit form and confirms the "already on Team A" warning appears *before* saving, ticks Driver A onto Team B and saves, confirms Driver A is gone from Team A's roster and present on Team B's (the reported bug, now fixed), then deletes Driver B entirely and confirms they're gone from Team B's roster too.
+
## In-app update checker and one-click updater
Implemented 2026-09-09, requested by the user: an easy way to see a new version is available in the GUI, and an easy way to update for someone not comfortable with `git pull` on their own.
diff --git a/package-lock.json b/package-lock.json
index 87379fb..f1cb6a7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "rc-racecore",
- "version": "1.10.1",
+ "version": "1.10.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rc-racecore",
- "version": "1.10.1",
+ "version": "1.10.2",
"hasInstallScript": true,
"dependencies": {
"better-sqlite3": "^12.11.1",
diff --git a/package.json b/package.json
index fd96083..02128ae 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "rc-racecore",
- "version": "1.10.1",
+ "version": "1.10.2",
"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 cc3e191..9a41056 100644
--- a/src/app.js
+++ b/src/app.js
@@ -11,7 +11,7 @@ import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudio
import { renderRaceFormatField, renderRaceFormatContextCard, getRaceSummaryItems, getRaceSummaryWarnings, renderManageStatusBadge, renderRaceWizardSteps, renderRaceWizardContent, renderRaceStandingsTable } from "./race_setup_ui.js";
-import { normalizeRaceTeam as normalizeRaceTeamLogic, normalizeStoredRacePreset as normalizeStoredRacePresetLogic, getRaceFormatPresets as getRaceFormatPresetsLogic, applyRaceFormatPreset as applyRaceFormatPresetLogic, buildRaceFormatConfigFromForm as buildRaceFormatConfigFromFormLogic, normalizeEvent as normalizeEventLogic, normalizeBrandingConfig as normalizeBrandingConfigLogic, resolveEventBranding as resolveEventBrandingLogic, getRaceManageStatuses as getRaceManageStatusesLogic, buildDefaultRaceWizardDraft as buildDefaultRaceWizardDraftLogic, getRaceWizardPreset as getRaceWizardPresetLogic, applyRaceWizardPresetDefaults as applyRaceWizardPresetDefaultsLogic, ensureRaceParticipantsConfigured as ensureRaceParticipantsConfiguredLogic, buildRaceSession as buildRaceSessionLogic, buildOpenPracticeBundle as buildOpenPracticeBundleLogic, buildTrackSession as buildTrackSessionLogic, createSponsorRounds as createSponsorRoundsLogic, buildRaceSessionsFromWizard as buildRaceSessionsFromWizardLogic, getRaceWizardSessionPlan as getRaceWizardSessionPlanLogic, getEventDrivers as getEventDriversLogic, getEventTeams as getEventTeamsLogic, getTeamDriverPool as getTeamDriverPoolLogic, findEventTeamForPassing as findEventTeamForPassingLogic, generateQualifyingForRace as generateQualifyingForRaceLogic, reseedUpcomingQualifying as reseedUpcomingQualifyingLogic, generateFinalsForRace as generateFinalsForRaceLogic, applyBumpsForRace as applyBumpsForRaceLogic } from "./event_race_logic.js";
+import { normalizeRaceTeam as normalizeRaceTeamLogic, normalizeStoredRacePreset as normalizeStoredRacePresetLogic, getRaceFormatPresets as getRaceFormatPresetsLogic, applyRaceFormatPreset as applyRaceFormatPresetLogic, buildRaceFormatConfigFromForm as buildRaceFormatConfigFromFormLogic, normalizeEvent as normalizeEventLogic, normalizeBrandingConfig as normalizeBrandingConfigLogic, resolveEventBranding as resolveEventBrandingLogic, getRaceManageStatuses as getRaceManageStatusesLogic, buildDefaultRaceWizardDraft as buildDefaultRaceWizardDraftLogic, getRaceWizardPreset as getRaceWizardPresetLogic, applyRaceWizardPresetDefaults as applyRaceWizardPresetDefaultsLogic, ensureRaceParticipantsConfigured as ensureRaceParticipantsConfiguredLogic, buildRaceSession as buildRaceSessionLogic, buildOpenPracticeBundle as buildOpenPracticeBundleLogic, buildTrackSession as buildTrackSessionLogic, createSponsorRounds as createSponsorRoundsLogic, buildRaceSessionsFromWizard as buildRaceSessionsFromWizardLogic, getRaceWizardSessionPlan as getRaceWizardSessionPlanLogic, getEventDrivers as getEventDriversLogic, getEventTeams as getEventTeamsLogic, getTeamDriverPool as getTeamDriverPoolLogic, reassignTeamMembers as reassignTeamMembersLogic, findEventTeamForPassing as findEventTeamForPassingLogic, generateQualifyingForRace as generateQualifyingForRaceLogic, reseedUpcomingQualifying as reseedUpcomingQualifyingLogic, generateFinalsForRace as generateFinalsForRaceLogic, applyBumpsForRace as applyBumpsForRaceLogic } from "./event_race_logic.js";
import { getSessionTypeLabel as getSessionTypeLabelLogic, getStatusLabel as getStatusLabelLogic, isUntimedSession as isUntimedSessionLogic, getActiveSession as getActiveSessionLogic, getSessionTargetMs as getSessionTargetMsLogic, getSessionLapWindow as getSessionLapWindowLogic, isCountedPassing as isCountedPassingLogic, getVisiblePassings as getVisiblePassingsLogic, getPassingValidationLabel as getPassingValidationLabelLogic, getSessionTiming as getSessionTimingLogic, ensureSessionResult as ensureSessionResultLogic, buildLeaderboard as buildLeaderboardLogic, formatLapDelta as formatLapDeltaLogic, formatLeaderboardGap as formatLeaderboardGapLogic, getCompetitorElapsedMs as getCompetitorElapsedMsLogic, getCompetitorPassings as getCompetitorPassingsLogic, getCompetitorSeedMetric as getCompetitorSeedMetricLogic, getSessionEntrants as getSessionEntrantsLogic, buildPracticeStandings as buildPracticeStandingsLogic, getQualifyingPointsValue as getQualifyingPointsValueLogic, isHighPointsTable as isHighPointsTableLogic, compareNumberSet as compareNumberSetLogic, buildQualifyingTieBreakNote as buildQualifyingTieBreakNoteLogic, hasQualifyingPrimaryTie as hasQualifyingPrimaryTieLogic, buildQualifyingStandings as buildQualifyingStandingsLogic, formatTeamActiveMemberLabel as formatTeamActiveMemberLabelLogic, buildTeamRaceStandings as buildTeamRaceStandingsLogic, buildSessionChannelStats as buildSessionChannelStatsLogic, buildTeamStintLog as buildTeamStintLogLogic, getSessionGridEntries as getSessionGridEntriesLogic, getSessionGridOrder as getSessionGridOrderLogic, ensureSessionDriverOrder as ensureSessionDriverOrderLogic, buildFinalStandings as buildFinalStandingsLogic, getTrackRecordMs as getTrackRecordMsLogic } from "./timing_logic.js";
import { normalizeTrackLayouts, getTrackLayoutIdAt, getTrackLayoutLabel, nextTrackLayoutLabel } from "./track_layouts_logic.js";
@@ -72,6 +72,7 @@ const getRaceWizardSessionPlan = (draft) => getRaceWizardSessionPlanLogic(draft,
const getEventDrivers = (event) => getEventDriversLogic(event, { state });
const getEventTeams = (event) => getEventTeamsLogic(event, { normalizeRaceTeam });
const getTeamDriverPool = (event) => getTeamDriverPoolLogic(event, { getEventDrivers, state });
+const reassignTeamMembers = (teams, options) => reassignTeamMembersLogic(teams, options);
const findEventTeamForPassing = (event, driverId, carId) => findEventTeamForPassingLogic(event, driverId, carId, { getEventTeams });
const generateQualifyingForRace = (event) => generateQualifyingForRaceLogic(event, { buildPracticeStandings, getEventDrivers, state, normalizeStartMode, normalizeSession, uid, chunkArray, clearGeneratedQualifying });
const reseedUpcomingQualifying = (event) => reseedUpcomingQualifyingLogic(event, { buildQualifyingStandings, buildPracticeStandings, getEventDrivers, getSessionsForEvent, chunkArray });
@@ -425,6 +426,7 @@ const TRANSLATIONS = {
"race_day.check_participants": "Deltagare",
"race_day.check_transponders": "Transpondrar",
"race_day.check_duplicate_transponders": "Dubbla transpondrar",
+ "race_day.check_duplicate_team_members": "Förare/bil i flera lag",
"race_day.check_grid": "Startgrid",
"race_day.check_audio": "Raceljud",
"race_day.lap_window_edit": "Justera varvgränser",
@@ -1164,6 +1166,7 @@ const TRANSLATIONS = {
"events.team_form_drivers": "Markera lagförare innan du sparar laget.",
"events.team_form_cars": "Markera lagbilar innan du sparar laget.",
"events.team_driver_fallback": "Inga förare matchade race-klassen eller deltagarlistan. Visar alla förare som fallback.",
+ "events.team_member_elsewhere": "Redan i {team} - kryssar du i flyttas den hit",
"events.no_teams": "Inga lag skapade ännu.",
"events.team_standings": "Lagställning",
"events.no_team_results": "Inga teamresultat ännu.",
@@ -1929,6 +1932,7 @@ const TRANSLATIONS = {
"race_day.check_participants": "Participants",
"race_day.check_transponders": "Transponders",
"race_day.check_duplicate_transponders": "Duplicate transponders",
+ "race_day.check_duplicate_team_members": "Driver/car on multiple teams",
"race_day.check_grid": "Start grid",
"race_day.check_audio": "Race audio",
"race_day.lap_window_edit": "Adjust lap limits",
@@ -2668,6 +2672,7 @@ const TRANSLATIONS = {
"events.team_form_drivers": "Select team drivers before saving the team.",
"events.team_form_cars": "Select team cars before saving the team.",
"events.team_driver_fallback": "No drivers matched the race class or participant list. Showing all drivers as fallback.",
+ "events.team_member_elsewhere": "Already on {team} - checking this moves them here",
"events.no_teams": "No teams created yet.",
"events.team_standings": "Team standings",
"events.no_team_results": "No team results yet.",
@@ -5012,6 +5017,7 @@ function renderEventManager(eventId) {
getEventDrivers,
getTeamDriverPool,
getEventTeams,
+ reassignTeamMembers,
normalizeBrandingConfig,
getSessionTypeChoices,
getRaceFormatPresets,
diff --git a/src/core_views.js b/src/core_views.js
index 3882c68..e9b02dd 100644
--- a/src/core_views.js
+++ b/src/core_views.js
@@ -567,6 +567,18 @@ export function renderDriversView(deps) {
state.drivers = state.drivers.filter((x) => x.id !== d.id);
state.sessions.forEach((s) => {
s.assignments = (s.assignments || []).filter((a) => a.driverId !== d.id);
+ s.driverIds = (s.driverIds || []).filter((id) => id !== d.id);
+ s.manualGridIds = (s.manualGridIds || []).filter((id) => id !== d.id);
+ });
+ // Otherwise a deleted driver stays a "member" of every team/participant list they
+ // were ever added to, silently inflating team/participant counts forever.
+ state.events.forEach((event) => {
+ if (!event.raceConfig) return;
+ event.raceConfig.driverIds = (event.raceConfig.driverIds || []).filter((id) => id !== d.id);
+ event.raceConfig.teams = (event.raceConfig.teams || []).map((team) => ({
+ ...team,
+ driverIds: (team.driverIds || []).filter((id) => id !== d.id),
+ }));
});
saveState();
renderView();
@@ -781,6 +793,13 @@ export function renderCarsView(deps) {
state.sessions.forEach((s) => {
s.assignments = (s.assignments || []).filter((a) => a.carId !== c.id);
});
+ state.events.forEach((event) => {
+ if (!event.raceConfig) return;
+ event.raceConfig.teams = (event.raceConfig.teams || []).map((team) => ({
+ ...team,
+ carIds: (team.carIds || []).filter((id) => id !== c.id),
+ }));
+ });
saveState();
renderView();
});
diff --git a/src/event_manager_controller.js b/src/event_manager_controller.js
index 90f723a..7570768 100644
--- a/src/event_manager_controller.js
+++ b/src/event_manager_controller.js
@@ -18,6 +18,7 @@ export function renderEventManagerView(context) {
getEventDrivers,
getTeamDriverPool,
getEventTeams,
+ reassignTeamMembers,
normalizeBrandingConfig,
getSessionTypeChoices,
getRaceFormatPresets,
@@ -555,7 +556,7 @@ export function renderEventManagerView(context) {
}
setFormError("teamCreateError", "");
const createdTeam = normalizeRaceTeam({ id: uid("team"), name, driverIds, carIds });
- const nextTeams = [...getEventTeams(event), createdTeam];
+ const nextTeams = [...reassignTeamMembers(getEventTeams(event), { excludeTeamId: createdTeam.id, driverIds, carIds }), createdTeam];
updateEvent((item) => ({
...item,
raceConfig: {
@@ -639,7 +640,7 @@ export function renderEventManagerView(context) {
return;
}
setFormError("teamEditError", "");
- const nextTeams = getEventTeams(event).map((team) =>
+ const nextTeams = reassignTeamMembers(getEventTeams(event), { excludeTeamId: editingTeam.id, driverIds, carIds }).map((team) =>
team.id === editingTeam.id ? normalizeRaceTeam({ ...team, name, driverIds, carIds }) : team
);
updateEvent((item) => ({
diff --git a/src/event_manager_view.js b/src/event_manager_view.js
index a064f8f..14088ed 100644
--- a/src/event_manager_view.js
+++ b/src/event_manager_view.js
@@ -47,6 +47,13 @@ export function renderEventManagerMarkup(context) {
getModeLabel,
renderTable,
} = context;
+ // Checking a driver/car here moves them off whichever other team already has them (see
+ // reassignTeamMembers in event_race_logic.js) - name that team here so the admin sees
+ // the move coming rather than discovering it after saving.
+ const driverTeamElsewhere = (driverId, excludeTeamId = "") =>
+ raceTeams.find((team) => team.id !== excludeTeamId && team.driverIds.includes(driverId))?.name || "";
+ const carTeamElsewhere = (carId, excludeTeamId = "") =>
+ raceTeams.find((team) => team.id !== excludeTeamId && team.carIds.includes(carId))?.name || "";
const sessionChannelMarkup = sessions
.map((session) => ({ session, markup: renderSessionChannelAnalysis(session) }))
.filter((entry) => entry.markup)
@@ -324,7 +331,7 @@ export function renderEventManagerMarkup(context) {
(driver) => `
`
)
@@ -344,7 +351,7 @@ export function renderEventManagerMarkup(context) {
(car) => `
`
)
@@ -757,7 +764,7 @@ export function renderEventManagerMarkup(context) {
(driver) => `
`
)
@@ -772,7 +779,7 @@ export function renderEventManagerMarkup(context) {
(car) => `
`
)
diff --git a/src/event_race_logic.js b/src/event_race_logic.js
index 1a9e28a..1d522ff 100644
--- a/src/event_race_logic.js
+++ b/src/event_race_logic.js
@@ -560,10 +560,36 @@ export function getEventTeams(event, { normalizeRaceTeam }) {
export function getTeamDriverPool(event, { getEventDrivers, state }) {
- return {
- drivers: Array.isArray(state?.drivers) ? [...state.drivers] : [],
- fallback: false,
- };
+ const scopedDrivers = getEventDrivers(event);
+ if (scopedDrivers.length) {
+ return { drivers: scopedDrivers, fallback: false };
+ }
+ // No driver matched the event's class or configured participant list (e.g. a team is
+ // being set up before drivers/participants exist yet) - fall back to every driver in
+ // the club rather than leaving the admin with zero checkboxes and no way to proceed.
+ const allDrivers = Array.isArray(state?.drivers) ? [...state.drivers] : [];
+ return { drivers: allDrivers, fallback: allDrivers.length > 0 };
+}
+
+
+
+// A driver or car can only be a member of one team at a time. There is no separate "move
+// to another team" action in the UI - ticking a driver's checkbox on a different team's
+// form IS the move - so saving a team's roster here must pull that driver/car out of
+// whichever other team currently has them, or the same driver ends up listed on two teams
+// at once (their live passings then get silently credited to only the first match).
+export function reassignTeamMembers(teams, { excludeTeamId = "", driverIds = [], carIds = [] } = {}) {
+ const driverSet = new Set(driverIds);
+ const carSet = new Set(carIds);
+ return (Array.isArray(teams) ? teams : []).map((team) => {
+ if (team.id === excludeTeamId) return team;
+ const nextDriverIds = (team.driverIds || []).filter((id) => !driverSet.has(id));
+ const nextCarIds = (team.carIds || []).filter((id) => !carSet.has(id));
+ if (nextDriverIds.length === (team.driverIds || []).length && nextCarIds.length === (team.carIds || []).length) {
+ return team;
+ }
+ return { ...team, driverIds: nextDriverIds, carIds: nextCarIds };
+ });
}
diff --git a/src/race_day_logic.js b/src/race_day_logic.js
index 9d954c0..46804d1 100644
--- a/src/race_day_logic.js
+++ b/src/race_day_logic.js
@@ -104,6 +104,17 @@ export function buildRaceDayPreflight(state, session, { backendAvailable = false
const missingCount = teamTransponders.filter((values) => !values.length).length;
transponders = teamTransponders.flat();
checks.push(check("transponders", missingCount ? "error" : "ok", { count: transponders.length, missingCount, teamRace: true }));
+ // Named explicitly rather than relying on the transponder-duplicate check below to
+ // catch this indirectly: that check only fires when the shared driver/car has a
+ // transponder set, and reports "duplicate transponders" - a confusing diagnosis for
+ // what is actually "this driver/car is on two teams at once".
+ const duplicateTeamDriverIds = duplicateValues(teams.flatMap((team) => team.driverIds || []));
+ const duplicateTeamCarIds = duplicateValues(teams.flatMap((team) => team.carIds || []));
+ const duplicateTeamMemberNames = [
+ ...duplicateTeamDriverIds.map((id) => state.drivers.find((driver) => driver.id === id)?.name).filter(Boolean),
+ ...duplicateTeamCarIds.map((id) => state.cars.find((car) => car.id === id)?.name).filter(Boolean),
+ ];
+ checks.push(check("duplicate_team_members", duplicateTeamMemberNames.length ? "error" : "ok", { duplicateTeamMemberNames }));
} else {
entrantIds = getRaceDriverIds(state, session, event);
checks.push(check("participants", entrantIds.length ? "ok" : "error", { count: entrantIds.length }));
diff --git a/src/race_day_view.js b/src/race_day_view.js
index 855b860..d9873e7 100644
--- a/src/race_day_view.js
+++ b/src/race_day_view.js
@@ -11,7 +11,7 @@ export function getRaceDayCheckAction(item, session) {
const view = session?.mode === "track" ? "cars" : "drivers";
return { view, label: "nav." + view };
}
- if (["participants", "grid", "transponders", "duplicate_transponders", "lap_window"].includes(item.id)) {
+ if (["participants", "grid", "transponders", "duplicate_transponders", "duplicate_team_members", "lap_window"].includes(item.id)) {
const view = session?.mode === "track" ? "events" : "race_setup";
return { view, label: "nav." + view };
}
@@ -32,6 +32,7 @@ function renderPreflightDetail(item, { t, formatLap, escapeHtml }) {
: t("race_day.count", { count: item.count || 0 });
}
if (item.id === "duplicate_transponders" && item.duplicates?.length) return escapeHtml(item.duplicates.join(", "));
+ if (item.id === "duplicate_team_members" && item.duplicateTeamMemberNames?.length) return escapeHtml(item.duplicateTeamMemberNames.join(", "));
if (item.id === "grid") return item.notRequired ? t("race_day.not_required") : `${item.count || 0}/${item.expectedCount || 0}`;
return t(`race_day.check_${item.level}`);
}
diff --git a/src/styles.css b/src/styles.css
index 4570b60..254deb8 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -2506,6 +2506,12 @@ select:focus {
width: auto;
}
+.check-card small {
+ display: block;
+ color: var(--muted);
+ font-size: 0.72rem;
+}
+
.team-card,
.team-standings-block {
border: 1px solid var(--line);
diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js
index 18fce09..5af3137 100644
--- a/tests/browser_smoke.js
+++ b/tests/browser_smoke.js
@@ -2,6 +2,7 @@ import { runOverlaySmoke } from "./browser_overlays.js";
import { runRaceSetupSmoke } from "./browser_race_setup.js";
import { runRaceDaySmoke } from "./browser_race_day.js";
import { runRaceDayStartSequencesSmoke } from "./browser_race_day_start_sequences.js";
+import { runTeamMembershipSmoke } from "./browser_team_membership.js";
import { runObsPopupSmoke } from "./browser_obs_popup.js";
import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js";
import assert from "node:assert/strict";
@@ -134,6 +135,7 @@ try {
console.log("PASS create/edit/reload and draft retention: " + collection);
}
await runRaceSetupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause });
+ await runTeamMembershipSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base });
await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs });
await runRaceDayStartSequencesSmoke({ evaluate, navigate, waitFor, pause, base, dialogs });
await runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs, realClick });
diff --git a/tests/browser_team_membership.js b/tests/browser_team_membership.js
new file mode 100644
index 0000000..f248e5b
--- /dev/null
+++ b/tests/browser_team_membership.js
@@ -0,0 +1,78 @@
+import assert from "node:assert/strict";
+
+// Regression coverage for a real bug the user reported: moving a driver from one team to
+// another (by ticking them on the new team's edit form - there is no separate "move"
+// action) used to only ever ADD the driver to the new team, never remove them from the
+// old one, so the same driver ended up listed on two teams at once. Also covers the two
+// related findings from the same investigation: getTeamDriverPool used to ignore the
+// event's class/participant scoping entirely (showing every driver in the club), and
+// deleting a driver left them behind in any team's roster forever.
+//
+// The event/session fixtures are seeded directly rather than driven through the 4-step
+// race wizard (already covered by browser_race_setup.js) - only team management itself is
+// under test here, and the Teams section only renders once a team_race session exists
+// (see showTeamsSection in event_manager_controller.js).
+export async function runTeamMembershipSmoke({ evaluate, navigate, savedState, waitFor, pause, base }) {
+ const click = (id) => evaluate(`document.getElementById(${JSON.stringify(id)}).click()`);
+ const submit = (id) => evaluate(`document.getElementById(${JSON.stringify(id)}).requestSubmit()`);
+ const findEvent = async (id) => (await savedState()).events.find((event) => event.id === id);
+ const patchState = async (patch) => {
+ const current = await savedState();
+ Object.assign(current, patch);
+ const response = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current) });
+ assert.equal(response.ok, true);
+ };
+
+ const driverA = { id: "team_membership_driver_a", name: "Team Membership Driver A", classId: "test_class", transponder: "7810001" };
+ const driverB = { id: "team_membership_driver_b", name: "Team Membership Driver B", classId: "test_class", transponder: "7810002" };
+ const raceEvent = {
+ id: "team_membership_event", name: "Team Membership Event", classId: "test_class", mode: "race",
+ raceConfig: { participantsConfigured: true, driverIds: [driverA.id, driverB.id], teams: [] },
+ };
+ const teamRaceSession = { id: "team_membership_session", eventId: raceEvent.id, name: "Team Race Session", type: "team_race", mode: "race", durationMin: 45, status: "ready" };
+ const seeded = await savedState();
+ await patchState({
+ drivers: [...seeded.drivers, driverA, driverB],
+ events: [...seeded.events, raceEvent],
+ sessions: [...seeded.sessions, teamRaceSession],
+ });
+
+ await navigate("race_setup", "raceWizardStepForm");
+ await click("event-manage-" + raceEvent.id);
+ await waitFor(async () => await evaluate(`Boolean(document.getElementById("teamForm"))`), "teams section visible for a race-mode event with a team_race session");
+
+ await evaluate(`(() => { const form = document.getElementById("teamForm"); form.elements.namedItem("teamName").value = "Membership Team A"; })()`);
+ await evaluate(`document.querySelector('[form="teamForm"][name="teamDriverIds"][value="${driverA.id}"]').checked = true`);
+ await submit("teamForm");
+ await waitFor(async () => (await findEvent(raceEvent.id)).raceConfig.teams.some((team) => team.name === "Membership Team A"), "team A created");
+
+ await evaluate(`(() => { const form = document.getElementById("teamForm"); form.elements.namedItem("teamName").value = "Membership Team B"; })()`);
+ await evaluate(`document.querySelector('[form="teamForm"][name="teamDriverIds"][value="${driverB.id}"]').checked = true`);
+ await submit("teamForm");
+ await waitFor(async () => (await findEvent(raceEvent.id)).raceConfig.teams.some((team) => team.name === "Membership Team B"), "team B created");
+ const teamB = (await findEvent(raceEvent.id)).raceConfig.teams.find((team) => team.name === "Membership Team B");
+
+ await click("team-edit-" + teamB.id);
+ await waitFor(async () => await evaluate(`Boolean(document.getElementById("teamEditForm"))`), "team B edit form open");
+ const hintText = await evaluate(`document.querySelector('#teamEditForm [name="teamDriverIds"][value="${driverA.id}"]')?.closest("label")?.textContent || ""`);
+ assert.match(hintText, /Membership Team A/, "the edit form names the other team a driver is currently on, before the move happens");
+
+ await evaluate(`document.querySelector('#teamEditForm [name="teamDriverIds"][value="${driverA.id}"]').checked = true`);
+ await pause(5500); // outlast the periodic version poll, matching the existing team-edit browser coverage
+ await click("teamEditSave");
+ await waitFor(async () => (await findEvent(raceEvent.id)).raceConfig.teams.find((team) => team.name === "Membership Team B").driverIds.includes(driverA.id), "driver A saved onto team B");
+
+ const afterMove = await findEvent(raceEvent.id);
+ const teamA = afterMove.raceConfig.teams.find((team) => team.name === "Membership Team A");
+ const teamBAfter = afterMove.raceConfig.teams.find((team) => team.name === "Membership Team B");
+ assert.deepEqual(teamA.driverIds, [], "driver A was removed from team A when moved to team B - the reported duplicate-membership bug is fixed");
+ assert.deepEqual([...teamBAfter.driverIds].sort(), [driverA.id, driverB.id].sort(), "team B now has both drivers");
+ console.log("PASS moving a driver onto a different team's roster removes them from their previous team, with a warning shown before saving");
+
+ await navigate("drivers", "driverForm");
+ await click("driver-delete-" + driverB.id);
+ await waitFor(async () => !(await savedState()).drivers.some((driver) => driver.id === driverB.id), "driver B deleted");
+ const afterDelete = (await findEvent(raceEvent.id)).raceConfig.teams.find((team) => team.name === "Membership Team B");
+ assert.deepEqual(afterDelete.driverIds, [driverA.id], "a deleted driver no longer lingers in a team's roster");
+ console.log("PASS deleting a driver purges them from every team's roster too");
+}
diff --git a/tests/race_day_logic.test.js b/tests/race_day_logic.test.js
index 6030733..6fbd686 100644
--- a/tests/race_day_logic.test.js
+++ b/tests/race_day_logic.test.js
@@ -67,6 +67,31 @@ test("open practice allows an unset lap window but still requires the main decod
assert.equal(result.ready, false);
});
+test("team_race preflight blocks a driver saved onto two teams at once, naming them instead of a generic transponder error", () => {
+ const state = createState();
+ state.sessions.push({ id: "session_team", eventId: "event_1", name: "Team Race", mode: "race", type: "team_race", status: "ready", startMode: "mass" });
+ state.events[0].raceConfig.teams = [
+ { id: "team_a", name: "Team A", driverIds: ["driver_1"], carIds: [] },
+ { id: "team_b", name: "Team B", driverIds: ["driver_1", "driver_2"], carIds: [] },
+ ];
+ const result = buildRaceDayPreflight(state, state.sessions.find((s) => s.id === "session_team"), { backendAvailable: true });
+ assert.equal(result.ready, false);
+ const duplicateCheck = result.checks.find((item) => item.id === "duplicate_team_members");
+ assert.equal(duplicateCheck.level, "error");
+ assert.deepEqual(duplicateCheck.duplicateTeamMemberNames, ["One"]);
+});
+
+test("team_race preflight accepts teams with no members in common", () => {
+ const state = createState();
+ state.sessions.push({ id: "session_team", eventId: "event_1", name: "Team Race", mode: "race", type: "team_race", status: "ready", startMode: "mass" });
+ state.events[0].raceConfig.teams = [
+ { id: "team_a", name: "Team A", driverIds: ["driver_1"], carIds: [] },
+ { id: "team_b", name: "Team B", driverIds: ["driver_2"], carIds: [] },
+ ];
+ const result = buildRaceDayPreflight(state, state.sessions.find((s) => s.id === "session_team"), { backendAvailable: true });
+ assert.equal(result.checks.find((item) => item.id === "duplicate_team_members").level, "ok");
+});
+
test("preflight presents blockers before warnings without changing the original checks", async () => {
const { sortRaceDayChecks } = await import("../src/race_day_view.js");
const checks = [{ id: "audio", level: "warning" }, { id: "session", level: "ok" }, { id: "decoder", level: "error" }];
diff --git a/tests/race_timing_logic.test.js b/tests/race_timing_logic.test.js
index 8e10e10..9047a95 100644
--- a/tests/race_timing_logic.test.js
+++ b/tests/race_timing_logic.test.js
@@ -8,7 +8,9 @@ import {
buildRaceSessionsFromWizard,
generateFinalsForRace,
generateQualifyingForRace,
+ getTeamDriverPool,
normalizeEvent,
+ reassignTeamMembers,
reseedUpcomingQualifying,
} from "../src/event_race_logic.js";
import {
@@ -135,6 +137,43 @@ test("event normalization preserves the practice-only marker", () => {
assert.equal(event.raceConfig.presetId, "custom");
});
+test("reassignTeamMembers strips a moved driver/car from every other team, leaving the excluded team untouched", () => {
+ const teams = [
+ { id: "team_a", name: "A", driverIds: ["d1", "d2"], carIds: ["c1"] },
+ { id: "team_b", name: "B", driverIds: ["d3"], carIds: [] },
+ ];
+ const result = reassignTeamMembers(teams, { excludeTeamId: "team_b", driverIds: ["d1"], carIds: ["c1"] });
+ const teamA = result.find((team) => team.id === "team_a");
+ const teamB = result.find((team) => team.id === "team_b");
+ assert.deepEqual(teamA.driverIds, ["d2"], "d1 was moved out of team A");
+ assert.deepEqual(teamA.carIds, [], "c1 was moved out of team A too");
+ assert.deepEqual(teamB.driverIds, ["d3"], "the team being saved is left for the caller to update, not touched here");
+});
+
+test("reassignTeamMembers returns unchanged team objects as-is when nothing overlaps", () => {
+ const teams = [{ id: "team_a", name: "A", driverIds: ["d1"], carIds: [] }];
+ const result = reassignTeamMembers(teams, { excludeTeamId: "team_b", driverIds: ["d9"], carIds: [] });
+ assert.strictEqual(result[0], teams[0]);
+});
+
+test("getTeamDriverPool scopes to the event's class/participants, only falling back to every driver when that list is empty", () => {
+ const state = {
+ drivers: [
+ { id: "d1", name: "One", classId: "2wd" },
+ { id: "d2", name: "Two", classId: "4wd" },
+ ],
+ };
+ const getEventDrivers = (event) => state.drivers.filter((driver) => !event?.classId || driver.classId === event.classId);
+
+ const scoped = getTeamDriverPool({ classId: "2wd", raceConfig: {} }, { getEventDrivers, state });
+ assert.deepEqual(scoped.drivers.map((driver) => driver.id), ["d1"]);
+ assert.equal(scoped.fallback, false);
+
+ const noMatches = getTeamDriverPool({ classId: "unknown_class", raceConfig: {} }, { getEventDrivers, state });
+ assert.deepEqual(noMatches.drivers.map((driver) => driver.id), ["d1", "d2"], "falls back to every driver rather than leaving zero checkboxes");
+ assert.equal(noMatches.fallback, true);
+});
+
test("race wizard creates configured practice, qualifying, and endurance sessions", () => {
const event = {
id: "event_1",