From 9a9379aa83d169b6c7f108e501b0ada3f3567a74 Mon Sep 17 00:00:00 2001 From: larssand Date: Sat, 10 Oct 2026 19:49:23 +0200 Subject: [PATCH] feat(series): add series/cup championships with points tables and standings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 17 ++ README.md | 12 ++ README.sv.md | 12 ++ ROADMAP.md | 33 ++++ docs/javascript-architecture.md | 9 + package-lock.json | 4 +- package.json | 2 +- server.js | 51 +++-- src/app.js | 187 +++++++++++++++++++ src/runtime_services.js | 2 + src/series_logic.js | 148 +++++++++++++++ src/series_view.js | 171 +++++++++++++++++ src/styles.css | 12 ++ tests/browser_series.js | 94 ++++++++++ tests/browser_smoke.js | 2 + tests/run.js | 1 + tests/series_logic.test.js | 95 ++++++++++ tests/server_persistence.integration.test.js | 16 ++ 18 files changed, 848 insertions(+), 20 deletions(-) create mode 100644 src/series_logic.js create mode 100644 src/series_view.js create mode 100644 tests/browser_series.js create mode 100644 tests/series_logic.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e2361b..e6bce0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,23 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.34.0 - 2026-10-10 + +### Added + +- **Series / cup** (new menu item): group any number of races into a championship with freely chosen classes. Each class gets its own table. + - **Points table:** ÖRC preset, 100-80-70, F1, or your own list with a step and minimum after it. + - **Counted results:** best N, or all run rounds minus X, so a cancelled round adjusts automatically. Absence counts as 0 and can be dropped. + - **Club filter:** only drivers from the series' club score, and the club's drivers are re-ranked without guests. + - **No lap = 0 points.** + - **Ties:** most wins, then 2nd places and so on, then the latest common round. + - **Creating rounds:** from a list of dates, one race per date and class with a race format preset. + - **Standings:** per class, printable and exportable as PDF. + +### Fixed + +- PDF export of wide tables cut names to 4 characters and dropped the last columns. Such tables are now compacted and, when needed, printed on landscape A4. Tables that already fitted look exactly as before. + ## 1.33.0 - 2026-10-08 ### Added diff --git a/README.md b/README.md index a912b60..89f8753 100644 --- a/README.md +++ b/README.md @@ -353,6 +353,18 @@ The integration test starts its own backend on a temporary localhost port and ne - create practice, qualifying, and/or team-race sessions automatically - Finals are still generated later from `Race Actions` after practice or qualifying is complete. +## Series / cup + +Menu **Series / cup** groups several races into a championship: +- **Classes:** choose any classes; each gets its own table. +- **Rounds:** any number. Tick existing races, or create them from a list of dates. One race per date and class is created with the chosen race format; then add drivers and generate heats in Race Setup as usual. +- **Points table:** the ÖRC table (100, 90, 82, 75, 69 ... then -4 per place, min 10), 100-80-70 ..., F1, or your own list with a step and minimum after it. +- **Counted results:** best N (e.g. 9 of 12), or all run rounds except the X worst. A cancelled round then lowers the count by itself. Absence counts as 0 and can be dropped. +- **Club:** only drivers from that club (taken from the driver register) score. Drivers from other clubs race and appear in the day result, and the club's drivers are re-ranked without them. Leave the club empty to let everyone score. +- **Day result:** each round is ranked by its final standings (A before B), then drivers without a final in qualifying order, or by qualifying alone if no finals ran. A driver with no registered lap gets 0. +- **Ties:** most wins among counted results, then 2nd places and so on, then the latest common round. +- **Standings:** shown per class with dropped results in brackets. They can be printed or exported as PDF; wide tables switch to landscape. + ## Manage workflow - After the race is created, `Manage` is split into four clear stages: - `Setup` diff --git a/README.sv.md b/README.sv.md index a268399..8ac66d0 100644 --- a/README.sv.md +++ b/README.sv.md @@ -342,6 +342,18 @@ Integrationstestet startar en egen backend på en temporär localhost-port och l - skapa practice, kval och/eller team race automatiskt - Finaler skapas fortfarande senare från `Race actions` när practice eller kval är färdiga. +## Serie / cup + +Menyn **Serier / cup** samlar flera race i en serie: +- **Klasser:** välj fritt; varje klass får en egen tabell. +- **Deltävlingar:** valfritt antal. Bocka i befintliga race eller skapa dem från en datumlista. Ett race per datum och klass skapas med valt raceformat; lägg sedan till förare och generera heat i Race Setup som vanligt. +- **Poängtabell:** ÖRC (100, 90, 82, 75, 69 ... sedan -4 per plats, lägst 10), 100-80-70 ..., F1, eller en egen lista med minskning och lägsta värde efter den. +- **Räknade resultat:** bästa N (t.ex. 9 av 12), eller alla körda utom X sämsta. Då minskar antalet automatiskt om en deltävling ställs in. Frånvaro räknas som 0 och kan strykas. +- **Klubb:** bara förare från klubben (enligt förarregistret) får seriepoäng. Förare från andra klubbar kör och syns i dagens resultat, och klubbens förare rangordnas om utan dem. Tom klubb betyder att alla får poäng. +- **Dagens resultat:** varje deltävling rankas efter slutresultatet (A före B), sedan förare utan final i kvalordning, eller enbart efter kvalet om inga finaler körts. Den som saknar registrerat varv får 0. +- **Lika poäng:** flest segrar bland räknade resultat, sedan andraplatser osv., sedan senaste gemensamma deltävling. +- **Ställning:** visas per klass med strukna resultat inom parentes. Den kan skrivas ut eller exporteras som PDF; breda tabeller blir liggande A4. + ## Hantera-flödet - Efter att racet skapats är `Hantera` uppdelat i fyra tydliga steg: - `Setup` diff --git a/ROADMAP.md b/ROADMAP.md index 16731de..c49cac3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -774,6 +774,39 @@ Verified 2026-10-08: - It captures `speechSynthesis.speak` in the page and checks that the row button reads the saved spoken name, and that the add-form button reads an unsaved nickname without saving the driver. - 253 unit and 62 browser scenarios pass. +## Series / cup + +Asked by the user 2026-10-10, with the club's draft rules "ÖRC Winter Championship 2026/2027" (12 Sunday rounds, three classes, ÖRC points, best 9 of 12, members only) as the example. The user clarified: +- classes must be freely chosen; +- any number of rounds; +- "guests" are simply drivers whose club (from the driver register) differs from the series' club. + +- [x] `src/series_logic.js` (pure): + - `normalizeSeries`; + - points per place from a table, then step/min (`getSeriesPointsForPlace`, ÖRC formula max(10, 48 - 4 × (place - 10))); + - `scoreSeriesEvent`: club filter with re-ranking, and no lap = 0 points with no place; + - `getCountedResultsCount`: best N, or completed minus X with at least one; + - `buildSeriesStandings`: one table per class, absence = 0 and droppable, counted flags, starts, ties (wins, then 2nds..., then the latest common round with places, else shared rank). +- [x] `app.js`: + - `getEventDayResult`: final standings, then non-finalists in qualifying order, else qualifying; only once a qualifying/heat/final is finished; `hasLap` from counted passings. + - `createSeriesEvents`: one event per date × class, " # ", with a race format preset. + - Print and PDF export of the standings. + - `state.series` persisted (load, default, export, backend hydrate). +- [x] `src/series_view.js`: the new "Series / cup" page, with the series list, settings (name, club with suggestions, classes, points preset/table/step/min, count rule), round picker, create-from-dates, and standings per class (dropped results in brackets). +- [x] Server PDF: wide tables (16 columns for 12 rounds) were truncated, with names cut to 4 characters and the last columns lost. Now: unchanged layout when it fits, otherwise compact columns, landscape A4 when still too wide, and only then is the widest column shortened. Checked visually with a 12-round, 14-driver table. + +Verified 2026-10-10: +- 259 unit and integration tests pass. `tests/series_logic.test.js` covers the ÖRC points incl. places 11-25, F1 with no points after the table, the club filter with re-ranking and case/space-insensitive matching, 0 points without a lap, best-N and drop-X counting incl. cancelled rounds, dropped absences, three-way ties settled by the latest round, more wins winning, per-class tables, and other classes' events ignored. The PDF integration test covers portrait for narrow tables, and landscape with full names and the last column for wide ones. +- `tests/browser_series.js` checks the full flow: + - a series is created on the page with a club, one class and drop-1; + - two rounds are created from dates (named #1/#2 by date order); + - round 1 gets a finished A-final where a guest wins; + - the table shows Anna 100 and Bo 90 with the guest excluded and round 2 empty; + - the PDF payload has the same row. +- 65 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. + ## Documentation maintenance - [x] Update the architecture's module/dependency inventory as modules change. diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 55fa8c3..b8e11cb 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -597,6 +597,15 @@ Owns: - `countsForBestLap`, `listPendingSuspectLaps` (Timing / Race Day panel via `renderSuspectLapsPanel` in `timing_views.js`) - decisions go through `setCompetitorLapStatus` in `judging_logic.js` (remove / restore / approve, logged and undoable); `app.js` saves judge corrections with `saveCorrectionFor`, which also posts the session result to `POST /api/session-results` +### `src/series_logic.js` / `src/series_view.js` +Type: +- pure series/cup scoring, and the "Series / cup" page + +Owns: +- `state.series` entries (`normalizeSeries`): name, club, classIds, eventIds, pointsTable + pointsStep/pointsMin, countMode (`best` / `drop`) + countValue +- `buildSeriesStandings(series, { events, dayResults, getDriverClub, ... })`: one table per class; each event's day result comes from `getEventDayResult` in `app.js` (final standings, then qualifying order) +- the page renders the settings, the round picker, create-rounds-from-dates (`createSeriesEvents` in `app.js`) and the standings, plus print (`openPrintWindow`) and PDF (`/api/export/pdf`, which switches to landscape for wide tables) + ### `src/misc_views.js` Type: - misc page renderers diff --git a/package-lock.json b/package-lock.json index ad66cfe..d213707 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.33.0", + "version": "1.34.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.33.0", + "version": "1.34.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index dd3716f..60b6910 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.33.0", + "version": "1.34.0", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/server.js b/server.js index 5ab3cf9..04e33a3 100644 --- a/server.js +++ b/server.js @@ -816,14 +816,23 @@ function normalizePdfExportPayload(input = {}) { return { title, subtitle, brandName, brandTagline, footer, theme, logoDataUrl, filename, sections }; } +// Wide tables (a series with 12 rounds has 16 columns) don't fit portrait A4 even when +// compacted - the page then turns landscape (126 characters per line instead of 86). +const PDF_PORTRAIT_CHARS = 86; +const PDF_LANDSCAPE_CHARS = 126; +function pdfNeedsLandscape(payload) { + return payload.sections.some((section) => section.headers.length && getCompactTableWidth(section.headers, section.rows) > PDF_PORTRAIT_CHARS); +} + function buildSimplePdf(payload) { - const pageWidth = 595; - const pageHeight = 842; + const landscape = pdfNeedsLandscape(payload); + const pageWidth = landscape ? 842 : 595; + const pageHeight = landscape ? 595 : 842; const marginLeft = 42; const marginTop = 56; const marginBottom = 56; const lineHeight = 15; - const maxChars = 86; + const maxChars = landscape ? PDF_LANDSCAPE_CHARS : PDF_PORTRAIT_CHARS; const logo = parsePdfLogo(payload.logoDataUrl); const headerOffset = logo ? logo.displayHeight + 18 : 0; const lines = buildPdfLines(payload, maxChars); @@ -1030,11 +1039,11 @@ function buildPdfLines(payload, maxChars) { lines.push(payload.theme === "motorsport" ? `> ${section.title}` : section.title); } if (section.headers.length) { - const widths = getColumnWidths(section.headers, section.rows, maxChars); - lines.push(formatPdfRow(section.headers, widths)); - lines.push(formatPdfRow(widths.map((width) => (payload.theme === "minimal" ? " ".repeat(Math.min(width, 1)) : "-".repeat(Math.min(width, 12)))), widths)); + const { widths, separator } = getColumnWidths(section.headers, section.rows, maxChars); + lines.push(formatPdfRow(section.headers, widths, separator)); + lines.push(formatPdfRow(widths.map((width) => (payload.theme === "minimal" ? " ".repeat(Math.min(width, 1)) : "-".repeat(Math.min(width, 12)))), widths, separator)); section.rows.forEach((row) => { - lines.push(formatPdfRow(row, widths)); + lines.push(formatPdfRow(row, widths, separator)); }); } else { section.rows.forEach((row) => { @@ -1051,28 +1060,36 @@ function buildPdfLines(payload, maxChars) { return lines; } +const longestInColumn = (headers, rows, index) => Math.max(String(headers[index] || "").length, ...rows.map((row) => String(row[index] || "").length)); +// Columns exactly as wide as their content (max 26), one space between them. +function getCompactTableWidth(headers, rows) { + return headers.reduce((sum, _header, index) => sum + Math.min(26, Math.max(1, longestInColumn(headers, rows, index))), 0) + Math.max(0, headers.length - 1); +} + +// The usual layout (" | " between columns at least 4 wide) when it fits; otherwise compact +// columns with a single space; only then is the widest column shortened. function getColumnWidths(headers, rows, maxChars) { - const widths = headers.map((header, index) => { - const columnValues = rows.map((row) => String(row[index] || "")); - const longest = Math.max(header.length, ...columnValues.map((value) => value.length), 4); - return Math.min(26, Math.max(4, longest)); - }); - let total = widths.reduce((sum, width) => sum + width, 0) + Math.max(0, (widths.length - 1) * 3); + const roomy = headers.map((_header, index) => Math.min(26, Math.max(4, longestInColumn(headers, rows, index)))); + if (roomy.reduce((sum, width) => sum + width, 0) + Math.max(0, (roomy.length - 1) * 3) <= maxChars) { + return { widths: roomy, separator: " | " }; + } + const widths = headers.map((_header, index) => Math.min(26, Math.max(1, longestInColumn(headers, rows, index)))); + let total = widths.reduce((sum, width) => sum + width, 0) + Math.max(0, widths.length - 1); while (total > maxChars) { const widestIndex = widths.findIndex((width) => width === Math.max(...widths)); - if (widths[widestIndex] <= 4) { + if (widths[widestIndex] <= 3) { break; } widths[widestIndex] -= 1; total -= 1; } - return widths; + return { widths, separator: " " }; } -function formatPdfRow(values, widths) { +function formatPdfRow(values, widths, separator = " | ") { return values .map((value, index) => String(value || "").slice(0, widths[index]).padEnd(widths[index], " ")) - .join(" | "); + .join(separator); } function wrapPdfText(text, maxChars) { diff --git a/src/app.js b/src/app.js index 2239d65..7855467 100644 --- a/src/app.js +++ b/src/app.js @@ -9,6 +9,8 @@ import { recordTransponderSighting, buildWarmupCheck, createPrologue, getPrologu import { findTransponderConflict, getDriverProfileId } from "./driver_profile_logic.js"; import { renderRaceDayView, refreshRaceDayWarmup, refreshRaceDayPrologueClock, updateRaceDayStartDisplay } from "./race_day_view.js"; import { beginSession as beginSessionControl, finishSession as finishSessionControl, pauseSession as pauseSessionControl, resumeSession as resumeSessionControl } from "./session_control.js"; +import { normalizeSeries, buildSeriesStandings } from "./series_logic.js"; +import { renderSeriesView } from "./series_view.js"; import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudioSettings, isSilencedDuringRace } from "./race_audio.js"; import { renderRaceFormatField, renderRaceFormatContextCard, getRaceSummaryItems, getRaceSummaryWarnings, renderManageStatusBadge, renderRaceWizardSteps, renderRaceWizardContent, renderRaceStandingsTable } from "./race_setup_ui.js"; @@ -88,6 +90,7 @@ const getSessionTypeLabel = (type) => getSessionTypeLabelLogic(type, { t }); const getStatusLabel = (status) => getStatusLabelLogic(status, { t }); const isUntimedSession = (session) => isUntimedSessionLogic(session); const getActiveSession = () => getActiveSessionLogic({ state }); +const normalizeSeriesList = (list) => (Array.isArray(list) ? list : []).map((series) => normalizeSeries(series, { uid })).filter((series) => series.id); const getSessionTargetMs = (session) => getSessionTargetMsLogic(session, { isUntimedSession }); const getSessionLapWindow = (session) => getSessionLapWindowLogic(session, { state }); const isCountedPassing = (passing) => isCountedPassingLogic(passing); @@ -489,6 +492,84 @@ const buildStepTrainingProgress = (trainingState, driverId, classId, config, now const buildTrainingRecommendation = (stats) => buildTrainingRecommendationLogic(stats); const getTrainingGoalProgress = (goal, stats, exerciseSessionCount) => getTrainingGoalProgressLogic(goal, stats, exerciseSessionCount); const getNextTrainingMilestone = (stats) => getNextTrainingMilestoneLogic(stats); +// --- Series / cup --------------------------------------------------------------------- +// The day result a series scores: the final standings (A before B ...), then drivers who +// raced no final in qualifying order; or the qualifying order alone if no finals ran. Only +// once at least one qualifying/heat/final of the event is finished. `hasLap`: at least one +// counted lap in any of them (no lap = 0 series points). +const getEventDayResult = (event) => { + const sessions = getSessionsForEvent(event.id).filter((session) => ["qualification", "heat", "final"].includes(session.type)); + if (!sessions.some((session) => session.status === "finished")) return []; + const lapped = new Set(); + sessions.forEach((session) => (state.resultsBySession[session.id]?.passings || []).forEach((passing) => { + if (passing.validLap !== false && passing.driverId) lapped.add(String(passing.driverId)); + })); + const order = []; + const add = (row) => { if (row?.driverId && !order.some((item) => item.driverId === row.driverId)) order.push({ driverId: row.driverId, driverName: row.driverName, hasLap: lapped.has(String(row.driverId)) }); }; + buildFinalStandings(event).forEach(add); + buildQualifyingStandings(event).forEach(add); + return order; +}; +const getSeriesStandings = (series) => buildSeriesStandings(series, { + events: state.events, + dayResults: new Map(state.events.filter((event) => series.eventIds.includes(event.id)).map((event) => [event.id, getEventDayResult(event)])), + getDriverClub: (driverId) => state.drivers.find((driver) => driver.id === driverId)?.club || "", + getDriverName: (driverId) => state.drivers.find((driver) => driver.id === driverId)?.name || "", + classNames: new Map(state.classes.map((item) => [item.id, item.name])), +}); +// One race per date and class, named " # ", with the chosen race format. +const createSeriesEvents = (series, dates, presetId) => { + const existingDates = new Set(state.events.filter((event) => series.eventIds.includes(event.id)).map((event) => event.date)); + const allDates = [...new Set([...existingDates, ...dates])].sort(); + let created = 0; + [...new Set(dates)].sort().forEach((date) => { + const round = allDates.indexOf(date) + 1; + series.classIds.forEach((classId) => { + if (state.events.some((event) => series.eventIds.includes(event.id) && event.date === date && event.classId === classId)) return; + const event = normalizeEvent({ id: uid("event"), name: `${series.name} #${round} ${getClassName(classId)}`, date, classId, mode: "race" }); + applyRaceFormatPreset(event, presetId); + state.events.push(event); + series.eventIds.push(event.id); + created += 1; + }); + }); + saveState(); + return created; +}; +const seriesCell = (table, row, 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})`; +}; +const seriesSubtitle = (series) => [series.club, t("series.count_rule", { rule: series.countMode === "drop" ? t("series.count_drop_value", { count: series.countValue }) : t("series.count_best_value", { count: series.countValue }) })].filter(Boolean).join(" • "); +const printSeries = (series, tables) => openPrintWindow(series.name, ` + + ${tables.map((table) => ``).join("")} +

