diff --git a/CHANGELOG.md b/CHANGELOG.md index 8472d17..65b85e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.36.1 - 2026-10-11 + +### Fixed + +- Series page: + - **Classes:** shown as a sorted list, one class per row with the tick on the left and the selected ones highlighted, plus All / None buttons. Before, they wrapped in creation order and it was hard to see which tick belonged to which class. + - **Saving:** every change is saved straight away. Before, ticked classes were lost if a round was ticked or created before pressing Save. + - **New series:** starts with no class ticked instead of all of them. + - **Layout:** the page is split into numbered steps (1 Name and classes, 2 Points, 3 Rounds, 4 Standings). Rounds are listed date · race · class, sorted by date then class, and a race without a date says so. +- Primary and danger buttons had dark text on their red fill in the light theme, which was hard to read, e.g. "Create rounds" and "Delete". They now have white text in every theme. + ## 1.36.0 - 2026-10-11 ### Changed diff --git a/README.md b/README.md index 73b45ee..0fe8a9a 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,10 @@ Current layouts for race operation, live presentation, training and system confi In-app setup guide
In-app setup guide Event Manager's Live & results tab
Event Manager's Live & results tab + + Series / cup with numbered steps
Series / cup + + ## Video Guides diff --git a/README.sv.md b/README.sv.md index 701e36a..132ece5 100644 --- a/README.sv.md +++ b/README.sv.md @@ -80,6 +80,10 @@ Aktuella layouter för racedrift, livevisning, träning och systemkonfiguration. Inbyggd uppstartsguide
Inbyggd uppstartsguide Event Managers flik Live & resultat
Event Managers flik Live & resultat + + Serie / cup med numrerade steg
Serie / cup + + ## Videoguider diff --git a/ROADMAP.md b/ROADMAP.md index 64b5fbb..a0a93ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -814,6 +814,21 @@ The user pointed out that drivers from other clubs must be able to score in a no Verified: 262 unit tests pass (open series with a club filled in, members-only, defaults, legacy). The browser test sets members-only and checks that the guest is excluded, then switches to every driver and checks that the guest scores and wins round 1 and that the club field hides, then switches back. 68 browser scenarios pass. +### Follow-up: series page clarity (2026-10-11) + +The user found the class ticks unsorted and hard to match to their labels, and selections that seemed not to save (all classes came back). + +Causes: +- The classes were inline toggles wrapping in creation order. +- The form only saved on Save, so ticking a round or creating rounds re-rendered the page and dropped unsaved changes. +- New series started with every class ticked. + +- [x] Numbered steps. Steps 1-2 are one autosaving form (change → save, no Save button). Classes are a sorted grid of rows with the tick on the left, highlighted when selected, with All / None. New series start with none. Rounds are listed as aligned date · race · class rows. +- [x] Found in the screenshot: `.btn-primary` and `.btn-danger` inherited the light theme's dark text. They now have `color: #fff`. +- [x] New README screenshot `docs/screenshots/series.jpg` (`node scripts/capture-screenshots.mjs series`; `SHOT_SCROLL=` captures another part of the page for checking). + +Verified: three captures checked visually (steps 1-2, step 3 before and after the button fix). The browser test checks that a new series starts unticked, that classes are sorted, and that a tick is saved without submit and highlighted. 262 unit and 69 browser scenarios pass. + Not done yet: the rule that a disqualification from a whole round gives a 0 that may not be dropped (ÖRC draft p. 6). Also no public web page for the standings, only print/PDF. ## Training stats: practice by default, race laps separate diff --git a/docs/screenshots/series.jpg b/docs/screenshots/series.jpg new file mode 100644 index 0000000..05a83bf Binary files /dev/null and b/docs/screenshots/series.jpg differ diff --git a/package-lock.json b/package-lock.json index 26db4c6..2631857 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.36.0", + "version": "1.36.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.36.0", + "version": "1.36.1", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 893cb53..3295b50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.36.0", + "version": "1.36.1", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/scripts/capture-screenshots.mjs b/scripts/capture-screenshots.mjs index 9081e07..6797984 100644 --- a/scripts/capture-screenshots.mjs +++ b/scripts/capture-screenshots.mjs @@ -164,6 +164,20 @@ async function captureShot(name, { setup }) { } const shots = { + series: { + async setup({ evaluate, navigate, scrollTo }) { + // A series over two of four classes (added out of name order on purpose: the page sorts them). + await navigate("dashboard", "#view"); + await evaluate(`(async () => { + const { state } = await (await fetch("/api/state")).json(); + state.classes.push({ id: "shot_rookie", name: "Rookie" }, { id: "shot_4wd", name: "4WD Buggy" }, { id: "shot_nitro", name: "1/8 Nitro" }); + state.series = [{ id: "shot_series", name: "Winter Championship 2026/2027", classIds: ["shot_class", "shot_rookie"], eventIds: ["shot_event"], scoring: "club", club: "JMK RB", countMode: "best", countValue: 9 }]; + await fetch("/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(state) }); + })()`); + await navigate("series", "#seriesForm"); + await scrollTo(process.env.SHOT_SCROLL || "#seriesForm", process.env.SHOT_SCROLL ? "center" : "start"); + }, + }, guide: { async setup({ navigate }) { await navigate("guide", ".guide-list"); diff --git a/src/app.js b/src/app.js index 6928320..d923fe0 100644 --- a/src/app.js +++ b/src/app.js @@ -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", diff --git a/src/series_view.js b/src/series_view.js index 5e29905..28517f0 100644 --- a/src/series_view.js +++ b/src/series_view.js @@ -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 ` -
- - - -
${t("series.classes")} - ${state.classes.map((item) => ``).join("") || `

${t("series.no_classes")}

`} -
- - - - - -
+ +
+

