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>
301 lines
21 KiB
JavaScript
301 lines
21 KiB
JavaScript
// "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 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">
|
||
<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 };
|