Files
Live_RC/src/series_view.js
larssand b5707f92d1 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>
2026-10-11 18:55:36 +02:00

301 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// "Series" page: create a series/cup, pick its classes and races (or create the races in one
// go from a list of dates), set the points table and how many results count, and follow the
// standings per class - printable and exportable as PDF. Scoring lives in series_logic.js.
import { SERIES_POINT_PRESETS, normalizeSeries, parsePointsTable } from "./series_logic.js";
let selectedSeriesId = "";
function presetIdFor(series) {
return Object.entries(SERIES_POINT_PRESETS).find(([, preset]) =>
preset.table.join(",") === series.pointsTable.join(",") && preset.step === series.pointsStep && preset.min === series.pointsMin)?.[0] || "custom";
}
const byName = (a, b) => String(a.name || "").localeCompare(String(b.name || ""), undefined, { numeric: true, sensitivity: "base" });
// Steps 1 and 2 are one form: every change is saved straight away (no Save button to forget).
function renderSeriesForm(series, { t, escapeHtml, state }) {
const clubs = [...new Set(state.drivers.map((driver) => String(driver.club || "").trim()).filter(Boolean))].sort();
const presetId = presetIdFor(series);
const classes = state.classes.slice().sort(byName);
return `
<form id="seriesForm">
<section class="panel mt-16">
<div class="panel-header"><div><h3>${t("series.step1")}</h3><p class="hint">${t("series.step1_hint")}</p></div>
<button class="btn btn-mini" type="button" id="seriesDelete">${t("series.delete")}</button></div>
<div class="panel-body series-step">
<label class="series-name"><span>${t("series.name")}</span><input name="name" required value="${escapeHtml(series.name)}" placeholder="${escapeHtml(t("series.name_placeholder"))}" /></label>
<div class="series-classes">
<div class="series-classes-head"><span>${t("series.classes")}</span>
${classes.length ? `<span class="actions-inline"><button class="btn btn-mini" type="button" data-series-classes="all">${t("series.classes_all")}</button><button class="btn btn-mini" type="button" data-series-classes="none">${t("series.classes_none")}</button></span>` : ""}</div>
${classes.length ? `<div class="series-class-list">${classes.map((item) => `
<label class="series-class-row ${series.classIds.includes(item.id) ? "is-selected" : ""}"><input type="checkbox" name="classIds" value="${escapeHtml(item.id)}" ${series.classIds.includes(item.id) ? "checked" : ""} /><span>${escapeHtml(item.name)}</span></label>`).join("")}</div>`
: `<p class="hint">${t("series.no_classes")}</p>`}
<small class="hint">${t("series.classes_hint", { count: series.classIds.length })}</small>
</div>
</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><div><h3>${t("series.step2")}</h3><p class="hint">${t("series.step2_hint")}</p></div></div>
<div class="panel-body form-grid cols-3 settings-grid">
<label><span>${t("series.scoring")}</span><select name="scoring" data-series-scoring>
<option value="all" ${series.scoring === "all" ? "selected" : ""}>${t("series.scoring_all")}</option>
<option value="club" ${series.scoring === "club" ? "selected" : ""}>${t("series.scoring_club")}</option>
</select></label>
<label data-series-club ${series.scoring === "club" ? "" : "hidden"}><span>${t("series.club")}</span><input name="club" list="seriesClubOptions" value="${escapeHtml(series.club)}" placeholder="${escapeHtml(t("series.club_placeholder"))}" />
<datalist id="seriesClubOptions">${clubs.map((club) => `<option value="${escapeHtml(club)}"></option>`).join("")}</datalist>
<small>${t("series.club_hint")}</small></label>
<label><span>${t("series.points_preset")}</span><select name="pointsPreset" data-series-preset>
${["orc", "hundred", "f1", "custom"].map((id) => `<option value="${id}" ${presetId === id ? "selected" : ""}>${t(`series.preset_${id}`)}</option>`).join("")}
</select></label>
<label><span>${t("series.points_table")}</span><input name="pointsTable" value="${escapeHtml(series.pointsTable.join(", "))}" /><small>${t("series.points_table_hint")}</small></label>
<label><span>${t("series.points_after")}</span>
<span class="series-inline"><input name="pointsStep" type="number" min="0" value="${series.pointsStep}" /> / <input name="pointsMin" type="number" min="0" value="${series.pointsMin}" /></span>
<small>${t("series.points_after_hint")}</small></label>
<label><span>${t("series.count_mode")}</span><select name="countMode">
<option value="best" ${series.countMode === "best" ? "selected" : ""}>${t("series.count_best")}</option>
<option value="drop" ${series.countMode === "drop" ? "selected" : ""}>${t("series.count_drop")}</option>
</select></label>
<label><span>${t("series.count_value")}</span><input name="countValue" type="number" min="0" value="${series.countValue}" /><small>${t("series.count_hint")}</small></label>
</div>
</section>
<p class="hint series-saved-note">${t("series.autosave")}</p>
</form>`;
}
let expandedRoundId = "";
function renderRoundParticipants(round, { t, escapeHtml }) {
return `
<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) => {
const result = row.results.find((item) => item.eventId === event.id);
if (!event.completed || !result) return "<td></td>";
const text = result.absent ? "–" : String(result.points);
return `<td class="${result.counted ? "" : "series-struck"}">${result.counted ? text : `(${text})`}</td>`;
}).join("");
return `
<section class="series-standings" data-series-class="${escapeHtml(table.classId)}">
<h4>${escapeHtml(table.className)} <small>${t("series.counted_of", { counted: table.countedCount, completed: table.completedCount, total: table.events.length })}</small></h4>
${table.rows.length ? `
<div class="table-wrap"><table class="series-table">
<thead><tr><th>${t("table.pos")}</th><th>${t("table.driver")}</th>${head}<th>${t("series.starts")}</th><th>${t("series.total")}</th></tr></thead>
<tbody>${table.rows.map((row) => `<tr><td>${row.rank}</td><td>${escapeHtml(row.driverName)}</td>${cells(row)}<td>${row.starts}</td><td><strong>${row.total}</strong></td></tr>`).join("")}</tbody>
</table></div>` : `<p class="hint">${t("series.no_results")}</p>`}
</section>`;
}
export function renderSeriesView(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">
<div class="panel-header"><div><h3>${t("series.title")}</h3><p class="hint">${t("series.hint")}</p></div>
<button class="btn btn-primary" type="button" id="seriesNew">${t("series.new")}</button></div>
<div class="panel-body series-picker">
${list.length ? list.map((item) => `<button type="button" class="btn ${item.id === selectedSeriesId ? "btn-primary" : ""}" data-series-select="${escapeHtml(item.id)}">${escapeHtml(item.name || t("series.unnamed"))}</button>`).join("") : `<p class="hint">${t("series.none")}</p>`}
</div>
</section>
${series ? `
${renderSeriesForm(series, { t, escapeHtml, state })}
<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">
<form id="seriesCreateEvents" class="series-create">
<label><span>${t("series.create_dates")}</span><textarea name="dates" rows="3" placeholder="2026-11-01&#10;2026-11-15&#10;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">
<div class="panel-header"><h3>${t("series.step4")}</h3>
<div class="actions-inline"><button class="btn" type="button" id="seriesPrint">${t("series.print")}</button><button class="btn" type="button" id="seriesPdf">PDF</button></div></div>
<div class="panel-body">${tables.length ? tables.map((table) => renderStandingsTable(table, { t, escapeHtml })).join("") : `<p class="hint">${t("series.no_results")}</p>`}
<p class="hint">${t("series.legend")}</p></div>
</section>` : ""}`;
document.getElementById("seriesNew")?.addEventListener("click", () => {
// No classes ticked: step 1 asks for them, instead of starting with every class selected.
const created = normalizeSeries({ name: t("series.new_name", { year: new Date().getFullYear() }), classIds: [] }, { uid });
state.series = [...list, created];
selectedSeriesId = created.id;
saveState();
renderView();
});
dom.view.querySelectorAll("[data-series-select]").forEach((button) => button.addEventListener("click", () => { selectedSeriesId = button.dataset.seriesSelect; renderView(); }));
if (!series) return;
const form = document.getElementById("seriesForm");
form?.querySelector("[data-series-scoring]")?.addEventListener("change", (event) => {
form.querySelector("[data-series-club]").hidden = event.target.value !== "club";
});
form?.querySelector("[data-series-preset]")?.addEventListener("change", (event) => {
const preset = SERIES_POINT_PRESETS[event.target.value];
if (!preset) return;
form.elements.pointsTable.value = preset.table.join(", ");
form.elements.pointsStep.value = preset.step;
form.elements.pointsMin.value = preset.min;
saveForm();
});
const saveForm = () => {
const data = new FormData(form);
Object.assign(series, normalizeSeries({
...series,
name: String(data.get("name") || "").trim() || series.name,
scoring: data.get("scoring"),
club: data.get("club"),
classIds: data.getAll("classIds"),
pointsTable: parsePointsTable(data.get("pointsTable")),
pointsStep: data.get("pointsStep"),
pointsMin: data.get("pointsMin"),
countMode: data.get("countMode"),
countValue: data.get("countValue"),
}));
saveState();
renderView();
};
form?.addEventListener("change", (event) => {
if (event.target?.name === "pointsPreset") return; // handled below (fills the fields first)
saveForm();
});
form?.addEventListener("submit", (event) => { event.preventDefault(); saveForm(); });
form?.querySelectorAll("[data-series-classes]").forEach((button) => button.addEventListener("click", () => {
form.querySelectorAll('input[name="classIds"]').forEach((box) => { box.checked = button.dataset.seriesClasses === "all"; });
saveForm();
}));
document.getElementById("seriesDelete")?.addEventListener("click", () => {
if (!window.confirm(t("series.delete_confirm", { name: series.name }))) return;
state.series = list.filter((item) => item.id !== series.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"), {
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));
}
export { renderStandingsTable as renderSeriesStandingsTable };