${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")}

`; } 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 `
${candidates.length ? candidates.map((event) => ` - `).join("") + `).join("") : `

${t("series.no_events")}

`}
`; } @@ -90,31 +110,29 @@ export function renderSeriesView(deps) { ${series ? ` + ${renderSeriesForm(series, { t, escapeHtml, state })}
-

${t("series.settings")}

-
${renderSeriesForm(series, { t, escapeHtml, state })}
-
-
-

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

${t("series.events_hint")}

+

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

${t("series.events_hint")}

${renderEventPicker(series, { t, escapeHtml, state, getClassName })}
-
+
${series.classIds.length ? "" : `

${t("series.pick_classes_first")}

`}
-

${t("series.standings")}

+

${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", () => { - 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); diff --git a/src/styles.css b/src/styles.css index ef588a0..dbe3dfc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; } diff --git a/tests/browser_series.js b/tests/browser_series.js index fe4493e..06dbabb 100644 --- a/tests/browser_series.js +++ b/tests/browser_series.js @@ -27,6 +27,15 @@ export async function runSeriesSmoke({ evaluate, navigate, savedState, waitFor, await navigate("series", "seriesNew"); await evaluate(`document.getElementById("seriesNew").click()`); await waitFor(async () => Boolean(await evaluate(`document.getElementById("seriesForm")`)), "series form"); + assert.equal(await evaluate(`document.querySelectorAll('#seriesForm input[name="classIds"]:checked').length`), 0, "a new series starts with no class ticked"); + // Sorted by name, one row each. + const classNames = await evaluate(`[...document.querySelectorAll(".series-class-row span")].map((node) => node.textContent.trim())`); + assert.deepEqual(classNames.filter((name) => name.startsWith("SR ")), ["SR 2WD", "SR 4WD"]); + // A tick is saved straight away - no Save button. + await evaluate(`(() => { const box = document.querySelector('#seriesForm input[name="classIds"][value="sr_4wd"]'); box.checked = true; box.dispatchEvent(new Event("change", { bubbles: true })); })()`); + await waitFor(async () => ((await savedState()).series || []).some((series) => series.classIds.includes("sr_4wd")), "class tick saved immediately"); + await waitFor(async () => (await evaluate(`document.querySelector('.series-class-row input[value="sr_4wd"]')?.closest("label").classList.contains("is-selected")`)) === true, "the ticked class is highlighted"); + console.log("PASS series classes are sorted, start unticked and are saved as soon as they are ticked"); await evaluate(`(() => { const form = document.getElementById("seriesForm"); form.elements.name.value = "SR Winter Cup";