Add broader search, brand exports, and denser overlay

This commit is contained in:
larssand
2026-03-19 20:38:58 +01:00
parent e37aeff43a
commit fb9dcf1b9c
4 changed files with 86 additions and 61 deletions

View File

@@ -90,7 +90,8 @@ JMK RB RaceController is an RC timing and race-control system with support for s
- Editing in UI:
- classes, event names/dates, drivers, and cars can be edited directly
- drivers and cars support optional `brand` fields for team, sponsor, manufacturer, or model data
- `Drivers` and `Cars` can be filtered by brand directly in the UI
- `Drivers` and `Cars` can be searched by name, transponder, or brand directly in the UI
- race start lists and heat sheets include driver brand in print, CSV, and PDF exports
- Live race control:
- countdown during timed sessions
- auto-finish at end of session time with `Race is finished`

View File

@@ -81,7 +81,8 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he
- Redigering i UI:
- Klasser, eventnamn/datum, förare och bilar kan redigeras direkt
- förare och bilar har valfria brandfält för team, sponsor, märke eller modell
- `Förare` och `Bilar` kan filtreras direkt på brand i UI
- `Förare` och `Bilar` kan sökas på namn, transponder eller brand direkt i UI
- startlistor och heatsheets tar med förarens brand i print, CSV och PDF-export
- Live race-kontroll:
- Nedräkning under pågående session
- Auto-finish vid tidslut med status `Race is finished`

View File

