fix(series): clearer step layout, sorted class list and autosave

The series page is split into numbered steps; classes are a sorted list
with the tick on the left and All/None, new series start with none, and
every change is saved straight away so ticks are no longer lost. Also
gives primary/danger buttons white text in the light theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-10-11 18:28:05 +02:00
parent ec200aef2b
commit 581ec2c781
12 changed files with 172 additions and 46 deletions

View File

@@ -708,6 +708,18 @@ const TRANSLATIONS = {
"series.none": "Inga serier ännu.",
"series.unnamed": "Namnlös serie",
"series.settings": "Inställningar",
"series.step1": "1 · Namn och klasser",
"series.step1_hint": "Bocka i klasserna som ingår. Varje klass får en egen ställning.",
"series.step2": "2 · Poäng",
"series.step2_hint": "Vem som får poäng, poängtabell och hur många resultat som räknas.",
"series.step3": "3 · Deltävlingar ({count})",
"series.step4": "4 · Ställning",
"series.classes_all": "Alla",
"series.no_date": "utan datum",
"series.delete": "Ta bort serien",
"series.classes_none": "Inga",
"series.classes_hint": "{count} valda.",
"series.autosave": "Ändringar sparas direkt.",
"series.name": "Namn",
"series.name_placeholder": "t.ex. Winter Championship 2026/2027",
"series.scoring": "Vem får seriepoäng",
@@ -743,7 +755,7 @@ const TRANSLATIONS = {
"series.create_dates_hint": "Ett datum per rad (ÅÅÅÅ-MM-DD). Ett race skapas per datum och vald klass.",
"series.create_dates_invalid": "Ange minst ett datum som ÅÅÅÅ-MM-DD.",
"series.create_events": "Skapa deltävlingar",
"series.pick_classes_first": "Välj klasser och spara först.",
"series.pick_classes_first": "Välj minst en klass i steg 1 först.",
"series.standings": "Ställning",
"series.print": "Skriv ut",
"series.starts": "Starter",
@@ -2434,6 +2446,18 @@ const TRANSLATIONS = {
"series.none": "No series yet.",
"series.unnamed": "Unnamed series",
"series.settings": "Settings",
"series.step1": "1 · Name and classes",
"series.step1_hint": "Tick the classes that are part of the series. Each class gets its own standings.",
"series.step2": "2 · Points",
"series.step2_hint": "Who scores, the points table and how many results count.",
"series.step3": "3 · Rounds ({count})",
"series.step4": "4 · Standings",
"series.classes_all": "All",
"series.no_date": "no date",
"series.delete": "Delete series",
"series.classes_none": "None",
"series.classes_hint": "{count} selected.",
"series.autosave": "Changes are saved straight away.",
"series.name": "Name",
"series.name_placeholder": "e.g. Winter Championship 2026/2027",
"series.scoring": "Who scores series points",
@@ -2469,7 +2493,7 @@ const TRANSLATIONS = {
"series.create_dates_hint": "One date per line (YYYY-MM-DD). One race is created per date and selected class.",
"series.create_dates_invalid": "Enter at least one date as YYYY-MM-DD.",
"series.create_events": "Create rounds",
"series.pick_classes_first": "Choose classes and save first.",
"series.pick_classes_first": "Choose at least one class in step 1 first.",
"series.standings": "Standings",
"series.print": "Print",
"series.starts": "Starts",

View File

@@ -10,47 +10,67 @@ function presetIdFor(series) {
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" 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.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>
<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 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>`;
}
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)));
.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)));
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("")
<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>`;
}
@@ -90,31 +110,29 @@ export function renderSeriesView(deps) {
</div>
</section>
${series ? `
${renderSeriesForm(series, { t, escapeHtml, state })}
<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-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&#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>
<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>
</section>
<section class="panel mt-16">
<div class="panel-header"><h3>${t("series.standings")}</h3>
<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", () => {
const created = normalizeSeries({ name: t("series.new_name", { year: new Date().getFullYear() }), classIds: state.classes.map((item) => item.id) }, { uid });
// 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();
@@ -133,13 +151,13 @@ export function renderSeriesView(deps) {
form.elements.pointsTable.value = preset.table.join(", ");
form.elements.pointsStep.value = preset.step;
form.elements.pointsMin.value = preset.min;
saveForm();
});
form?.addEventListener("submit", (event) => {
event.preventDefault();
const saveForm = () => {
const data = new FormData(form);
Object.assign(series, normalizeSeries({
...series,
name: data.get("name"),
name: String(data.get("name") || "").trim() || series.name,
scoring: data.get("scoring"),
club: data.get("club"),
classIds: data.getAll("classIds"),
@@ -151,7 +169,16 @@ export function renderSeriesView(deps) {
}));
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);

View File

@@ -1908,6 +1908,8 @@ body {
}
.btn-primary {
/* White text on the red/accent fill in every theme - the light theme's dark text was unreadable here. */
color: #fff;
border-color: color-mix(in srgb, var(--accent) 72%, black 28%);
background: linear-gradient(
180deg,
@@ -1918,6 +1920,8 @@ body {
}
.btn-danger {
/* White text on the red/accent fill in every theme - the light theme's dark text was unreadable here. */
color: #fff;
border-color: color-mix(in srgb, var(--danger) 62%, black 38%);
background: linear-gradient(
180deg,
@@ -4572,10 +4576,24 @@ tr.is-removed td.lap-actions {
/* Series / cup page */
.series-picker { display: flex; flex-wrap: wrap; gap: 8px; }
.series-classes { display: flex; flex-wrap: wrap; gap: 6px 14px; border: 1px solid var(--line); border-radius: 9px; padding: 8px 10px; }
.series-step { display: grid; grid-template-columns: minmax(220px, 320px) 1fr; gap: 16px; align-items: start; }
.series-name { display: grid; gap: 4px; }
.series-classes { display: grid; gap: 8px; }
.series-classes-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.series-class-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 6px; }
.series-class-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 9px; cursor: pointer; }
.series-class-row input { margin: 0; width: 18px; height: 18px; flex: none; }
.series-class-row.is-selected { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); font-weight: 600; }
.series-saved-note { margin: 6px 2px 0; }
@media (max-width: 720px) { .series-step { grid-template-columns: 1fr; } }
.series-inline { display: flex; align-items: center; gap: 6px; }
.series-inline input { width: 80px; }
.series-event-list { display: grid; gap: 4px; max-height: 320px; overflow: auto; }
.series-event-list { display: grid; gap: 6px; max-height: 360px; overflow: auto; }
.series-event-row { display: grid; grid-template-columns: 18px 110px minmax(0, 1fr) auto; }
.series-event-date { color: var(--muted); font-variant-numeric: tabular-nums; }
.series-event-class { color: var(--muted); }
.series-create-actions { display: flex; align-items: flex-end; }
.series-create-actions .btn { height: auto; }
.series-standings + .series-standings { margin-top: 20px; }
.series-standings h4 small { color: var(--muted); font-weight: 400; margin-left: 6px; }
.series-table td, .series-table th { text-align: center; white-space: nowrap; }