feat(series): run each round from the series page
Step 3 becomes a rounds table: pick drivers inline (copy from the previous round, add a late entry), set qualifying rounds/finals/max cars per heat per round, see the status, and create qualifying and finals from there. Qualifying heats are now split evenly so nobody drives alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
153
src/app.js
153
src/app.js
@@ -9,7 +9,7 @@ import { recordTransponderSighting, buildWarmupCheck, createPrologue, getPrologu
|
||||
import { findTransponderConflict, getDriverProfileId } from "./driver_profile_logic.js";
|
||||
import { renderRaceDayView, refreshRaceDayWarmup, refreshRaceDayPrologueClock, updateRaceDayStartDisplay } from "./race_day_view.js";
|
||||
import { beginSession as beginSessionControl, finishSession as finishSessionControl, pauseSession as pauseSessionControl, resumeSession as resumeSessionControl } from "./session_control.js";
|
||||
import { normalizeSeries, buildSeriesStandings } from "./series_logic.js";
|
||||
import { normalizeSeries, buildSeriesStandings, getSeriesRoundStatus } from "./series_logic.js";
|
||||
import { renderSeriesView } from "./series_view.js";
|
||||
import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudioSettings, isSilencedDuringRace } from "./race_audio.js";
|
||||
|
||||
@@ -518,7 +518,7 @@ const getSeriesStandings = (series) => buildSeriesStandings(series, {
|
||||
classNames: new Map(state.classes.map((item) => [item.id, item.name])),
|
||||
});
|
||||
// One race per date and class, named "<series> #<round> <class>", with the chosen race format.
|
||||
const createSeriesEvents = (series, dates, presetId) => {
|
||||
const createSeriesEvents = (series, dates, presetId, format = {}) => {
|
||||
const existingDates = new Set(state.events.filter((event) => series.eventIds.includes(event.id)).map((event) => event.date));
|
||||
const allDates = [...new Set([...existingDates, ...dates])].sort();
|
||||
let created = 0;
|
||||
@@ -529,6 +529,7 @@ const createSeriesEvents = (series, dates, presetId) => {
|
||||
const event = normalizeEvent({ id: uid("event"), name: `${series.name} #${round} ${getClassName(classId)}`, date, classId, mode: "race" });
|
||||
applyRaceFormatPreset(event, presetId);
|
||||
state.events.push(event);
|
||||
if (Object.keys(format).length) setSeriesRoundFormat(event.id, format);
|
||||
series.eventIds.push(event.id);
|
||||
created += 1;
|
||||
});
|
||||
@@ -536,6 +537,84 @@ const createSeriesEvents = (series, dates, presetId) => {
|
||||
saveState();
|
||||
return created;
|
||||
};
|
||||
// --- Series hub: each round (one race) handled right from the series page ----------------
|
||||
const byDriverName = (a, b) => String(a.name || "").localeCompare(String(b.name || ""), undefined, { sensitivity: "base" });
|
||||
const getSeriesRounds = (series) => state.events
|
||||
.filter((event) => series.eventIds.includes(event.id))
|
||||
.sort((a, b) => String(a.date || "").localeCompare(String(b.date || "")) || getClassName(a.classId).localeCompare(getClassName(b.classId), undefined, { numeric: true }))
|
||||
.map((event) => {
|
||||
const sessions = getSessionsForEvent(event.id);
|
||||
const qualSessions = sessions.filter((session) => session.type === "qualification");
|
||||
return {
|
||||
event,
|
||||
className: getClassName(event.classId),
|
||||
classDrivers: state.drivers.filter((driver) => driver.classId === event.classId).sort(byDriverName),
|
||||
participantIds: getEventDrivers(event).map((driver) => driver.id),
|
||||
status: getSeriesRoundStatus(sessions),
|
||||
qualHeatCount: new Set(qualSessions.map((session) => String(session.name || "").split(" ").pop())).size,
|
||||
finalCount: sessions.filter((session) => session.type === "final").length,
|
||||
};
|
||||
});
|
||||
const findSeriesEvent = (eventId) => state.events.find((event) => event.id === eventId) || null;
|
||||
// Format per round (can differ between rounds and classes); locked once qualifying exists.
|
||||
const setSeriesRoundFormat = (eventId, values) => {
|
||||
const event = findSeriesEvent(eventId);
|
||||
if (!event) return;
|
||||
const config = event.raceConfig;
|
||||
config.qualifyingRounds = Math.max(1, Math.round(Number(values.qualifyingRounds ?? config.qualifyingRounds) || 1));
|
||||
config.finalLegs = Math.max(1, Math.round(Number(values.finalLegs ?? config.finalLegs) || 1));
|
||||
config.carsPerHeat = Math.max(2, Math.round(Number(values.carsPerHeat ?? config.carsPerHeat) || 2));
|
||||
config.carsPerFinal = Math.max(2, Math.round(Number(values.carsPerHeat ?? config.carsPerFinal) || config.carsPerFinal || 2));
|
||||
// Two of three finals count (or all, with fewer than three).
|
||||
config.countedFinalLegs = config.finalLegs >= 3 ? config.finalLegs - 1 : config.finalLegs;
|
||||
config.presetId = "custom";
|
||||
saveState();
|
||||
};
|
||||
const setSeriesRoundParticipants = (eventId, driverIds) => {
|
||||
const event = findSeriesEvent(eventId);
|
||||
if (!event) return;
|
||||
event.raceConfig.driverIds = [...new Set(driverIds)];
|
||||
event.raceConfig.participantsConfigured = true;
|
||||
saveState();
|
||||
};
|
||||
// Same starting field as the previous round of the same class in this series.
|
||||
const copySeriesRoundParticipants = (series, eventId) => {
|
||||
const rounds = getSeriesRounds(series);
|
||||
const index = rounds.findIndex((round) => round.event.id === eventId);
|
||||
const previous = rounds.slice(0, Math.max(0, index)).reverse().find((round) => round.event.classId === rounds[index]?.event.classId);
|
||||
if (!previous) return false;
|
||||
setSeriesRoundParticipants(eventId, previous.participantIds.filter((id) => rounds[index].classDrivers.some((driver) => driver.id === id)));
|
||||
return true;
|
||||
};
|
||||
// A driver turning up on the day: registered in the round's class (and the series' club for a
|
||||
// members-only series) and ticked for this round.
|
||||
const addDriverToSeriesRound = (series, eventId, { name, transponder }) => {
|
||||
const event = findSeriesEvent(eventId);
|
||||
const cleanName = String(name || "").trim();
|
||||
if (!event || !cleanName) return false;
|
||||
const driver = normalizeDriver({ id: uid("driver"), name: cleanName, classId: event.classId, transponder: String(transponder || "").trim(), club: series.scoring === "club" ? series.club : "" });
|
||||
state.drivers.push(driver);
|
||||
setSeriesRoundParticipants(eventId, [...getEventDrivers(event).map((item) => item.id), driver.id]);
|
||||
return true;
|
||||
};
|
||||
const generateSeriesRoundQualifying = (eventId) => {
|
||||
const event = findSeriesEvent(eventId);
|
||||
if (!event) return 0;
|
||||
const created = generateQualifyingForRace(event);
|
||||
saveState();
|
||||
return created;
|
||||
};
|
||||
const generateSeriesRoundFinals = (eventId) => {
|
||||
const event = findSeriesEvent(eventId);
|
||||
if (!event) return 0;
|
||||
const created = generateFinalsForRace(event);
|
||||
saveState();
|
||||
return created;
|
||||
};
|
||||
const openSeriesRound = (eventId) => {
|
||||
selectedEventEditId = eventId;
|
||||
navigateToView("race_setup");
|
||||
};
|
||||
const seriesCell = (table, row, event) => {
|
||||
const result = row.results.find((item) => item.eventId === event.id);
|
||||
if (!event.completed || !result) return "";
|
||||
@@ -569,7 +648,7 @@ const exportSeriesPdf = async (series, tables) => {
|
||||
)),
|
||||
});
|
||||
};
|
||||
const renderSeries = () => renderSeriesView({ state, dom, t, escapeHtml, saveState, renderView, uid, getClassName, getSeriesStandings, createSeriesEvents, getRaceFormatPresets, exportSeriesPdf, printSeries });
|
||||
const renderSeries = () => renderSeriesView({ state, dom, t, escapeHtml, saveState, renderView, uid, getClassName, getSeriesStandings, createSeriesEvents, getRaceFormatPresets, exportSeriesPdf, printSeries, getSeriesRounds, setSeriesRoundFormat, setSeriesRoundParticipants, copySeriesRoundParticipants, addDriverToSeriesRound, generateSeriesRoundQualifying, generateSeriesRoundFinals, openSeriesRound });
|
||||
const renderArchive = () => renderResultsArchiveView({
|
||||
state, dom, t, escapeHtml, formatLap, formatRaceClock, renderTable,
|
||||
getClassName, getEventName, buildLeaderboard, getCompetitorPassings,
|
||||
@@ -715,6 +794,38 @@ const TRANSLATIONS = {
|
||||
"series.step3": "3 · Deltävlingar ({count})",
|
||||
"series.step4": "4 · Ställning",
|
||||
"series.classes_all": "Alla",
|
||||
"series.round_participants": "Deltagare: {count} av {total} i klassen",
|
||||
"series.copy_previous": "Kopiera från förra",
|
||||
"series.no_previous_round": "Ingen tidigare deltävling i samma klass.",
|
||||
"series.no_class_drivers": "Inga förare i klassen ännu - lägg till nedan.",
|
||||
"series.new_driver_name": "Ny förare - namn",
|
||||
"series.add_driver": "Lägg till",
|
||||
"series.no_rounds": "Inga deltävlingar ännu - skapa dem ovan.",
|
||||
"series.col_date": "Datum",
|
||||
"series.col_race": "Race",
|
||||
"series.col_drivers": "Förare",
|
||||
"series.col_qual": "Kvalomgångar",
|
||||
"series.col_qual_short": "Kval",
|
||||
"series.col_qual_hint": "Antal kvalomgångar. Antalet heat per omgång räknas ut av antalet förare.",
|
||||
"series.col_finals": "Finaler",
|
||||
"series.col_per_heat": "Max bilar/heat",
|
||||
"series.col_status": "Status",
|
||||
"series.base_format": "Övrigt format (tider, starttyp)",
|
||||
"series.make_qualifying": "Skapa kval",
|
||||
"series.make_finals": "Skapa finaler",
|
||||
"series.open_race": "Öppna",
|
||||
"series.remove_round": "Ta bort ur serien (racet finns kvar)",
|
||||
"series.remove_round_confirm": "Ta bort deltävlingen ur serien? Racet och dess resultat finns kvar.",
|
||||
"series.no_participants": "Välj minst en förare först (klicka på antalet förare).",
|
||||
"series.heats_made": "{heats} heat per omgång",
|
||||
"series.status_not_started": "Ej startad",
|
||||
"series.status_qual_ready": "Kval skapat",
|
||||
"series.status_qual_running": "Kval pågår",
|
||||
"series.status_qual_done": "Kval klart",
|
||||
"series.status_finals": "Finaler",
|
||||
"series.status_done": "Klar",
|
||||
"series.add_existing": "Lägg till ett befintligt race",
|
||||
"series.add_existing_pick": "Välj race ...",
|
||||
"series.no_date": "utan datum",
|
||||
"series.delete": "Ta bort serien",
|
||||
"series.classes_none": "Inga",
|
||||
@@ -749,7 +860,7 @@ const TRANSLATIONS = {
|
||||
"series.count_best_value": "bästa {count}",
|
||||
"series.count_drop_value": "alla utom {count} sämsta",
|
||||
"series.events": "Deltävlingar ({count})",
|
||||
"series.events_hint": "Bocka i racen som ingår, eller skapa dem på en gång: ett race per datum och klass. Lägg sedan till förare och generera heat i Race Setup som vanligt.",
|
||||
"series.events_hint": "Skapa alla deltävlingar på en gång (ett race per datum och klass). Sedan sköts varje deltävling härifrån: välj förare, ändra antal kval och finaler, skapa kval - heaten delas in efter hur många som är med - och sedan finaler.",
|
||||
"series.no_events": "Inga race i de valda klasserna ännu - skapa dem nedan.",
|
||||
"series.create_dates": "Skapa deltävlingar för datum",
|
||||
"series.create_dates_hint": "Ett datum per rad (ÅÅÅÅ-MM-DD). Ett race skapas per datum och vald klass.",
|
||||
@@ -2453,6 +2564,38 @@ const TRANSLATIONS = {
|
||||
"series.step3": "3 · Rounds ({count})",
|
||||
"series.step4": "4 · Standings",
|
||||
"series.classes_all": "All",
|
||||
"series.round_participants": "Drivers: {count} of {total} in the class",
|
||||
"series.copy_previous": "Copy from previous",
|
||||
"series.no_previous_round": "No earlier round in the same class.",
|
||||
"series.no_class_drivers": "No drivers in the class yet - add one below.",
|
||||
"series.new_driver_name": "New driver - name",
|
||||
"series.add_driver": "Add",
|
||||
"series.no_rounds": "No rounds yet - create them above.",
|
||||
"series.col_date": "Date",
|
||||
"series.col_race": "Race",
|
||||
"series.col_drivers": "Drivers",
|
||||
"series.col_qual": "Qualifying rounds",
|
||||
"series.col_qual_short": "Qualifying",
|
||||
"series.col_qual_hint": "Number of qualifying rounds. The number of heats per round follows the number of drivers.",
|
||||
"series.col_finals": "Finals",
|
||||
"series.col_per_heat": "Max cars/heat",
|
||||
"series.col_status": "Status",
|
||||
"series.base_format": "Other format (times, start mode)",
|
||||
"series.make_qualifying": "Create qualifying",
|
||||
"series.make_finals": "Create finals",
|
||||
"series.open_race": "Open",
|
||||
"series.remove_round": "Remove from the series (the race is kept)",
|
||||
"series.remove_round_confirm": "Remove the round from the series? The race and its results are kept.",
|
||||
"series.no_participants": "Pick at least one driver first (click the number of drivers).",
|
||||
"series.heats_made": "{heats} heats per round",
|
||||
"series.status_not_started": "Not started",
|
||||
"series.status_qual_ready": "Qualifying created",
|
||||
"series.status_qual_running": "Qualifying running",
|
||||
"series.status_qual_done": "Qualifying done",
|
||||
"series.status_finals": "Finals",
|
||||
"series.status_done": "Done",
|
||||
"series.add_existing": "Add an existing race",
|
||||
"series.add_existing_pick": "Pick a race ...",
|
||||
"series.no_date": "no date",
|
||||
"series.delete": "Delete series",
|
||||
"series.classes_none": "None",
|
||||
@@ -2487,7 +2630,7 @@ const TRANSLATIONS = {
|
||||
"series.count_best_value": "best {count}",
|
||||
"series.count_drop_value": "all but the {count} worst",
|
||||
"series.events": "Rounds ({count})",
|
||||
"series.events_hint": "Tick the races that belong to the series, or create them in one go: one race per date and class. Then add drivers and generate heats in Race Setup as usual.",
|
||||
"series.events_hint": "Create all rounds in one go (one race per date and class). Then run each round from here: pick drivers, change the number of qualifying rounds and finals, create qualifying - heats are split by how many drivers take part - and then finals.",
|
||||
"series.no_events": "No races in the selected classes yet - create them below.",
|
||||
"series.create_dates": "Create rounds for dates",
|
||||
"series.create_dates_hint": "One date per line (YYYY-MM-DD). One race is created per date and selected class.",
|
||||
|
||||
@@ -622,6 +622,25 @@ export function findEventTeamForPassing(event, driverId, carId, { getEventTeams
|
||||
|
||||
|
||||
|
||||
// Qualifying heats as even as possible: 9 drivers with max 8 per heat -> 5 + 4, never 8 + 1
|
||||
// (someone driving alone). The number of heats follows the number of participants. Order is
|
||||
// kept; earlier heats get the extra driver.
|
||||
export function splitIntoBalancedGroups(items, maxSize) {
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
if (!list.length) return [];
|
||||
const groupCount = Math.ceil(list.length / Math.max(1, maxSize));
|
||||
const base = Math.floor(list.length / groupCount);
|
||||
const extra = list.length % groupCount;
|
||||
const groups = [];
|
||||
let index = 0;
|
||||
for (let group = 0; group < groupCount; group += 1) {
|
||||
const size = base + (group < extra ? 1 : 0);
|
||||
groups.push(list.slice(index, index + size));
|
||||
index += size;
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function generateQualifyingForRace(event, { buildPracticeStandings, getEventDrivers, state, normalizeStartMode, normalizeSession, uid, chunkArray, clearGeneratedQualifying }) {
|
||||
const sourceRows = buildPracticeStandings(event);
|
||||
const fallbackRows =
|
||||
@@ -648,7 +667,7 @@ export function generateQualifyingForRace(event, { buildPracticeStandings, getEv
|
||||
? String(event.raceConfig.qualSeedMethod).toLowerCase()
|
||||
: "best_sum";
|
||||
const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0);
|
||||
const heats = chunkArray(fallbackRows, carsPerHeat);
|
||||
const heats = splitIntoBalancedGroups(fallbackRows, carsPerHeat);
|
||||
let created = 0;
|
||||
|
||||
heats.forEach((heatRows, heatIndex) => {
|
||||
@@ -718,7 +737,8 @@ export function reseedUpcomingQualifying(event, { buildQualifyingStandings, buil
|
||||
}
|
||||
|
||||
const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8);
|
||||
const heats = chunkArray(sourceRows, carsPerHeat);
|
||||
// Same even split as generateQualifyingForRace, so reseeded heats line up with the generated ones.
|
||||
const heats = splitIntoBalancedGroups(sourceRows, carsPerHeat);
|
||||
const sessionsByRound = new Map();
|
||||
getSessionsForEvent(event.id)
|
||||
.filter((session) => session.type === "qualification" && session.generated && session.status === "ready")
|
||||
|
||||
@@ -149,3 +149,16 @@ export function buildSeriesStandings(series, { events = [], dayResults = new Map
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Where a round (one race) stands, from its sessions: not started -> qualifying created ->
|
||||
// qualifying running -> qualifying done -> finals -> done.
|
||||
export function getSeriesRoundStatus(sessions = []) {
|
||||
const quals = sessions.filter((session) => session.type === "qualification");
|
||||
const finals = sessions.filter((session) => session.type === "final");
|
||||
const finished = (list) => list.length > 0 && list.every((session) => session.status === "finished");
|
||||
const started = (list) => list.some((session) => ["running", "paused", "finished"].includes(session.status));
|
||||
if (finals.length) return finished(finals) ? "done" : "finals";
|
||||
if (!quals.length) return "not_started";
|
||||
if (finished(quals)) return "qual_done";
|
||||
return started(quals) ? "qual_running" : "qual_ready";
|
||||
}
|
||||
|
||||
@@ -62,19 +62,65 @@ function renderSeriesForm(series, { t, escapeHtml, state }) {
|
||||
</form>`;
|
||||
}
|
||||
|
||||
function renderEventPicker(series, { t, escapeHtml, state, getClassName }) {
|
||||
const candidates = state.events
|
||||
.filter((event) => event.mode === "race" && !event.raceConfig?.practiceOnly && (!series.classIds.length || series.classIds.includes(event.classId)))
|
||||
.sort((a, b) => String(a.date || "").localeCompare(String(b.date || "")) || String(getClassName(a.classId)).localeCompare(String(getClassName(b.classId)), undefined, { numeric: true }) || String(a.name).localeCompare(String(b.name)));
|
||||
let expandedRoundId = "";
|
||||
|
||||
function renderRoundParticipants(round, { t, escapeHtml }) {
|
||||
return `
|
||||
<div class="series-event-list">
|
||||
${candidates.length ? candidates.map((event) => `
|
||||
<label class="series-class-row series-event-row ${series.eventIds.includes(event.id) ? "is-selected" : ""}"><input type="checkbox" data-series-event="${escapeHtml(event.id)}" ${series.eventIds.includes(event.id) ? "checked" : ""} />
|
||||
<span class="series-event-date">${escapeHtml(event.date || t("series.no_date"))}</span><strong>${escapeHtml(event.name)}</strong><span class="series-event-class">${escapeHtml(getClassName(event.classId))}</span></label>`).join("")
|
||||
: `<p class="hint">${t("series.no_events")}</p>`}
|
||||
<div class="series-round-participants" data-round-participants="${escapeHtml(round.event.id)}">
|
||||
<div class="series-classes-head"><span>${t("series.round_participants", { count: round.participantIds.length, total: round.classDrivers.length })}</span>
|
||||
<span class="actions-inline">
|
||||
<button class="btn btn-mini" type="button" data-round-action="all">${t("series.classes_all")}</button>
|
||||
<button class="btn btn-mini" type="button" data-round-action="none">${t("series.classes_none")}</button>
|
||||
<button class="btn btn-mini" type="button" data-round-action="copy">${t("series.copy_previous")}</button>
|
||||
</span></div>
|
||||
${round.classDrivers.length ? `<div class="series-class-list">${round.classDrivers.map((driver) => `
|
||||
<label class="series-class-row ${round.participantIds.includes(driver.id) ? "is-selected" : ""}"><input type="checkbox" data-round-driver="${escapeHtml(driver.id)}" ${round.participantIds.includes(driver.id) ? "checked" : ""} /><span>${escapeHtml(driver.name)}${driver.transponder ? ` <small>${escapeHtml(driver.transponder)}</small>` : ""}</span></label>`).join("")}</div>`
|
||||
: `<p class="hint">${t("series.no_class_drivers")}</p>`}
|
||||
<form class="series-add-driver" data-round-add-driver>
|
||||
<input name="name" required placeholder="${escapeHtml(t("series.new_driver_name"))}" />
|
||||
<input name="transponder" placeholder="${escapeHtml(t("drivers.transponder_placeholder"))}" inputmode="numeric" />
|
||||
<button class="btn btn-mini" type="submit">${t("series.add_driver")}</button>
|
||||
</form>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderRoundsTable(rounds, { t, escapeHtml }) {
|
||||
if (!rounds.length) return `<p class="hint">${t("series.no_rounds")}</p>`;
|
||||
const number = (round, name, value, min) => `<input class="series-round-number" type="number" min="${min}" name="${name}" value="${value}" ${round.status === "not_started" ? "" : "disabled"} />`;
|
||||
return `
|
||||
<div class="table-wrap"><table class="series-rounds-table">
|
||||
<thead><tr><th>${t("series.col_date")}</th><th>${t("series.col_race")}</th><th>${t("series.col_drivers")}</th><th title="${escapeHtml(t("series.col_qual_hint"))}">${t("series.col_qual_short")}</th><th>${t("series.col_finals")}</th><th>${t("series.col_per_heat")}</th><th>${t("series.col_status")}</th><th></th></tr></thead>
|
||||
<tbody>${rounds.map((round) => {
|
||||
const config = round.event.raceConfig || {};
|
||||
const action = round.status === "not_started"
|
||||
? `<button class="btn btn-primary btn-mini" type="button" data-round-action="qualifying" ${round.participantIds.length ? "" : "disabled"}>${t("series.make_qualifying")}</button>`
|
||||
: round.status === "qual_done" ? `<button class="btn btn-primary btn-mini" type="button" data-round-action="finals">${t("series.make_finals")}</button>` : "";
|
||||
return `
|
||||
<tr data-round="${escapeHtml(round.event.id)}" class="${expandedRoundId === round.event.id ? "is-expanded" : ""}">
|
||||
<td>${escapeHtml(round.event.date || t("series.no_date"))}</td>
|
||||
<td><strong>${escapeHtml(round.event.name)}</strong><br><small>${escapeHtml(round.className)}</small></td>
|
||||
<td><button class="btn btn-mini" type="button" data-round-action="toggle">${round.participantIds.length} ${expandedRoundId === round.event.id ? "▲" : "▼"}</button></td>
|
||||
<td>${number(round, "qualifyingRounds", config.qualifyingRounds || 1, 1)}</td>
|
||||
<td>${number(round, "finalLegs", config.finalLegs || 1, 1)}</td>
|
||||
<td>${number(round, "carsPerHeat", config.carsPerHeat || 8, 2)}</td>
|
||||
<td><span class="pill series-status-${round.status}">${t(`series.status_${round.status}`)}</span>${round.qualHeatCount ? `<br><small>${t("series.heats_made", { heats: round.qualHeatCount })}</small>` : ""}</td>
|
||||
<td><div class="series-round-actions">${action}<button class="btn btn-mini" type="button" data-round-action="open">${t("series.open_race")}</button><button class="btn btn-mini" type="button" data-round-action="remove" title="${escapeHtml(t("series.remove_round"))}">✕</button></div></td>
|
||||
</tr>
|
||||
${expandedRoundId === round.event.id ? `<tr class="series-round-detail"><td colspan="8">${renderRoundParticipants(round, { t, escapeHtml })}</td></tr>` : ""}`;
|
||||
}).join("")}</tbody>
|
||||
</table></div>`;
|
||||
}
|
||||
|
||||
function renderAddExistingRace(series, rounds, { t, escapeHtml, state, getClassName }) {
|
||||
const candidates = state.events
|
||||
.filter((event) => event.mode === "race" && !event.raceConfig?.practiceOnly && !series.eventIds.includes(event.id) && (!series.classIds.length || series.classIds.includes(event.classId)))
|
||||
.sort((a, b) => String(a.date || "").localeCompare(String(b.date || "")));
|
||||
if (!candidates.length) return "";
|
||||
return `
|
||||
<label class="series-add-existing"><span>${t("series.add_existing")}</span>
|
||||
<select data-series-add-existing><option value="">${t("series.add_existing_pick")}</option>${candidates.map((event) => `<option value="${escapeHtml(event.id)}">${escapeHtml(`${event.date || t("series.no_date")} · ${event.name} · ${getClassName(event.classId)}`)}</option>`).join("")}</select></label>`;
|
||||
}
|
||||
|
||||
function renderStandingsTable(table, { t, escapeHtml }) {
|
||||
const head = table.events.map((event, index) => `<th title="${escapeHtml(`${event.date || ""} ${event.name}`)}">${index + 1}</th>`).join("");
|
||||
const cells = (row) => table.events.map((event) => {
|
||||
@@ -95,11 +141,15 @@ function renderStandingsTable(table, { t, escapeHtml }) {
|
||||
}
|
||||
|
||||
export function renderSeriesView(deps) {
|
||||
const { state, dom, t, escapeHtml, saveState, renderView, uid, getClassName, getSeriesStandings, createSeriesEvents, getRaceFormatPresets, exportSeriesPdf, printSeries } = deps;
|
||||
const {
|
||||
state, dom, t, escapeHtml, saveState, renderView, uid, getClassName, getSeriesStandings, createSeriesEvents, getRaceFormatPresets, exportSeriesPdf, printSeries,
|
||||
getSeriesRounds, setSeriesRoundFormat, setSeriesRoundParticipants, copySeriesRoundParticipants, addDriverToSeriesRound, generateSeriesRoundQualifying, generateSeriesRoundFinals, openSeriesRound,
|
||||
} = deps;
|
||||
const list = state.series || [];
|
||||
if (!list.some((item) => item.id === selectedSeriesId)) selectedSeriesId = list[0]?.id || "";
|
||||
const series = list.find((item) => item.id === selectedSeriesId) || null;
|
||||
const tables = series ? getSeriesStandings(series) : [];
|
||||
const rounds = series ? getSeriesRounds(series) : [];
|
||||
|
||||
dom.view.innerHTML = `
|
||||
<section class="panel">
|
||||
@@ -114,13 +164,17 @@ export function renderSeriesView(deps) {
|
||||
<section class="panel mt-16">
|
||||
<div class="panel-header"><div><h3>${t("series.step3", { count: series.eventIds.length })}</h3><p class="hint">${t("series.events_hint")}</p></div></div>
|
||||
<div class="panel-body">
|
||||
${renderEventPicker(series, { t, escapeHtml, state, getClassName })}
|
||||
<form id="seriesCreateEvents" class="form-grid cols-3 settings-grid mt-16">
|
||||
<label><span>${t("series.create_dates")}</span><textarea name="dates" rows="4" placeholder="2026-11-01 2026-11-15 2026-11-29"></textarea><small>${t("series.create_dates_hint")}</small></label>
|
||||
<label><span>${t("events.race_preset")}</span><select name="presetId">${getRaceFormatPresets().map((preset) => `<option value="${escapeHtml(preset.id)}">${escapeHtml(preset.label || preset.name)}</option>`).join("")}</select></label>
|
||||
<form id="seriesCreateEvents" class="series-create">
|
||||
<label><span>${t("series.create_dates")}</span><textarea name="dates" rows="3" placeholder="2026-11-01 2026-11-15 2026-11-29"></textarea><small>${t("series.create_dates_hint")}</small></label>
|
||||
<label><span>${t("series.col_qual")}</span><input type="number" min="1" name="qualifyingRounds" value="3" /></label>
|
||||
<label><span>${t("series.col_finals")}</span><input type="number" min="1" name="finalLegs" value="3" /></label>
|
||||
<label><span>${t("series.col_per_heat")}</span><input type="number" min="2" name="carsPerHeat" value="6" /></label>
|
||||
<label><span>${t("series.base_format")}</span><select name="presetId">${getRaceFormatPresets().map((preset) => `<option value="${escapeHtml(preset.id)}">${escapeHtml(preset.label || preset.name)}</option>`).join("")}</select></label>
|
||||
<div class="series-create-actions"><button class="btn btn-primary" type="submit" ${series.classIds.length ? "" : "disabled"}>${t("series.create_events")}</button></div>
|
||||
</form>
|
||||
${series.classIds.length ? "" : `<p class="hint">${t("series.pick_classes_first")}</p>`}
|
||||
<div class="mt-16">${renderRoundsTable(rounds, { t, escapeHtml })}</div>
|
||||
${renderAddExistingRace(series, rounds, { t, escapeHtml, state, getClassName })}
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel mt-16">
|
||||
@@ -185,20 +239,60 @@ export function renderSeriesView(deps) {
|
||||
saveState();
|
||||
renderView();
|
||||
});
|
||||
dom.view.querySelectorAll("[data-series-event]").forEach((box) => box.addEventListener("change", () => {
|
||||
const id = box.dataset.seriesEvent;
|
||||
series.eventIds = box.checked ? [...new Set([...series.eventIds, id])] : series.eventIds.filter((item) => item !== id);
|
||||
saveState();
|
||||
renderView();
|
||||
}));
|
||||
document.getElementById("seriesCreateEvents")?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
const dates = String(data.get("dates") || "").split(/[\s,;]+/).map((value) => value.trim()).filter((value) => /^\d{4}-\d{2}-\d{2}$/.test(value));
|
||||
if (!dates.length) { window.alert(t("series.create_dates_invalid")); return; }
|
||||
createSeriesEvents(series, dates, String(data.get("presetId") || "custom"));
|
||||
createSeriesEvents(series, dates, String(data.get("presetId") || "custom"), {
|
||||
qualifyingRounds: data.get("qualifyingRounds"), finalLegs: data.get("finalLegs"), carsPerHeat: data.get("carsPerHeat"),
|
||||
});
|
||||
renderView();
|
||||
});
|
||||
dom.view.querySelector("[data-series-add-existing]")?.addEventListener("change", (event) => {
|
||||
if (!event.target.value) return;
|
||||
series.eventIds = [...new Set([...series.eventIds, event.target.value])];
|
||||
saveState();
|
||||
renderView();
|
||||
});
|
||||
dom.view.querySelectorAll("tr[data-round]").forEach((row) => {
|
||||
const eventId = row.dataset.round;
|
||||
row.querySelectorAll(".series-round-number").forEach((input) => input.addEventListener("change", () => {
|
||||
setSeriesRoundFormat(eventId, { [input.name]: input.value });
|
||||
renderView();
|
||||
}));
|
||||
row.querySelectorAll("[data-round-action]").forEach((button) => button.addEventListener("click", () => {
|
||||
const action = button.dataset.roundAction;
|
||||
if (action === "toggle") expandedRoundId = expandedRoundId === eventId ? "" : eventId;
|
||||
if (action === "open") { openSeriesRound(eventId); return; }
|
||||
if (action === "qualifying" && !generateSeriesRoundQualifying(eventId)) window.alert(t("series.no_participants"));
|
||||
if (action === "finals") generateSeriesRoundFinals(eventId);
|
||||
if (action === "remove") {
|
||||
if (!window.confirm(t("series.remove_round_confirm"))) return;
|
||||
series.eventIds = series.eventIds.filter((id) => id !== eventId);
|
||||
saveState();
|
||||
}
|
||||
renderView();
|
||||
}));
|
||||
});
|
||||
const detail = dom.view.querySelector("[data-round-participants]");
|
||||
if (detail) {
|
||||
const eventId = detail.dataset.roundParticipants;
|
||||
const ticked = () => [...detail.querySelectorAll("[data-round-driver]:checked")].map((box) => box.dataset.roundDriver);
|
||||
detail.querySelectorAll("[data-round-driver]").forEach((box) => box.addEventListener("change", () => { setSeriesRoundParticipants(eventId, ticked()); renderView(); }));
|
||||
detail.querySelectorAll("[data-round-action]").forEach((button) => button.addEventListener("click", () => {
|
||||
const action = button.dataset.roundAction;
|
||||
if (action === "all") setSeriesRoundParticipants(eventId, [...detail.querySelectorAll("[data-round-driver]")].map((box) => box.dataset.roundDriver));
|
||||
if (action === "none") setSeriesRoundParticipants(eventId, []);
|
||||
if (action === "copy" && !copySeriesRoundParticipants(series, eventId)) window.alert(t("series.no_previous_round"));
|
||||
renderView();
|
||||
}));
|
||||
detail.querySelector("[data-round-add-driver]")?.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
if (addDriverToSeriesRound(series, eventId, { name: data.get("name"), transponder: data.get("transponder") })) renderView();
|
||||
});
|
||||
}
|
||||
document.getElementById("seriesPrint")?.addEventListener("click", () => printSeries(series, tables));
|
||||
document.getElementById("seriesPdf")?.addEventListener("click", () => exportSeriesPdf(series, tables));
|
||||
}
|
||||
|
||||
@@ -4602,3 +4602,22 @@ tr.is-removed td.lap-actions {
|
||||
|
||||
/* Training stats: race laps (shown under "Races" / "All") carry their origin */
|
||||
.training-race-origin { color: var(--muted); font-size: 0.72rem; }
|
||||
|
||||
/* Series hub: rounds table */
|
||||
.series-create { display: grid; grid-template-columns: minmax(200px, 2fr) repeat(3, minmax(90px, 1fr)) minmax(160px, 2fr) auto; gap: 12px; align-items: end; }
|
||||
.series-create label { display: grid; gap: 4px; }
|
||||
.series-create textarea { font-family: inherit; }
|
||||
.series-rounds-table { width: 100%; border-collapse: collapse; }
|
||||
.series-rounds-table th, .series-rounds-table td { padding: 8px 10px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--line); }
|
||||
.series-rounds-table th { font-size: 0.78rem; color: var(--muted); white-space: nowrap; }
|
||||
.series-round-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.series-round-number { width: 64px; }
|
||||
.series-rounds-table tr.is-expanded td { border-bottom-color: transparent; }
|
||||
.series-round-detail td { background: color-mix(in srgb, var(--accent) 4%, transparent); }
|
||||
.series-round-participants { display: grid; gap: 10px; padding: 6px 2px; }
|
||||
.series-add-driver { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.series-add-driver input { max-width: 220px; }
|
||||
.series-add-existing { display: grid; gap: 4px; margin-top: 14px; max-width: 520px; }
|
||||
.series-status-done { border-color: var(--success, #2e9d5b); }
|
||||
.series-status-qual_running, .series-status-finals { border-color: var(--accent); }
|
||||
@media (max-width: 900px) { .series-create { grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
Reference in New Issue
Block a user