@@ -87,14 +87,14 @@ const TRANSLATIONS = {
"drivers.create": "Skapa förare",
"drivers.name_placeholder": "Förarnamn",
"drivers.brand_placeholder": "Team / märke (valfritt)",
"drivers.brand_filter_placeholder": "Filtrera på team / märke",
"drivers.brand_filter_placeholder": "Sök namn / transponder / brand",
"drivers.transponder_placeholder": "Personlig transponder (valfritt)",
"drivers.add": "Lägg till förare",
"drivers.title": "Förare",
"cars.create": "Skapa bil",
"cars.name_placeholder": "Bilnamn eller nummer",
"cars.brand_placeholder": "Märke / modell (valfritt)",
"cars.brand_filter_placeholder": "Filtrera på märke / modell",
"cars.brand_filter_placeholder": "Sök namn / transponder / brand",
"cars.transponder_placeholder": "Bilens transponder",
"cars.add": "Lägg till bil",
"cars.title": "Bilar",
@@ -729,14 +729,14 @@ const TRANSLATIONS = {
"drivers.create": "Create Driver",
"drivers.name_placeholder": "Driver name",
"drivers.brand_placeholder": "Team / brand (optional)",
"drivers.brand_filter_placeholder": "Filter by team / brand",
"drivers.brand_filter_placeholder": "Search name / transponder / brand",
"drivers.transponder_placeholder": "Personal transponder (optional)",
"drivers.add": "Add Driver",
"drivers.title": "Drivers",
"cars.create": "Create Track Car",
"cars.name_placeholder": "Car name or number",
"cars.brand_placeholder": "Brand / model (optional)",
"cars.brand_filter_placeholder": "Filter by brand / model",
"cars.brand_filter_placeholder": "Search name / transponder / brand",
"cars.transponder_placeholder": "Car transponder",
"cars.add": "Add Car",
"cars.title": "Cars",
@@ -2841,10 +2841,12 @@ function renderDrivers() {
const classOptions = state.classes
.map((c) => `<option value="${c.id}">${escapeHtml(c.name)}</option>`)
.join("");
const driverSearch = driverBrandFilter.trim().toLowerCase();
const filteredDrivers = state.drivers.filter((driver) =>
String(driver.brand || "")
.toLowerCase()
.includes(driverBrandFilter.trim().toLowerCase())
!driverSearch ||
[driver.name, driver.transponder, driver.brand]
.map((value) => String(value || "").toLowerCase())
.some((value) => value.includes(driverSearch))
);
const editingDriver = state.drivers.find((driver) => driver.id === selectedDriverEditId) || null;
@@ -3013,10 +3015,12 @@ function renderDrivers() {
}
function renderCars() {
const carSearch = carBrandFilter.trim().toLowerCase();
const filteredCars = state.cars.filter((car) =>
String(car.brand || "")
.toLowerCase()
.includes(carBrandFilter.trim().toLowerCase())
!carSearch ||
[car.name, car.transponder, car.brand]
.map((value) => String(value || "").toLowerCase())
.some((value) => value.includes(carSearch))
);
const editingCar = state.cars.find((car) => car.id === selectedCarEditId) || null;
dom.view.innerHTML = `
@@ -5910,10 +5914,18 @@ function renderOverlayLeaderboard(rows) {
</div>
</div>
</div>
<div class="overlay-race-metric">
<label>${t("table.laps")}</label>
<strong>${row.laps ?? 0}</strong>
</div>
<div class="overlay-race-metric">
<label>${t("table.result")}</label>
<strong>${escapeHtml(row.resultDisplay)}</strong>
</div>
<div class="overlay-race-metric">
<label>${t("table.gap")}</label>
<strong>${escapeHtml(row.gapDisplay || row.gapAhead || "-")}</strong>
</div>
<div class="overlay-race-metric">
<label>${t("table.ahead_gap")}</label>
<strong>${escapeHtml(row.gapAhead || "-")}</strong>
@@ -8463,16 +8475,18 @@ function buildRaceStartListsHtml(event) {
${
entries.length
? renderTable(
[t("events.slot"), t("table.driver"), t("table.transponder")],
entries.map(
(entry) => `
[t("events.slot"), t("table.driver"), t("table.brand"), t("table.transponder")],
entries.map((entry) => {
const driver = state.drivers.find((item) => item.id === entry.id);
return `
<tr>
<td>${entry.slot}</td>
<td>${escapeHtml(entry.name)}</td>
<td>${escapeHtml(driver?.brand || "-")}</td>
<td>${escapeHtml(entry.meta || "-")}</td>
</tr>
`
)
`;
})
)
: `<p>${t("common.no_entries")}</p>`
}
@@ -8684,8 +8698,11 @@ async function exportRaceStartListsPdf(event) {
const sections = sessions.map((session) =>
buildPdfSection(
`${session.name}${getSessionTypeLabel(session.type)}`,
[t("events.slot"), t("table.driver"), t("table.transponder")],
getSessionGridEntries(session).map((entry) => [String(entry.slot), entry.name, entry.meta || "-"])
[t("events.slot"), t("table.driver"), t("table.brand"), t("table.transponder")],
getSessionGridEntries(session).map((entry) => {
const driver = state.drivers.find((item) => item.id === entry.id);
return [String(entry.slot), entry.name, driver?.brand || "-", entry.meta || "-"];
})
)
);
@@ -8815,8 +8832,11 @@ async function exportSessionHeatSheetPdf(session) {
sections: [
buildPdfSection(
`${session.name}${getSessionTypeLabel(session.type)}`,
[t("events.slot"), t("table.driver"), t("table.transponder")],
getSessionGridEntries(session).map((entry) => [String(entry.slot), entry.name, entry.meta || "-"])
[t("events.slot"), t("table.driver"), t("table.brand"), t("table.transponder")],
getSessionGridEntries(session).map((entry) => {
const driver = state.drivers.find((item) => item.id === entry.id);
return [String(entry.slot), entry.name, driver?.brand || "-", entry.meta || "-"];
})
),
],
});
@@ -8848,16 +8868,18 @@ function buildSessionHeatSheetHtml(session) {
${
entries.length
? renderTable(
[t("events.slot"), t("table.driver"), t("table.transponder")],
entries.map(
(entry) => `
[t("events.slot"), t("table.driver"), t("table.brand"), t("table.transponder")],
entries.map((entry) => {
const driver = state.drivers.find((item) => item.id === entry.id);
return `
<tr>
<td>${entry.slot}</td>
<td>${escapeHtml(entry.name)}</td>
<td>${escapeHtml(driver?.brand || "-")}</td>
<td>${escapeHtml(entry.meta || "-")}</td>
</tr>
`
)
`;
})
)
: `<p>${t("common.no_entries")}</p>`
}
@@ -8868,7 +8890,7 @@ function exportSessionHeatSheet(session) {
const event = state.events.find((item) => item.id === session.eventId);
const entries = getSessionGridEntries(session);
const rows = [
["event", "class", "session", "type", "start_mode", "duration_min", "slot", "driver", "transponder"],
["event", "class", "session", "type", "start_mode", "duration_min", "slot", "driver", "brand", "transponder"],
...entries.map((entry) => [
event?.name || "",
getClassName(event?.classId || ""),
@@ -8878,6 +8900,7 @@ function exportSessionHeatSheet(session) {
String(session.durationMin || ""),
String(entry.slot),
entry.name,
state.drivers.find((item) => item.id === entry.id)?.brand || "",
entry.meta || "",
]),
];

View File

@@ -879,8 +879,8 @@ select:focus {
.overlay-board {
display: grid;
grid-template-columns: minmax(0, 1.92fr) minmax(180px, 0.31fr);
gap: 8px;
grid-template-columns: minmax(0, 1.98fr) minmax(156px, 0.27fr);
gap: 6px;
}
.overlay-board-tv {
@@ -982,7 +982,7 @@ select:focus {
}
.overlay-table-wrap {
padding: 4px 6px 6px;
padding: 3px 5px 5px;
}
.overlay-display-wrap {
@@ -1002,8 +1002,8 @@ select:focus {
display: flex;
justify-content: space-between;
align-items: end;
gap: 8px;
padding: 7px 10px;
gap: 6px;
padding: 6px 8px;
background:
linear-gradient(135deg, rgba(225, 6, 0, 0.18), rgba(225, 6, 0, 0.04)),
rgba(7, 12, 20, 0.92);
@@ -1018,13 +1018,13 @@ select:focus {
.overlay-fastest-banner strong {
display: block;
margin-top: 2px;
margin-top: 1px;
font-family: Orbitron, sans-serif;
font-size: clamp(1.02rem, 1.7vw, 1.45rem);
font-size: clamp(0.94rem, 1.45vw, 1.26rem);
}
.overlay-leaderboard-card {
padding: 5px;
padding: 4px;
}
.overlay-leaderboard-card-tv {
@@ -1045,13 +1045,13 @@ select:focus {
}
.overlay-fastest-driver {
font-size: 0.76rem;
font-size: 0.68rem;
text-align: right;
}
.overlay-fastest-meta {
color: var(--muted);
font-size: 0.58rem;
font-size: 0.52rem;
text-transform: uppercase;
letter-spacing: 0.06em;
white-space: nowrap;
@@ -1063,14 +1063,14 @@ select:focus {
.overlay-race-metric strong,
.overlay-race-best strong {
font-size: clamp(0.78rem, 0.98vw, 0.92rem);
line-height: 1.05;
font-size: clamp(0.72rem, 0.88vw, 0.84rem);
line-height: 1.02;
}
.overlay-race-pos .pos-pill {
min-width: 26px;
height: 26px;
font-size: 0.76rem;
min-width: 22px;
height: 22px;
font-size: 0.68rem;
}
.overlay-shell-dense .pill {
@@ -1123,12 +1123,12 @@ select:focus {
.overlay-side {
display: grid;
gap: 6px;
font-size: 0.78rem;
gap: 4px;
font-size: 0.72rem;
}
.overlay-side-card {
padding: 6px;
padding: 5px;
}
.overlay-rotating-card {
@@ -1136,15 +1136,15 @@ select:focus {
}
.overlay-side-card h3 {
margin: 0 0 3px;
font-size: 0.78rem;
margin: 0 0 2px;
font-size: 0.7rem;
}
.overlay-passing {
display: flex;
justify-content: space-between;
gap: 5px;
padding: 3px 0;
gap: 4px;
padding: 2px 0;
border-bottom: 1px solid var(--line);
}
@@ -1153,13 +1153,13 @@ select:focus {
}
.overlay-side-card .overlay-passing strong {
font-size: 0.72rem;
font-size: 0.66rem;
line-height: 1.15;
}
.overlay-side-card .overlay-passing span {
font-family: Orbitron, sans-serif;
font-size: 0.64rem;
font-size: 0.58rem;
color: var(--text);
white-space: nowrap;
}
@@ -1171,15 +1171,15 @@ select:focus {
.overlay-race-list {
display: grid;
gap: 4px;
gap: 3px;
}
.overlay-race-row {
display: grid;
grid-template-columns: 38px minmax(170px, 1.72fr) repeat(3, minmax(90px, 0.62fr)) minmax(95px, 0.66fr);
gap: 6px;
grid-template-columns: 34px minmax(160px, 1.7fr) repeat(4, minmax(78px, 0.54fr)) minmax(86px, 0.58fr);
gap: 5px;
align-items: center;
padding: 5px 7px;
padding: 4px 6px;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
@@ -1202,15 +1202,15 @@ select:focus {
}
.overlay-race-driver strong {
font-size: clamp(0.82rem, 1.04vw, 0.96rem);
line-height: 1.05;
font-size: clamp(0.75rem, 0.92vw, 0.88rem);
line-height: 1.02;
}
.overlay-race-driver span,
.overlay-race-metric label,
.overlay-race-best label {
color: var(--muted);
font-size: 0.54rem;
font-size: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
@@ -1225,14 +1225,14 @@ select:focus {
}
.overlay-prediction {
margin-top: 2px;
margin-top: 1px;
}
.overlay-prediction-meta {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 2px;
gap: 6px;
margin-bottom: 1px;
}
.overlay-prediction-meta label,