// "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 `

${t("series.step1")}

${t("series.step1_hint")}

${t("series.classes")} ${classes.length ? `` : ""}
${classes.length ? `
${classes.map((item) => ` `).join("")}
` : `

${t("series.no_classes")}

`} ${t("series.classes_hint", { count: series.classIds.length })}

${t("series.step2")}

${t("series.step2_hint")}

${t("series.autosave")}

`; } let expandedRoundId = ""; function renderRoundParticipants(round, { t, escapeHtml }) { return `
${t("series.round_participants", { count: round.participantIds.length, total: round.classDrivers.length })}
${round.classDrivers.length ? `
${round.classDrivers.map((driver) => ` `).join("")}
` : `

${t("series.no_class_drivers")}

`}
`; } function renderRoundsTable(rounds, { t, escapeHtml }) { if (!rounds.length) return `

${t("series.no_rounds")}

`; const number = (round, name, value, min) => ``; return `
${rounds.map((round) => { const config = round.event.raceConfig || {}; const action = round.status === "not_started" ? `` : round.status === "qual_done" ? `` : ""; return ` ${expandedRoundId === round.event.id ? `` : ""}`; }).join("")}
${t("series.col_date")}${t("series.col_race")}${t("series.col_drivers")}${t("series.col_qual_short")}${t("series.col_finals")}${t("series.col_per_heat")}${t("series.col_status")}
${escapeHtml(round.event.date || t("series.no_date"))} ${escapeHtml(round.event.name)}
${escapeHtml(round.className)}
${number(round, "qualifyingRounds", config.qualifyingRounds || 1, 1)} ${number(round, "finalLegs", config.finalLegs || 1, 1)} ${number(round, "carsPerHeat", config.carsPerHeat || 8, 2)} ${t(`series.status_${round.status}`)}${round.qualHeatCount ? `
${t("series.heats_made", { heats: round.qualHeatCount })}` : ""}
${action}
${renderRoundParticipants(round, { t, escapeHtml })}
`; } 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 ` `; } function renderStandingsTable(table, { t, escapeHtml }) { const head = table.events.map((event, index) => `${index + 1}`).join(""); const cells = (row) => table.events.map((event) => { const result = row.results.find((item) => item.eventId === event.id); if (!event.completed || !result) return ""; const text = result.absent ? "–" : String(result.points); return `${result.counted ? text : `(${text})`}`; }).join(""); return `

${escapeHtml(table.className)} ${t("series.counted_of", { counted: table.countedCount, completed: table.completedCount, total: table.events.length })}

${table.rows.length ? `
${head}${table.rows.map((row) => `${cells(row)}`).join("")}
${t("table.pos")}${t("table.driver")}${t("series.starts")}${t("series.total")}
${row.rank}${escapeHtml(row.driverName)}${row.starts}${row.total}
` : `

${t("series.no_results")}

`}
`; } 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 = `

${t("series.title")}

${t("series.hint")}

${list.length ? list.map((item) => ``).join("") : `

${t("series.none")}

`}
${series ? ` ${renderSeriesForm(series, { t, escapeHtml, state })}

${t("series.step3", { count: series.eventIds.length })}

${t("series.events_hint")}

${series.classIds.length ? "" : `

${t("series.pick_classes_first")}

`}
${renderRoundsTable(rounds, { t, escapeHtml })}
${renderAddExistingRace(series, rounds, { t, escapeHtml, state, getClassName })}

${t("series.step4")}

${tables.length ? tables.map((table) => renderStandingsTable(table, { t, escapeHtml })).join("") : `

${t("series.no_results")}

`}

${t("series.legend")}

` : ""}`; 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 };