${escapeHtml(t("series.legend"))}

`); +const exportSeriesPdf = async (series, tables) => { + const branding = resolveEventBranding(state.events.find((event) => series.eventIds.includes(event.id)) || {}); + await requestPdfExport({ + filename: `${(series.name || "series").replaceAll(/\s+/g, "_")}_standings.pdf`, + title: series.name, + subtitle: seriesSubtitle(series), + brandName: branding.brandName, + brandTagline: branding.brandTagline, + footer: branding.pdfFooter, + theme: branding.pdfTheme, + logoDataUrl: await ensurePdfLogoDataUrl(branding.logoDataUrl), + sections: tables.map((table) => buildPdfSection( + `${table.className} - ${t("series.counted_of", { counted: table.countedCount, completed: table.completedCount, total: table.events.length })}`, + [t("table.pos"), t("table.driver"), ...table.events.map((event, index) => String(index + 1)), t("series.starts"), t("series.total")], + table.rows.map((row) => [String(row.rank), row.driverName, ...table.events.map((event) => seriesCell(table, row, event)), String(row.starts), String(row.total)]), + )), + }); +}; +const renderSeries = () => renderSeriesView({ state, dom, t, escapeHtml, saveState, renderView, uid, getClassName, getSeriesStandings, createSeriesEvents, getRaceFormatPresets, exportSeriesPdf, printSeries }); const renderArchive = () => renderResultsArchiveView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, renderTable, getClassName, getEventName, buildLeaderboard, getCompetitorPassings, @@ -525,6 +606,7 @@ const NAV_ITEMS = [ { id: "race_day", titleKey: "nav.race_day", subtitleKey: "nav.race_day_sub", groupKey: "nav.group_start" }, { id: "race_setup", titleKey: "nav.race_setup", subtitleKey: "nav.race_setup_sub", groupKey: "nav.group_create" }, { id: "events", titleKey: "nav.events", subtitleKey: "nav.events_sub", groupKey: "nav.group_create" }, + { id: "series", titleKey: "nav.series", subtitleKey: "nav.series_sub", groupKey: "nav.group_create" }, { id: "timing", titleKey: "nav.timing", subtitleKey: "nav.timing_sub", groupKey: "nav.group_run" }, { id: "judging", titleKey: "nav.judging", subtitleKey: "nav.judging_sub", groupKey: "nav.group_run" }, { id: "overlay", titleKey: "nav.overlay", subtitleKey: "nav.overlay_sub", groupKey: "nav.group_run" }, @@ -617,6 +699,55 @@ const TRANSLATIONS = { "nav.group_system": "System och hjälp", "nav.events": "Sponsor Event", "nav.events_sub": "Sponsor-event och delade bilar", + "nav.series": "Serier / cup", + "nav.series_sub": "Poäng över flera race", + "series.title": "Serier och cuper", + "series.hint": "Samla flera race i en serie med poäng per placering. Välj klasser, poängtabell och hur många resultat som räknas. Ställningen räknas per klass.", + "series.new": "Ny serie", + "series.new_name": "Serie {year}", + "series.none": "Inga serier ännu.", + "series.unnamed": "Namnlös serie", + "series.settings": "Inställningar", + "series.name": "Namn", + "series.name_placeholder": "t.ex. Winter Championship 2026/2027", + "series.club": "Klubb (bara dess förare får poäng)", + "series.club_all": "tomt = alla får poäng", + "series.club_hint": "Förare från andra klubbar kör och syns i dagens resultat men får inga seriepoäng; klubbens förare rangordnas om utan dem. Klubben hämtas från förarregistret.", + "series.classes": "Klasser (en tabell per klass)", + "series.no_classes": "Skapa klasser först under Klasser.", + "series.points_preset": "Poängtabell", + "series.preset_orc": "100, 90, 82, 75, 69 ... (-4, lägst 10)", + "series.preset_hundred": "100, 80, 70, 60, 50 ... (-5, lägst 5)", + "series.preset_f1": "F1: 25, 18, 15, 12, 10 ...", + "series.preset_custom": "Egen", + "series.points_table": "Poäng plats 1, 2, 3 ...", + "series.points_table_hint": "Kommaseparerat, t.ex. 100, 80, 70, 60.", + "series.points_after": "Efter tabellen: minus per plats / lägst", + "series.points_after_hint": "0 / 0 = inga poäng efter tabellen.", + "series.count_mode": "Räknade resultat", + "series.count_best": "Bästa N räknas", + "series.count_drop": "Alla genomförda utom X sämsta", + "series.count_value": "N eller X", + "series.count_hint": "T.ex. bästa 9 av 12, eller stryk 3 (räknar automatiskt om ett race ställs in). Frånvaro = 0 och kan strykas.", + "series.count_rule": "räknas: {rule}", + "series.count_best_value": "bästa {count}", + "series.count_drop_value": "alla utom {count} sämsta", + "series.events": "Deltävlingar ({count})", + "series.events_hint": "Bocka i racen som ingår, eller skapa dem på en gång: ett race per datum och klass. Lägg sedan till förare och generera heat i Race Setup som vanligt.", + "series.no_events": "Inga race i de valda klasserna ännu - skapa dem nedan.", + "series.create_dates": "Skapa deltävlingar för datum", + "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.standings": "Ställning", + "series.print": "Skriv ut", + "series.starts": "Starter", + "series.total": "Summa", + "series.counted_of": "{counted} räknas av {completed} körda ({total} planerade)", + "series.no_results": "Inga körda deltävlingar ännu.", + "series.legend": "Siffra = seriepoäng. (Inom parentes) = struket resultat. – = deltog inte. Lika poäng: flest segrar, sedan andraplatser osv., sedan senaste gemensamma deltävling.", + "series.delete_confirm": "Ta bort serien \"{name}\"? Racen och deras resultat finns kvar.", "nav.race_setup": "Race Setup", "nav.race_setup_sub": "Open Practice, klubbrace och endurance", "nav.overlay": "Overlay", @@ -2282,6 +2413,55 @@ const TRANSLATIONS = { "nav.group_system": "System and help", "nav.events": "Sponsor Events", "nav.events_sub": "Sponsor events and shared cars", + "nav.series": "Series / cup", + "nav.series_sub": "Points across several races", + "series.title": "Series and cups", + "series.hint": "Group several races into a series with points per place. Choose classes, points table and how many results count. Standings are per class.", + "series.new": "New series", + "series.new_name": "Series {year}", + "series.none": "No series yet.", + "series.unnamed": "Unnamed series", + "series.settings": "Settings", + "series.name": "Name", + "series.name_placeholder": "e.g. Winter Championship 2026/2027", + "series.club": "Club (only its drivers score)", + "series.club_all": "empty = everyone scores", + "series.club_hint": "Drivers from other clubs race and appear in the day result but get no series points; the club's drivers are re-ranked without them. The club comes from the driver register.", + "series.classes": "Classes (one table per class)", + "series.no_classes": "Create classes first under Classes.", + "series.points_preset": "Points table", + "series.preset_orc": "100, 90, 82, 75, 69 ... (-4, min 10)", + "series.preset_hundred": "100, 80, 70, 60, 50 ... (-5, min 5)", + "series.preset_f1": "F1: 25, 18, 15, 12, 10 ...", + "series.preset_custom": "Custom", + "series.points_table": "Points for place 1, 2, 3 ...", + "series.points_table_hint": "Comma separated, e.g. 100, 80, 70, 60.", + "series.points_after": "After the table: minus per place / minimum", + "series.points_after_hint": "0 / 0 = no points after the table.", + "series.count_mode": "Counted results", + "series.count_best": "Best N count", + "series.count_drop": "All completed except the X worst", + "series.count_value": "N or X", + "series.count_hint": "E.g. best 9 of 12, or drop 3 (adjusts automatically if a race is cancelled). Absence = 0 and can be dropped.", + "series.count_rule": "counted: {rule}", + "series.count_best_value": "best {count}", + "series.count_drop_value": "all but the {count} worst", + "series.events": "Rounds ({count})", + "series.events_hint": "Tick the races that belong to the series, or create them in one go: one race per date and class. Then add drivers and generate heats in Race Setup as usual.", + "series.no_events": "No races in the selected classes yet - create them below.", + "series.create_dates": "Create rounds for dates", + "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.standings": "Standings", + "series.print": "Print", + "series.starts": "Starts", + "series.total": "Total", + "series.counted_of": "{counted} counted of {completed} run ({total} planned)", + "series.no_results": "No rounds run yet.", + "series.legend": "Number = series points. (In brackets) = dropped result. – = did not take part. Ties: most wins, then 2nd places etc., then the latest common round.", + "series.delete_confirm": "Delete the series \"{name}\"? The races and their results are kept.", "nav.race_setup": "Race Setup", "nav.race_setup_sub": "Open Practice, club races and endurance", "nav.overlay": "Overlay", @@ -4369,6 +4549,7 @@ function seedDefaultData() { state.sessions = state.sessions.map((session) => normalizeSession(session)); state.training = normalizeTrainingState(state.training); state.trackLayouts = normalizeTrackLayouts(state.trackLayouts, { uid }); + state.series = normalizeSeriesList(state.series); saveState({ skipBackend: true }); } @@ -4389,6 +4570,7 @@ function loadState() { training: normalizeTrainingState(parsed.training), activeSessionId: parsed.activeSessionId || null, trackLayouts: normalizeTrackLayouts(parsed.trackLayouts, { uid }), + series: normalizeSeriesList(parsed.series), settings: { wsUrl: parsed.settings?.wsUrl || "ws://127.0.0.1:9000", splitDecoderEnabled: parsed.settings?.splitDecoderEnabled === true, @@ -4460,6 +4642,7 @@ function loadState() { training: normalizeTrainingState(), activeSessionId: null, trackLayouts: [], + series: [], settings: { wsUrl: "ws://127.0.0.1:9000", splitDecoderEnabled: false, @@ -4724,6 +4907,7 @@ function buildPersistableState() { activeSessionId: state.activeSessionId, settings: state.settings, trackLayouts: normalizeTrackLayouts(state.trackLayouts, { uid }), + series: normalizeSeriesList(state.series), }; } @@ -5334,6 +5518,9 @@ function renderView() { case "archive": renderArchive(); break; + case "series": + renderSeries(); + break; case "timing": renderTiming(); break; diff --git a/src/runtime_services.js b/src/runtime_services.js index ad6ebe7..ac88556 100644 --- a/src/runtime_services.js +++ b/src/runtime_services.js @@ -1,3 +1,4 @@ +import { normalizeSeries } from "./series_logic.js"; import { speakWithPiper, isPiperSpeaking } from "./piper_speech.js"; export function createDefaultAmmcConfigHelper() { return { @@ -113,6 +114,7 @@ export function applyPersistedStateHelper(persisted, deps) { state.training = normalizeTrainingState(persisted.training); state.activeSessionId = persisted.activeSessionId || null; state.trackLayouts = normalizeTrackLayouts(persisted.trackLayouts); + state.series = (Array.isArray(persisted.series) ? persisted.series : []).map((series) => normalizeSeries(series)).filter((series) => series.id); state.settings = { wsUrl: persisted.settings?.wsUrl || state.settings.wsUrl || "ws://127.0.0.1:9000", splitDecoderEnabled: persisted.settings?.splitDecoderEnabled === true, diff --git a/src/series_logic.js b/src/series_logic.js new file mode 100644 index 0000000..89d3300 --- /dev/null +++ b/src/series_logic.js @@ -0,0 +1,148 @@ +// Series / cup: a set of races (events) scored together. Each event is one class, so a series +// with several classes gives one standings table per class. Pure functions - app.js feeds +// each event's day result (finishing order, from finals or else qualifying). +// +// Rules supported (see ROADMAP "Series / cup"): +// - points per place from a table, then a fixed step down to a minimum for later places; +// - only drivers from the series' club score (others still race and appear in the day +// result); club drivers are re-ranked without them; +// - a driver with no registered lap in the event gets 0 points and no place; +// - count the best N results, or all completed events minus X (so a cancelled event +// reduces the count automatically); +// - ties: more wins among counted results, then 2nd places, 3rd..., then the latest common +// event where both scored, going backwards; otherwise a shared position. + +export const SERIES_POINT_PRESETS = Object.freeze({ + orc: { table: [100, 90, 82, 75, 69, 64, 60, 56, 52, 48], step: 4, min: 10 }, + hundred: { table: [100, 80, 70, 60, 50, 45, 40, 35, 30, 25], step: 5, min: 5 }, + f1: { table: [25, 18, 15, 12, 10, 8, 6, 4, 2, 1], step: 0, min: 0 }, +}); + +const clampInt = (value, fallback, min = 0) => { + const number = Math.round(Number(value)); + return Number.isFinite(number) && number >= min ? number : fallback; +}; + +export function parsePointsTable(text) { + return String(text || "") + .split(/[\s,;]+/) + .map((value) => Math.round(Number(value))) + .filter((value) => Number.isFinite(value) && value >= 0); +} + +export function normalizeSeries(series = {}, { uid } = {}) { + const preset = SERIES_POINT_PRESETS.orc; + const table = Array.isArray(series.pointsTable) ? series.pointsTable.map((value) => clampInt(value, 0)) : preset.table.slice(); + return { + id: String(series.id || (uid ? uid("series") : "")), + name: String(series.name || "").trim(), + club: String(series.club || "").trim(), + classIds: Array.isArray(series.classIds) ? [...new Set(series.classIds.map(String).filter(Boolean))] : [], + eventIds: Array.isArray(series.eventIds) ? [...new Set(series.eventIds.map(String).filter(Boolean))] : [], + pointsTable: table.length ? table : preset.table.slice(), + pointsStep: clampInt(series.pointsStep, preset.step), + pointsMin: clampInt(series.pointsMin, preset.min), + countMode: series.countMode === "drop" ? "drop" : "best", + countValue: clampInt(series.countValue, series.countMode === "drop" ? 0 : 9), + createdAt: Number(series.createdAt) > 0 ? Number(series.createdAt) : Date.now(), + }; +} + +export function getSeriesPointsForPlace(series, place) { + const table = series.pointsTable || []; + if (!(place >= 1)) return 0; + if (place <= table.length) return table[place - 1]; + // Step 0: every place after the table gets the minimum (F1: 0 from 11th). + if (!Number(series.pointsStep)) return Number(series.pointsMin || 0); + const last = table.length ? table[table.length - 1] : 0; + return Math.max(Number(series.pointsMin || 0), last - Number(series.pointsStep) * (place - table.length)); +} + +const sameClub = (club, seriesClub) => String(club || "").trim().toLowerCase() === String(seriesClub || "").trim().toLowerCase(); + +// dayResult: the event's finishing order, [{ driverId, driverName, hasLap }]. +// Returns per-driver { place, points } for the drivers who score in this series. +export function scoreSeriesEvent(series, dayResult, { getDriverClub = () => "" } = {}) { + const scored = new Map(); + let place = 0; + (dayResult || []).forEach((row) => { + if (!row?.driverId || scored.has(row.driverId)) return; + if (series.club && !sameClub(getDriverClub(row.driverId), series.club)) return; + if (!row.hasLap) { + scored.set(row.driverId, { place: null, points: 0, driverName: row.driverName }); + return; + } + place += 1; + scored.set(row.driverId, { place, points: getSeriesPointsForPlace(series, place), driverName: row.driverName }); + }); + return scored; +} + +export function getCountedResultsCount(series, completedEvents) { + if (series.countMode === "drop") return Math.max(Math.min(1, completedEvents), completedEvents - series.countValue); + return Math.min(series.countValue || completedEvents, completedEvents); +} + +function compareTie(a, b, completedEventIds) { + const maxPlace = Math.max(0, ...a.countedPlaces, ...b.countedPlaces); + for (let place = 1; place <= maxPlace; place += 1) { + const diff = b.countedPlaces.filter((value) => value === place).length - a.countedPlaces.filter((value) => value === place).length; + if (diff) return diff; + } + for (let index = completedEventIds.length - 1; index >= 0; index -= 1) { + const left = a.byEvent.get(completedEventIds[index]); + const right = b.byEvent.get(completedEventIds[index]); + if (left?.place && right?.place && left.place !== right.place) return left.place - right.place; + } + return 0; +} + +// events: the series' events (each { id, name, date, classId }), dayResults: Map eventId -> +// day result (empty/missing = not run yet). Returns one table per class. +export function buildSeriesStandings(series, { events = [], dayResults = new Map(), getDriverClub, getDriverName = () => "", classNames = new Map() } = {}) { + const inSeries = events + .filter((event) => series.eventIds.includes(event.id)) + .sort((a, b) => String(a.date || "").localeCompare(String(b.date || "")) || String(a.name || "").localeCompare(String(b.name || ""))); + const classIds = series.classIds.length ? series.classIds : [...new Set(inSeries.map((event) => event.classId))]; + return classIds.map((classId) => { + const classEvents = inSeries.filter((event) => event.classId === classId); + const completed = classEvents.filter((event) => (dayResults.get(event.id) || []).length > 0); + const completedIds = completed.map((event) => event.id); + const counted = getCountedResultsCount(series, completed.length); + const drivers = new Map(); + completed.forEach((event) => { + scoreSeriesEvent(series, dayResults.get(event.id), { getDriverClub }).forEach((result, driverId) => { + if (!drivers.has(driverId)) drivers.set(driverId, { driverId, driverName: getDriverName(driverId) || result.driverName || driverId, byEvent: new Map() }); + drivers.get(driverId).byEvent.set(event.id, result); + }); + }); + const rows = [...drivers.values()].map((driver) => { + // Every completed event is a result; absence counts as 0 and can be struck. + const results = completed.map((event) => ({ eventId: event.id, ...(driver.byEvent.get(event.id) || { place: null, points: 0, absent: true }) })); + const keep = new Set([...results.keys()].sort((a, b) => results[b].points - results[a].points || a - b).slice(0, counted)); + results.forEach((result, index) => { result.counted = keep.has(index); }); + return { + driverId: driver.driverId, + driverName: driver.driverName, + results, + total: results.filter((result) => result.counted).reduce((sum, result) => sum + result.points, 0), + starts: results.filter((result) => !result.absent).length, + countedPlaces: results.filter((result) => result.counted && result.place).map((result) => result.place), + byEvent: driver.byEvent, + }; + }); + rows.sort((a, b) => b.total - a.total || compareTie(a, b, completedIds) || a.driverName.localeCompare(b.driverName)); + rows.forEach((row, index) => { + const previous = rows[index - 1]; + row.rank = previous && previous.total === row.total && compareTie(previous, row, completedIds) === 0 ? previous.rank : index + 1; + }); + return { + classId, + className: classNames.get(classId) || classId, + events: classEvents.map((event) => ({ id: event.id, name: event.name, date: event.date, completed: completedIds.includes(event.id) })), + completedCount: completed.length, + countedCount: counted, + rows: rows.map(({ byEvent, countedPlaces, ...row }) => row), + }; + }); +} diff --git a/src/series_view.js b/src/series_view.js new file mode 100644 index 0000000..269b92a --- /dev/null +++ b/src/series_view.js @@ -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 ` +
+ + +
${t("series.classes")} + ${state.classes.map((item) => ``).join("") || `

${t("series.no_classes")}

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

${t("series.no_events")}

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

${t("series.title")}

${t("series.hint")}

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

${t("series.none")}

`} +
+
+ ${series ? ` +
+

${t("series.settings")}

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

${t("series.events", { 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")}

+
+
${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 }); + 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 }; diff --git a/src/styles.css b/src/styles.css index 3002f60..2a5418c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4569,3 +4569,15 @@ tr.is-removed td.lap-actions { @media (max-width: 720px) { .backup-row.track-layout-row { grid-template-columns: 1fr; } } + +/* 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-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-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; } +.series-table td:nth-child(2), .series-table th:nth-child(2) { text-align: left; } +.series-struck { color: var(--muted); } diff --git a/tests/browser_series.js b/tests/browser_series.js new file mode 100644 index 0000000..3a0b574 --- /dev/null +++ b/tests/browser_series.js @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; + +// Series / cup end to end: create a series on the Series page, pick its club and class, +// create two rounds from a list of dates, run the first round (seeded final results), and +// check the standings: only club drivers score (re-ranked without the guest), the round not +// run yet is an empty column, and the PDF export carries the same table. +export async function runSeriesSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs }) { + const patchState = async (patch) => { + const current = await savedState(); + Object.assign(current, patch); + const response = await fetch(base + "/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(current) }); + assert.equal(response.ok, true); + }; + const dialogsAtStart = dialogs.length; + const seeded = await savedState(); + await patchState({ + classes: [...seeded.classes, { id: "sr_2wd", name: "SR 2WD" }, { id: "sr_4wd", name: "SR 4WD" }], + drivers: [ + ...seeded.drivers, + { id: "sr_guest", name: "SR Guest", classId: "sr_2wd", club: "Other RC", transponder: "8660001" }, + { id: "sr_anna", name: "SR Anna", classId: "sr_2wd", club: "Östersunds RC", transponder: "8660002" }, + { id: "sr_bo", name: "SR Bo", classId: "sr_2wd", club: "Östersunds RC", transponder: "8660003" }, + ], + }); + + try { + await navigate("series", "seriesNew"); + await evaluate(`document.getElementById("seriesNew").click()`); + await waitFor(async () => Boolean(await evaluate(`document.getElementById("seriesForm")`)), "series form"); + await evaluate(`(() => { + const form = document.getElementById("seriesForm"); + form.elements.name.value = "SR Winter Cup"; + form.elements.club.value = "Östersunds RC"; + form.querySelectorAll('input[name="classIds"]').forEach((box) => { box.checked = box.value === "sr_2wd"; }); + form.elements.countMode.value = "drop"; + form.elements.countValue.value = "1"; + form.requestSubmit(); + })()`); + await waitFor(async () => (await savedState()).series?.some((series) => series.name === "SR Winter Cup"), "series saved"); + const saved = (await savedState()).series.find((series) => series.name === "SR Winter Cup"); + assert.deepEqual(saved.classIds, ["sr_2wd"]); + assert.equal(saved.club, "Östersunds RC"); + assert.deepEqual(saved.pointsTable.slice(0, 3), [100, 90, 82], "the ÖRC table is the default"); + + await evaluate(`(() => { const form = document.getElementById("seriesCreateEvents"); form.elements.dates.value = "2026-11-15\\n2026-11-01"; form.requestSubmit(); })()`); + await waitFor(async () => ((await savedState()).series.find((series) => series.id === saved.id)?.eventIds.length) === 2, "two rounds created"); + const state = await savedState(); + const rounds = state.series.find((series) => series.id === saved.id).eventIds.map((id) => state.events.find((event) => event.id === id)); + assert.deepEqual(rounds.map((event) => [event.name, event.date, event.classId]).sort(), [["SR Winter Cup #1 SR 2WD", "2026-11-01", "sr_2wd"], ["SR Winter Cup #2 SR 2WD", "2026-11-15", "sr_2wd"]]); + console.log("PASS a series is created with club, class and count rule, and its rounds are created from dates"); + + // Round 1 is run: a finished A-final where the guest wins, Anna 2nd, Bo 3rd. + const round1 = rounds.find((event) => event.date === "2026-11-01"); + const startedAt = Date.parse("2026-11-01T10:00:00Z"); + const finalSession = { id: "sr_final", eventId: round1.id, name: "A Final", type: "final", durationMin: 5, status: "finished", startedAt, endedAt: startedAt + 300000, driverIds: ["sr_guest", "sr_anna", "sr_bo"] }; + const competitor = (driverId, laps) => ({ key: `driver_${driverId}`, driverId, driverName: driverId, laps, startTimestamp: startedAt, lastTimestamp: startedAt + laps * 20000, bestLapMs: 20000 }); + const passingsFor = (driverId, laps) => Array.from({ length: laps }, (_, index) => ({ driverId, competitorKey: `driver_${driverId}`, lapMs: 20000, timestamp: startedAt + (index + 1) * 20000, validLap: true })); + await patchState({ + sessions: [...(await savedState()).sessions, finalSession], + resultsBySession: { ...(await savedState()).resultsBySession, sr_final: { passings: [...passingsFor("sr_guest", 15), ...passingsFor("sr_anna", 14), ...passingsFor("sr_bo", 13)], competitors: { driver_sr_guest: competitor("sr_guest", 15), driver_sr_anna: competitor("sr_anna", 14), driver_sr_bo: competitor("sr_bo", 13) }, adjustments: [] } }, + }); + + await navigate("series", "seriesNew"); + await evaluate(`document.querySelector('[data-series-select="${saved.id}"]')?.click()`); + await waitFor(async () => Boolean(await evaluate(`document.querySelector('[data-series-class="sr_2wd"] tbody tr')`)), "standings table"); + const rows = await evaluate(`[...document.querySelectorAll('[data-series-class="sr_2wd"] tbody tr')].map((tr) => [...tr.children].map((td) => td.textContent.trim()))`); + assert.deepEqual(rows, [["1", "SR Anna", "100", "", "1", "100"], ["2", "SR Bo", "90", "", "1", "90"]], "the guest races but doesn't score; Anna is re-ranked 1st; round 2 is empty"); + console.log("PASS standings: only club drivers score, re-ranked without guests; unplayed rounds stay empty"); + + // The PDF export carries the same table (request captured in the page, not downloaded). + const payload = await evaluate(`(async () => { + const realFetch = window.fetch; + let body = null; + window.fetch = async (url, options) => { if (String(url).includes("/api/export/pdf")) { body = options.body; return new Response(new Blob(["%PDF"]), { status: 200 }); } return realFetch(url, options); }; + try { document.getElementById("seriesPdf").click(); for (let i = 0; i < 50 && !body; i += 1) await new Promise((r) => setTimeout(r, 20)); return body; } + finally { window.fetch = realFetch; } + })()`); + const pdf = JSON.parse(payload); + assert.equal(pdf.title, "SR Winter Cup"); + assert.deepEqual(pdf.sections[0].rows[0], ["1", "SR Anna", "100", "", "1", "100"]); + console.log("PASS the series PDF export contains the standings"); + } finally { + dialogs.splice(dialogsAtStart); + const current = await savedState(); + const seriesEventIds = new Set((current.series || []).filter((series) => series.name === "SR Winter Cup").flatMap((series) => series.eventIds)); + await patchState({ + series: (current.series || []).filter((series) => series.name !== "SR Winter Cup"), + events: current.events.filter((event) => !seriesEventIds.has(event.id)), + sessions: current.sessions.filter((session) => session.id !== "sr_final"), + drivers: current.drivers.filter((driver) => !driver.id.startsWith("sr_")), + classes: current.classes.filter((item) => !item.id.startsWith("sr_")), + }); + } +} diff --git a/tests/browser_smoke.js b/tests/browser_smoke.js index ef6046b..26a445a 100644 --- a/tests/browser_smoke.js +++ b/tests/browser_smoke.js @@ -15,6 +15,7 @@ import { runPracticeResetSmoke } from "./browser_practice_reset.js"; import { runSuspectLapsSmoke } from "./browser_suspect_laps.js"; import { runCorrectionsScopeSmoke } from "./browser_corrections_scope.js"; import { runDriversPageSmoke } from "./browser_drivers_page.js"; +import { runSeriesSmoke } from "./browser_series.js"; import { runObsPopupSmoke } from "./browser_obs_popup.js"; import { runDecoderReconnectSmoke } from "./browser_decoder_reconnect.js"; import assert from "node:assert/strict"; @@ -160,6 +161,7 @@ try { await runSuspectLapsSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs }); await runCorrectionsScopeSmoke({ evaluate, navigate, savedState, waitFor, base }); await runDriversPageSmoke({ evaluate, navigate, savedState, waitFor, base }); + await runSeriesSmoke({ evaluate, navigate, savedState, waitFor, base, dialogs }); await runRaceDaySmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, dialogs }); await runRaceDayStartSequencesSmoke({ evaluate, navigate, waitFor, pause, base, dialogs }); await runObsPopupSmoke({ evaluate, navigate, fill, savedState, waitFor, pause, base, debugPort, dialogs, realClick }); diff --git a/tests/run.js b/tests/run.js index e8eec1a..78cedd5 100644 --- a/tests/run.js +++ b/tests/run.js @@ -1,6 +1,7 @@ import "./auth.test.js"; import "./prologue_logic.test.js"; import "./suspect_laps_logic.test.js"; +import "./series_logic.test.js"; import "./tts.test.js"; import "./piper_speech.test.js"; import "./driver_profile_logic.test.js"; diff --git a/tests/series_logic.test.js b/tests/series_logic.test.js new file mode 100644 index 0000000..dcb8586 --- /dev/null +++ b/tests/series_logic.test.js @@ -0,0 +1,95 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { normalizeSeries, getSeriesPointsForPlace, scoreSeriesEvent, getCountedResultsCount, buildSeriesStandings, parsePointsTable, SERIES_POINT_PRESETS } from "../src/series_logic.js"; + +const orc = normalizeSeries({ id: "s", name: "ÖRC Winter", club: "Östersunds RC", classIds: ["2wd"], countMode: "best", countValue: 9 }); + +test("ÖRC points: 100, 90, 82 ... 48, then -4 per place down to 10 from place 20", () => { + assert.deepEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((place) => getSeriesPointsForPlace(orc, place)), [100, 90, 82, 75, 69, 64, 60, 56, 52, 48]); + assert.deepEqual([11, 12, 13, 19, 20, 25].map((place) => getSeriesPointsForPlace(orc, place)), [44, 40, 36, 12, 10, 10]); + const f1 = normalizeSeries({ pointsTable: SERIES_POINT_PRESETS.f1.table, pointsStep: 0, pointsMin: 0 }); + assert.equal(getSeriesPointsForPlace(f1, 11), 0); + assert.deepEqual(parsePointsTable("100, 80 70;60 x -5"), [100, 80, 70, 60]); +}); + +test("only club members score; they are re-ranked without guests; no lap means 0 points", () => { + const clubs = { a: "Östersunds RC", guest: "Other RC", b: "östersunds rc ", c: "Östersunds RC" }; + const scored = scoreSeriesEvent(orc, [ + { driverId: "guest", hasLap: true }, { driverId: "a", hasLap: true }, { driverId: "b", hasLap: true }, { driverId: "c", hasLap: false }, + ], { getDriverClub: (id) => clubs[id] }); + assert.equal(scored.has("guest"), false, "a guest races but doesn't score"); + assert.deepEqual(scored.get("a"), { place: 1, points: 100, driverName: undefined }, "first member after the guest is ranked 1st"); + assert.equal(scored.get("b").points, 90, "club name match ignores case and spaces"); + assert.deepEqual([scored.get("c").place, scored.get("c").points], [null, 0]); + const open = normalizeSeries({ club: "" }); + assert.equal(scoreSeriesEvent(open, [{ driverId: "guest", hasLap: true }]).get("guest").points, 100, "no club set: everyone scores"); +}); + +test("best N, or completed minus X (so a cancelled event lowers the count)", () => { + assert.equal(getCountedResultsCount(orc, 12), 9); + assert.equal(getCountedResultsCount(orc, 4), 4); + const drop3 = normalizeSeries({ countMode: "drop", countValue: 3 }); + assert.equal(getCountedResultsCount(drop3, 12), 9); + assert.equal(getCountedResultsCount(drop3, 10), 7, "two cancelled events: 10 - 3"); + assert.equal(getCountedResultsCount(drop3, 2), 1, "at least one result"); + assert.equal(getCountedResultsCount(drop3, 0), 0); +}); + +function series3(extra = {}) { + return normalizeSeries({ id: "s", club: "", classIds: ["2wd"], eventIds: ["e1", "e2", "e3", "e4"], countMode: "best", countValue: 2, ...extra }); +} +const events = [ + { id: "e1", name: "R1", date: "2026-11-01", classId: "2wd" }, + { id: "e2", name: "R2", date: "2026-11-15", classId: "2wd" }, + { id: "e3", name: "R3", date: "2026-11-29", classId: "2wd" }, + { id: "e4", name: "R4", date: "2026-12-13", classId: "2wd" }, // not run yet +]; +const row = (driverId) => ({ driverId, driverName: driverId.toUpperCase(), hasLap: true }); + +test("standings count the best results, strike the rest, and absence is a 0 that can be struck", () => { + const dayResults = new Map([["e1", [row("a"), row("b")]], ["e2", [row("b"), row("a")]], ["e3", [row("a")]]]); + const [table] = buildSeriesStandings(series3(), { events, dayResults, getDriverName: (id) => id.toUpperCase() }); + assert.equal(table.completedCount, 3); + assert.equal(table.countedCount, 2); + assert.equal(table.events.length, 4, "the not-yet-run event is listed as a column"); + const [first, second] = table.rows; + assert.equal(first.driverName, "A"); + assert.equal(first.total, 200, "two wins counted, the 2nd place struck"); + assert.deepEqual(first.results.map((result) => [result.points, result.counted]), [[100, true], [90, false], [100, true]]); + assert.equal(second.total, 190); + assert.deepEqual(second.results.map((result) => [result.points, result.counted, Boolean(result.absent)]), [[90, true, false], [100, true, false], [0, false, true]]); + assert.equal(second.starts, 2); +}); + +test("ties: more wins first, then the latest common event, otherwise a shared position", () => { + const all = normalizeSeries({ id: "s", classIds: ["2wd"], eventIds: ["e1", "e2", "e3"], countMode: "best", countValue: 3, pointsTable: [10, 10, 5], pointsStep: 0, pointsMin: 0 }); + // A: 1st, 3rd, 2nd = 10+5+10 = 25 (1 win). B: 2nd, 1st, 3rd = 10+10+5 = 25 (1 win). C: 3rd, 2nd, 1st = 25. + const dayResults = new Map([ + ["e1", [row("a"), row("b"), row("c")]], + ["e2", [row("b"), row("c"), row("a")]], + ["e3", [row("c"), row("a"), row("b")]], + ]); + const [table] = buildSeriesStandings(all, { events, dayResults }); + // Same wins/2nds/3rds for all - the latest event (e3) decides: C, A, B. + assert.deepEqual(table.rows.map((entry) => [entry.driverId, entry.total, entry.rank]), [["c", 25, 1], ["a", 25, 2], ["b", 25, 3]]); + + const winsDecide = new Map([["e1", [row("a"), row("b")]], ["e2", [row("a"), row("b")]], ["e3", [row("b"), row("a")]]]); + const twoWins = normalizeSeries({ ...all, pointsTable: [10, 10] }); + assert.deepEqual(buildSeriesStandings(twoWins, { events, dayResults: winsDecide })[0].rows.map((entry) => entry.driverId), ["a", "b"], "A has two wins against one"); + + const shared = new Map([["e1", [row("a"), row("b")]]]); + const equal = normalizeSeries({ ...all, pointsTable: [10, 10] }); + const sharedRows = buildSeriesStandings(equal, { events, dayResults: shared })[0].rows; + assert.equal(sharedRows[0].rank, 1); + assert.equal(sharedRows[1].rank, 2, "different places in the latest event still separate them"); +}); + +test("each chosen class gets its own table; events of other classes are ignored", () => { + const multi = normalizeSeries({ id: "m", classIds: ["2wd", "4wd"], eventIds: ["e1", "x1"], countValue: 9 }); + const tables = buildSeriesStandings(multi, { + events: [events[0], { id: "x1", name: "R1 4WD", date: "2026-11-01", classId: "4wd" }, { id: "other", name: "Not in series", classId: "2wd" }], + dayResults: new Map([["e1", [row("a")]], ["x1", [row("a"), row("b")]], ["other", [row("z")]]]), + classNames: new Map([["2wd", "2WD"], ["4wd", "4WD"]]), + }); + assert.deepEqual(tables.map((table) => [table.className, table.rows.map((entry) => entry.driverId).join(",")]), [["2WD", "a"], ["4WD", "a,b"]]); +}); diff --git a/tests/server_persistence.integration.test.js b/tests/server_persistence.integration.test.js index a82a314..b5c47ea 100644 --- a/tests/server_persistence.integration.test.js +++ b/tests/server_persistence.integration.test.js @@ -535,6 +535,22 @@ test("PDF export renders Swedish åäö/ÅÄÖ correctly instead of mojibake", { // A name with å from a table row must also be encoded correctly, not just the title. const orjanWinAnsi = Buffer.from([0xd6, 0x72, 0x6a, 0x61, 0x6e]); // "Örjan" - Ö = 0xD6 assert.ok(pdfBuffer.includes(orjanWinAnsi), "a Swedish name inside a table row must also use WinAnsi bytes"); + assert.ok(pdfBuffer.toString("latin1").includes("/MediaBox [0 0 595 842]"), "a narrow table stays portrait A4"); + + // A series table with 12 rounds (16 columns) used to lose its last columns and cut names to + // 4 characters. It now turns landscape and keeps every column and the full name. + const wide = await fetch(`${baseUrl}/api/export/pdf`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: "Series", + sections: [{ title: "2WD", headers: ["Pos", "Driver", ...Array.from({ length: 12 }, (_, i) => String(i + 1)), "Starts", "Total"], rows: [["1", "Charlotte Lindqvist", ...Array.from({ length: 12 }, () => "(100)"), "12", "900"]] }], + }), + }); + const widePdf = Buffer.from(await wide.arrayBuffer()).toString("latin1"); + assert.ok(widePdf.includes("/MediaBox [0 0 842 595]"), "landscape for a wide table"); + assert.ok(widePdf.includes("Charlotte Lindqvist"), "the full name is kept"); + assert.ok(widePdf.includes("Total") && widePdf.includes("900"), "the last column is kept"); } finally { await stopServer(child); rmSync(dataDir, { recursive: true, force: true });