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: - Editing in UI:
- classes, event names/dates, drivers, and cars can be edited directly - 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 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: - Live race control:
- countdown during timed sessions - countdown during timed sessions
- auto-finish at end of session time with `Race is finished` - 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: - Redigering i UI:
- Klasser, eventnamn/datum, förare och bilar kan redigeras direkt - 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 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: - Live race-kontroll:
- Nedräkning under pågående session - Nedräkning under pågående session
- Auto-finish vid tidslut med status `Race is finished` - Auto-finish vid tidslut med status `Race is finished`

View File

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

View File

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