feat(series): add series/cup championships with points tables and standings

Group any number of races in freely chosen classes into a series with a
points table (ÖRC/100-80-70/F1/custom), best-N or drop-X counting, a
club filter that re-ranks members without guests, tie-breaks, rounds
created from a date list, and per-class standings with print and PDF.

Also fixes PDF export of wide tables: they are compacted and switched
to landscape instead of truncating names and dropping columns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-10-10 19:49:23 +02:00
parent f65e526948
commit 9a9379aa83
18 changed files with 848 additions and 20 deletions

171
src/series_view.js Normal file
View File

@@ -0,0 +1,171 @@
// "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";
}
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);
return `
<form id="seriesForm" class="form-grid cols-3 settings-grid">
<label><span>${t("series.name")}</span><input name="name" required value="${escapeHtml(series.name)}" placeholder="${escapeHtml(t("series.name_placeholder"))}" /></label>
<label><span>${t("series.club")}</span><input name="club" list="seriesClubOptions" value="${escapeHtml(series.club)}" placeholder="${escapeHtml(t("series.club_all"))}" />
<datalist id="seriesClubOptions">${clubs.map((club) => `<option value="${escapeHtml(club)}"></option>`).join("")}</datalist>
<small>${t("series.club_hint")}</small></label>
<fieldset class="series-classes"><legend>${t("series.classes")}</legend>
${state.classes.map((item) => `<label class="toggle"><input type="checkbox" name="classIds" value="${escapeHtml(item.id)}" ${series.classIds.includes(item.id) ? "checked" : ""} /><span>${escapeHtml(item.name)}</span></label>`).join("") || `<p class="hint">${t("series.no_classes")}</p>`}
</fieldset>
<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 class="actions"><button class="btn btn-primary" type="submit">${t("common.save")}</button><button class="btn btn-danger" type="button" id="seriesDelete">${t("common.delete")}</button></div>
</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(a.name).localeCompare(String(b.name)));
return `
<div class="series-event-list">
${candidates.length ? candidates.map((event) => `
<label class="toggle"><input type="checkbox" data-series-event="${escapeHtml(event.id)}" ${series.eventIds.includes(event.id) ? "checked" : ""} />
<span>${escapeHtml(event.date || "-")} · <strong>${escapeHtml(event.name)}</strong> · ${escapeHtml(getClassName(event.classId))}</span></label>`).join("")
: `<p class="hint">${t("series.no_events")}</p>`}
</div>`;
}
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 } = 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) : [];
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 ? `
<section class="panel mt-16">
<div class="panel-header"><h3>${t("series.settings")}</h3></div>
<div class="panel-body">${renderSeriesForm(series, { t, escapeHtml, state })}</div>
</section>
<section class="panel mt-16">
<div class="panel-header"><div><h3>${t("series.events", { 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&#10;2026-11-15&#10;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>
<div class="actions"><button class="btn" 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>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("series.standings")}</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", () => {
const created = normalizeSeries({ name: t("series.new_name", { year: new Date().getFullYear() }), classIds: state.classes.map((item) => item.id) }, { 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-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;
});
form?.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(form);
Object.assign(series, normalizeSeries({
...series,
name: data.get("name"),
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();
});
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();
});
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"));
renderView();
});
document.getElementById("seriesPrint")?.addEventListener("click", () => printSeries(series, tables));
document.getElementById("seriesPdf")?.addEventListener("click", () => exportSeriesPdf(series, tables));
}
export { renderStandingsTable as renderSeriesStandingsTable };