diff --git a/CHANGELOG.md b/CHANGELOG.md index 65b85e5..69fd63e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.37.0 - 2026-10-11 + +### Added + +- **Series hub:** each round of a series is run from the series page (step 3), one row per race. + - **Drivers:** pick them inline. **Copy from previous** takes the field from the previous round of the same class, and **New driver** registers a late entry in the class (and the series' club) and ticks it. + - **Format per round:** qualifying rounds, finals and max cars per heat. It can differ between rounds and classes, and locks once qualifying exists. + - **Status:** not started, qualifying created/running/done, finals, done. + - **Buttons:** Create qualifying, Create finals, and Open to go to the race. A round can be removed from the series; the race is kept. + - **Creating rounds from dates** takes the qualifying rounds, finals and max cars per heat directly. An existing race can be added from a list. + +### Changed + +- Qualifying heats are split as evenly as possible, and the number of heats still follows the number of drivers: 9 drivers with max 8 per heat gives 5 + 4 instead of 8 + 1 (someone driving alone). Reseeding later rounds uses the same split. Finals still fill from the top. + ## 1.36.1 - 2026-10-11 ### Fixed diff --git a/README.md b/README.md index 0fe8a9a..2d4198f 100644 --- a/README.md +++ b/README.md @@ -361,7 +361,12 @@ The integration test starts its own backend on a temporary localhost port and ne 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. +- **Rounds:** any number. Create them from a list of dates (one race per date and class) with the number of qualifying rounds, finals and max cars per heat, or add an existing race. +- **Running the season from the series page (step 3)**, one row per round: + - **Drivers:** click the number to tick who takes part. All drivers in the class are ticked to begin with. **Copy from previous** takes the field from the previous round of the same class, and **New driver** registers a late entry in the class (and the series' club) and ticks it. + - **Format per round:** qualifying rounds, finals and max cars per heat. Each round and class can differ; the format locks once qualifying is created. + - **Create qualifying:** the number of heats follows the number of drivers, split evenly (9 drivers, max 8 per heat → 5 + 4, never 8 + 1). + - **Create finals** once qualifying is done. **Status** shows not started, qualifying created/running/done, finals, done. **Open** goes to the race in Race Setup. - **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. - **Who scores series points** is a choice per series: diff --git a/README.sv.md b/README.sv.md index 132ece5..d6e529a 100644 --- a/README.sv.md +++ b/README.sv.md @@ -350,7 +350,12 @@ Integrationstestet startar en egen backend på en temporär localhost-port och l 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. +- **Deltävlingar:** valfritt antal. Skapa dem från en datumlista (ett race per datum och klass) med antal kvalomgångar, finaler och max bilar per heat, eller lägg till ett befintligt race. +- **Säsongen sköts från seriesidan (steg 3)**, en rad per deltävling: + - **Förare:** klicka på antalet och bocka i vilka som kör. Alla förare i klassen är förbockade från början. **Kopiera från förra** tar startfältet från förra deltävlingen i samma klass, och **Ny förare** lägger in en efteranmäld förare i klassen (och seriens klubb) och bockar i hen. + - **Format per deltävling:** kvalomgångar, finaler och max bilar per heat. Varje deltävling och klass kan skilja sig; formatet låses när kvalet har skapats. + - **Skapa kval:** antalet heat följer antalet förare och fördelas jämnt (9 förare, max 8 per heat → 5 + 4, aldrig 8 + 1). + - **Skapa finaler** när kvalet är klart. **Status** visar ej startad, kval skapat/pågår/klart, finaler, klar. **Öppna** går till racet i Race Setup. - **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. - **Vem får seriepoäng** väljs per serie: diff --git a/ROADMAP.md b/ROADMAP.md index a0a93ce..575cb92 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -829,6 +829,34 @@ Causes: 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. +### Follow-up: series hub (2026-10-11) + +The user found it unclear what to do manually after creating a series. They want: +- an easier setup; +- drivers added per race later; +- heats created by how many take part; +- the format allowed to differ, e.g. 2 heats + 1 final one round and 3 + 2 the next. + +Before, the series only created empty races, and everything else meant opening each race in Race Setup. + +- [x] Step 3 is a rounds table (`getSeriesRounds` in `app.js`): date, race/class, drivers, qualifying rounds / finals / max cars per heat (`setSeriesRoundFormat`, locked once qualifying exists; counted finals = all but one from three finals up), status (`getSeriesRoundStatus` in `series_logic.js`), Create qualifying / Create finals (the existing generators), Open (`selectedEventEditId` and Race Setup), and remove from the series. +- [x] Inline drivers per round: all / none, **Copy from previous** (same class, previous round by date), and **New driver** (registered in the class, with the series' club when members-only, and ticked). +- [x] Creating rounds from dates takes qualifying rounds, finals and max cars per heat. An existing race is added from a list instead of the old checkbox list. +- [x] `splitIntoBalancedGroups` in `event_race_logic.js` for qualifying heats and reseeding: even sizes, so nobody drives alone. Finals keep filling from the top. +- [x] Found while testing, fixed: a layout normalization test compared two `Date.now()` timestamps and failed intermittently. It now uses a fixed `startedAt`. + +Verified 2026-10-11: +- 264 unit tests pass. They cover round status, the even split (9→5+4, 13→5+4+4, order kept) and the qualifying generator (7 drivers, max 3 → 3+2+2). +- The browser test, on the series page: + - creates rounds with 2/1/2; + - unticks Bo for round 1; + - adds the late entry "SR Cia" (class and club checked, ticked); + - copies round 1's field to round 2; + - changes round 1 to 3 qualifying rounds while round 2 keeps 2; + - creates qualifying (6 heats, sizes 2 and 1); + - checks that the status shows and the format locks. +- Screenshots of the table and the open driver list were checked. 70 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 index 05a83bf..5aa7dfd 100644 Binary files a/docs/screenshots/series.jpg and b/docs/screenshots/series.jpg differ diff --git a/package-lock.json b/package-lock.json index 2631857..34df49b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.36.1", + "version": "1.37.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.36.1", + "version": "1.37.0", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 3295b50..57d686a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.36.1", + "version": "1.37.0", "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 6797984..db4b511 100644 --- a/scripts/capture-screenshots.mjs +++ b/scripts/capture-screenshots.mjs @@ -171,10 +171,15 @@ const shots = { 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 }]; + state.drivers.push({ id: "shot_rookie_1", name: "Elsa Rookie", classId: "shot_rookie" }, { id: "shot_rookie_2", name: "Theo Rookie", classId: "shot_rookie" }); + const round = (id, date, classId, name) => ({ id, name, date, classId, mode: "race", raceConfig: { qualifyingRounds: 2, finalLegs: 1, carsPerHeat: 6, countedFinalLegs: 1 } }); + state.events.push(round("shot_r1_2wd", "2026-11-01", "shot_class", "Winter #1 2WD Buggy"), round("shot_r1_rookie", "2026-11-01", "shot_rookie", "Winter #1 Rookie"), round("shot_r2_2wd", "2026-11-15", "shot_class", "Winter #2 2WD Buggy")); + state.series = [{ id: "shot_series", name: "Winter Championship 2026/2027", classIds: ["shot_class", "shot_rookie"], eventIds: ["shot_r1_2wd", "shot_r1_rookie", "shot_r2_2wd"], 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"); + // For checking other parts while developing: SHOT_EVAL runs in the page first. + if (process.env.SHOT_EVAL) { await evaluate(process.env.SHOT_EVAL); await new Promise((resolve) => setTimeout(resolve, 300)); } await scrollTo(process.env.SHOT_SCROLL || "#seriesForm", process.env.SHOT_SCROLL ? "center" : "start"); }, }, diff --git a/src/app.js b/src/app.js index d923fe0..53334af 100644 --- a/src/app.js +++ b/src/app.js @@ -9,7 +9,7 @@ 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 { normalizeSeries, buildSeriesStandings, getSeriesRoundStatus } from "./series_logic.js"; import { renderSeriesView } from "./series_view.js"; import { RACE_AUDIO_EVENT_DEFINITIONS, createRaceAudioEngine, normalizeRaceAudioSettings, isSilencedDuringRace } from "./race_audio.js"; @@ -518,7 +518,7 @@ const getSeriesStandings = (series) => buildSeriesStandings(series, { 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 createSeriesEvents = (series, dates, presetId, format = {}) => { 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; @@ -529,6 +529,7 @@ const createSeriesEvents = (series, dates, presetId) => { const event = normalizeEvent({ id: uid("event"), name: `${series.name} #${round} ${getClassName(classId)}`, date, classId, mode: "race" }); applyRaceFormatPreset(event, presetId); state.events.push(event); + if (Object.keys(format).length) setSeriesRoundFormat(event.id, format); series.eventIds.push(event.id); created += 1; }); @@ -536,6 +537,84 @@ const createSeriesEvents = (series, dates, presetId) => { saveState(); return created; }; +// --- Series hub: each round (one race) handled right from the series page ---------------- +const byDriverName = (a, b) => String(a.name || "").localeCompare(String(b.name || ""), undefined, { sensitivity: "base" }); +const getSeriesRounds = (series) => state.events + .filter((event) => series.eventIds.includes(event.id)) + .sort((a, b) => String(a.date || "").localeCompare(String(b.date || "")) || getClassName(a.classId).localeCompare(getClassName(b.classId), undefined, { numeric: true })) + .map((event) => { + const sessions = getSessionsForEvent(event.id); + const qualSessions = sessions.filter((session) => session.type === "qualification"); + return { + event, + className: getClassName(event.classId), + classDrivers: state.drivers.filter((driver) => driver.classId === event.classId).sort(byDriverName), + participantIds: getEventDrivers(event).map((driver) => driver.id), + status: getSeriesRoundStatus(sessions), + qualHeatCount: new Set(qualSessions.map((session) => String(session.name || "").split(" ").pop())).size, + finalCount: sessions.filter((session) => session.type === "final").length, + }; + }); +const findSeriesEvent = (eventId) => state.events.find((event) => event.id === eventId) || null; +// Format per round (can differ between rounds and classes); locked once qualifying exists. +const setSeriesRoundFormat = (eventId, values) => { + const event = findSeriesEvent(eventId); + if (!event) return; + const config = event.raceConfig; + config.qualifyingRounds = Math.max(1, Math.round(Number(values.qualifyingRounds ?? config.qualifyingRounds) || 1)); + config.finalLegs = Math.max(1, Math.round(Number(values.finalLegs ?? config.finalLegs) || 1)); + config.carsPerHeat = Math.max(2, Math.round(Number(values.carsPerHeat ?? config.carsPerHeat) || 2)); + config.carsPerFinal = Math.max(2, Math.round(Number(values.carsPerHeat ?? config.carsPerFinal) || config.carsPerFinal || 2)); + // Two of three finals count (or all, with fewer than three). + config.countedFinalLegs = config.finalLegs >= 3 ? config.finalLegs - 1 : config.finalLegs; + config.presetId = "custom"; + saveState(); +}; +const setSeriesRoundParticipants = (eventId, driverIds) => { + const event = findSeriesEvent(eventId); + if (!event) return; + event.raceConfig.driverIds = [...new Set(driverIds)]; + event.raceConfig.participantsConfigured = true; + saveState(); +}; +// Same starting field as the previous round of the same class in this series. +const copySeriesRoundParticipants = (series, eventId) => { + const rounds = getSeriesRounds(series); + const index = rounds.findIndex((round) => round.event.id === eventId); + const previous = rounds.slice(0, Math.max(0, index)).reverse().find((round) => round.event.classId === rounds[index]?.event.classId); + if (!previous) return false; + setSeriesRoundParticipants(eventId, previous.participantIds.filter((id) => rounds[index].classDrivers.some((driver) => driver.id === id))); + return true; +}; +// A driver turning up on the day: registered in the round's class (and the series' club for a +// members-only series) and ticked for this round. +const addDriverToSeriesRound = (series, eventId, { name, transponder }) => { + const event = findSeriesEvent(eventId); + const cleanName = String(name || "").trim(); + if (!event || !cleanName) return false; + const driver = normalizeDriver({ id: uid("driver"), name: cleanName, classId: event.classId, transponder: String(transponder || "").trim(), club: series.scoring === "club" ? series.club : "" }); + state.drivers.push(driver); + setSeriesRoundParticipants(eventId, [...getEventDrivers(event).map((item) => item.id), driver.id]); + return true; +}; +const generateSeriesRoundQualifying = (eventId) => { + const event = findSeriesEvent(eventId); + if (!event) return 0; + const created = generateQualifyingForRace(event); + saveState(); + return created; +}; +const generateSeriesRoundFinals = (eventId) => { + const event = findSeriesEvent(eventId); + if (!event) return 0; + const created = generateFinalsForRace(event); + saveState(); + return created; +}; +const openSeriesRound = (eventId) => { + selectedEventEditId = eventId; + navigateToView("race_setup"); +}; const seriesCell = (table, row, event) => { const result = row.results.find((item) => item.eventId === event.id); if (!event.completed || !result) return ""; @@ -569,7 +648,7 @@ const exportSeriesPdf = async (series, tables) => { )), }); }; -const renderSeries = () => renderSeriesView({ state, dom, t, escapeHtml, saveState, renderView, uid, getClassName, getSeriesStandings, createSeriesEvents, getRaceFormatPresets, exportSeriesPdf, printSeries }); +const renderSeries = () => renderSeriesView({ state, dom, t, escapeHtml, saveState, renderView, uid, getClassName, getSeriesStandings, createSeriesEvents, getRaceFormatPresets, exportSeriesPdf, printSeries, getSeriesRounds, setSeriesRoundFormat, setSeriesRoundParticipants, copySeriesRoundParticipants, addDriverToSeriesRound, generateSeriesRoundQualifying, generateSeriesRoundFinals, openSeriesRound }); const renderArchive = () => renderResultsArchiveView({ state, dom, t, escapeHtml, formatLap, formatRaceClock, renderTable, getClassName, getEventName, buildLeaderboard, getCompetitorPassings, @@ -715,6 +794,38 @@ const TRANSLATIONS = { "series.step3": "3 · Deltävlingar ({count})", "series.step4": "4 · Ställning", "series.classes_all": "Alla", + "series.round_participants": "Deltagare: {count} av {total} i klassen", + "series.copy_previous": "Kopiera från förra", + "series.no_previous_round": "Ingen tidigare deltävling i samma klass.", + "series.no_class_drivers": "Inga förare i klassen ännu - lägg till nedan.", + "series.new_driver_name": "Ny förare - namn", + "series.add_driver": "Lägg till", + "series.no_rounds": "Inga deltävlingar ännu - skapa dem ovan.", + "series.col_date": "Datum", + "series.col_race": "Race", + "series.col_drivers": "Förare", + "series.col_qual": "Kvalomgångar", + "series.col_qual_short": "Kval", + "series.col_qual_hint": "Antal kvalomgångar. Antalet heat per omgång räknas ut av antalet förare.", + "series.col_finals": "Finaler", + "series.col_per_heat": "Max bilar/heat", + "series.col_status": "Status", + "series.base_format": "Övrigt format (tider, starttyp)", + "series.make_qualifying": "Skapa kval", + "series.make_finals": "Skapa finaler", + "series.open_race": "Öppna", + "series.remove_round": "Ta bort ur serien (racet finns kvar)", + "series.remove_round_confirm": "Ta bort deltävlingen ur serien? Racet och dess resultat finns kvar.", + "series.no_participants": "Välj minst en förare först (klicka på antalet förare).", + "series.heats_made": "{heats} heat per omgång", + "series.status_not_started": "Ej startad", + "series.status_qual_ready": "Kval skapat", + "series.status_qual_running": "Kval pågår", + "series.status_qual_done": "Kval klart", + "series.status_finals": "Finaler", + "series.status_done": "Klar", + "series.add_existing": "Lägg till ett befintligt race", + "series.add_existing_pick": "Välj race ...", "series.no_date": "utan datum", "series.delete": "Ta bort serien", "series.classes_none": "Inga", @@ -749,7 +860,7 @@ const TRANSLATIONS = { "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.events_hint": "Skapa alla deltävlingar på en gång (ett race per datum och klass). Sedan sköts varje deltävling härifrån: välj förare, ändra antal kval och finaler, skapa kval - heaten delas in efter hur många som är med - och sedan finaler.", "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.", @@ -2453,6 +2564,38 @@ const TRANSLATIONS = { "series.step3": "3 · Rounds ({count})", "series.step4": "4 · Standings", "series.classes_all": "All", + "series.round_participants": "Drivers: {count} of {total} in the class", + "series.copy_previous": "Copy from previous", + "series.no_previous_round": "No earlier round in the same class.", + "series.no_class_drivers": "No drivers in the class yet - add one below.", + "series.new_driver_name": "New driver - name", + "series.add_driver": "Add", + "series.no_rounds": "No rounds yet - create them above.", + "series.col_date": "Date", + "series.col_race": "Race", + "series.col_drivers": "Drivers", + "series.col_qual": "Qualifying rounds", + "series.col_qual_short": "Qualifying", + "series.col_qual_hint": "Number of qualifying rounds. The number of heats per round follows the number of drivers.", + "series.col_finals": "Finals", + "series.col_per_heat": "Max cars/heat", + "series.col_status": "Status", + "series.base_format": "Other format (times, start mode)", + "series.make_qualifying": "Create qualifying", + "series.make_finals": "Create finals", + "series.open_race": "Open", + "series.remove_round": "Remove from the series (the race is kept)", + "series.remove_round_confirm": "Remove the round from the series? The race and its results are kept.", + "series.no_participants": "Pick at least one driver first (click the number of drivers).", + "series.heats_made": "{heats} heats per round", + "series.status_not_started": "Not started", + "series.status_qual_ready": "Qualifying created", + "series.status_qual_running": "Qualifying running", + "series.status_qual_done": "Qualifying done", + "series.status_finals": "Finals", + "series.status_done": "Done", + "series.add_existing": "Add an existing race", + "series.add_existing_pick": "Pick a race ...", "series.no_date": "no date", "series.delete": "Delete series", "series.classes_none": "None", @@ -2487,7 +2630,7 @@ const TRANSLATIONS = { "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.events_hint": "Create all rounds in one go (one race per date and class). Then run each round from here: pick drivers, change the number of qualifying rounds and finals, create qualifying - heats are split by how many drivers take part - and then finals.", "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.", diff --git a/src/event_race_logic.js b/src/event_race_logic.js index c9872c7..6d34417 100644 --- a/src/event_race_logic.js +++ b/src/event_race_logic.js @@ -622,6 +622,25 @@ export function findEventTeamForPassing(event, driverId, carId, { getEventTeams +// Qualifying heats as even as possible: 9 drivers with max 8 per heat -> 5 + 4, never 8 + 1 +// (someone driving alone). The number of heats follows the number of participants. Order is +// kept; earlier heats get the extra driver. +export function splitIntoBalancedGroups(items, maxSize) { + const list = Array.isArray(items) ? items : []; + if (!list.length) return []; + const groupCount = Math.ceil(list.length / Math.max(1, maxSize)); + const base = Math.floor(list.length / groupCount); + const extra = list.length % groupCount; + const groups = []; + let index = 0; + for (let group = 0; group < groupCount; group += 1) { + const size = base + (group < extra ? 1 : 0); + groups.push(list.slice(index, index + size)); + index += size; + } + return groups; +} + export function generateQualifyingForRace(event, { buildPracticeStandings, getEventDrivers, state, normalizeStartMode, normalizeSession, uid, chunkArray, clearGeneratedQualifying }) { const sourceRows = buildPracticeStandings(event); const fallbackRows = @@ -648,7 +667,7 @@ export function generateQualifyingForRace(event, { buildPracticeStandings, getEv ? String(event.raceConfig.qualSeedMethod).toLowerCase() : "best_sum"; const followUpSec = Math.max(0, Number(event.raceConfig?.followUpSec || 0) || 0); - const heats = chunkArray(fallbackRows, carsPerHeat); + const heats = splitIntoBalancedGroups(fallbackRows, carsPerHeat); let created = 0; heats.forEach((heatRows, heatIndex) => { @@ -718,7 +737,8 @@ export function reseedUpcomingQualifying(event, { buildQualifyingStandings, buil } const carsPerHeat = Math.max(2, Number(event.raceConfig?.carsPerHeat || 8) || 8); - const heats = chunkArray(sourceRows, carsPerHeat); + // Same even split as generateQualifyingForRace, so reseeded heats line up with the generated ones. + const heats = splitIntoBalancedGroups(sourceRows, carsPerHeat); const sessionsByRound = new Map(); getSessionsForEvent(event.id) .filter((session) => session.type === "qualification" && session.generated && session.status === "ready") diff --git a/src/series_logic.js b/src/series_logic.js index 53bf4d5..71a4096 100644 --- a/src/series_logic.js +++ b/src/series_logic.js @@ -149,3 +149,16 @@ export function buildSeriesStandings(series, { events = [], dayResults = new Map }; }); } + +// Where a round (one race) stands, from its sessions: not started -> qualifying created -> +// qualifying running -> qualifying done -> finals -> done. +export function getSeriesRoundStatus(sessions = []) { + const quals = sessions.filter((session) => session.type === "qualification"); + const finals = sessions.filter((session) => session.type === "final"); + const finished = (list) => list.length > 0 && list.every((session) => session.status === "finished"); + const started = (list) => list.some((session) => ["running", "paused", "finished"].includes(session.status)); + if (finals.length) return finished(finals) ? "done" : "finals"; + if (!quals.length) return "not_started"; + if (finished(quals)) return "qual_done"; + return started(quals) ? "qual_running" : "qual_ready"; +} diff --git a/src/series_view.js b/src/series_view.js index 28517f0..f8f4846 100644 --- a/src/series_view.js +++ b/src/series_view.js @@ -62,19 +62,65 @@ function renderSeriesForm(series, { t, escapeHtml, state }) { `; } -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(getClassName(a.classId)).localeCompare(String(getClassName(b.classId)), undefined, { numeric: true }) || String(a.name).localeCompare(String(b.name))); +let expandedRoundId = ""; + +function renderRoundParticipants(round, { t, escapeHtml }) { return ` -
- ${candidates.length ? candidates.map((event) => ` - `).join("") - : `

${t("series.no_events")}

`} +
+
${t("series.round_participants", { count: round.participantIds.length, total: round.classDrivers.length })} + + + + +
+ ${round.classDrivers.length ? `
${round.classDrivers.map((driver) => ` + `).join("")}
` + : `

${t("series.no_class_drivers")}

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

${t("series.no_rounds")}

`; + const number = (round, name, value, min) => ``; + return ` +
+ + ${rounds.map((round) => { + const config = round.event.raceConfig || {}; + const action = round.status === "not_started" + ? `` + : round.status === "qual_done" ? `` : ""; + return ` + + + + + + + + + + + ${expandedRoundId === round.event.id ? `` : ""}`; + }).join("")} +
${t("series.col_date")}${t("series.col_race")}${t("series.col_drivers")}${t("series.col_qual_short")}${t("series.col_finals")}${t("series.col_per_heat")}${t("series.col_status")}
${escapeHtml(round.event.date || t("series.no_date"))}${escapeHtml(round.event.name)}
${escapeHtml(round.className)}
${number(round, "qualifyingRounds", config.qualifyingRounds || 1, 1)}${number(round, "finalLegs", config.finalLegs || 1, 1)}${number(round, "carsPerHeat", config.carsPerHeat || 8, 2)}${t(`series.status_${round.status}`)}${round.qualHeatCount ? `
${t("series.heats_made", { heats: round.qualHeatCount })}` : ""}
${action}
${renderRoundParticipants(round, { t, escapeHtml })}
`; +} + +function renderAddExistingRace(series, rounds, { t, escapeHtml, state, getClassName }) { + const candidates = state.events + .filter((event) => event.mode === "race" && !event.raceConfig?.practiceOnly && !series.eventIds.includes(event.id) && (!series.classIds.length || series.classIds.includes(event.classId))) + .sort((a, b) => String(a.date || "").localeCompare(String(b.date || ""))); + if (!candidates.length) return ""; + return ` + `; +} + function renderStandingsTable(table, { t, escapeHtml }) { const head = table.events.map((event, index) => `${index + 1}`).join(""); const cells = (row) => table.events.map((event) => { @@ -95,11 +141,15 @@ function renderStandingsTable(table, { t, escapeHtml }) { } export function renderSeriesView(deps) { - const { state, dom, t, escapeHtml, saveState, renderView, uid, getClassName, getSeriesStandings, createSeriesEvents, getRaceFormatPresets, exportSeriesPdf, printSeries } = deps; + const { + state, dom, t, escapeHtml, saveState, renderView, uid, getClassName, getSeriesStandings, createSeriesEvents, getRaceFormatPresets, exportSeriesPdf, printSeries, + getSeriesRounds, setSeriesRoundFormat, setSeriesRoundParticipants, copySeriesRoundParticipants, addDriverToSeriesRound, generateSeriesRoundQualifying, generateSeriesRoundFinals, openSeriesRound, + } = deps; const list = state.series || []; if (!list.some((item) => item.id === selectedSeriesId)) selectedSeriesId = list[0]?.id || ""; const series = list.find((item) => item.id === selectedSeriesId) || null; const tables = series ? getSeriesStandings(series) : []; + const rounds = series ? getSeriesRounds(series) : []; dom.view.innerHTML = `
@@ -114,13 +164,17 @@ export function renderSeriesView(deps) {

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

`} +
${renderRoundsTable(rounds, { t, escapeHtml })}
+ ${renderAddExistingRace(series, rounds, { t, escapeHtml, state, getClassName })}
@@ -185,20 +239,60 @@ export function renderSeriesView(deps) { 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")); + createSeriesEvents(series, dates, String(data.get("presetId") || "custom"), { + qualifyingRounds: data.get("qualifyingRounds"), finalLegs: data.get("finalLegs"), carsPerHeat: data.get("carsPerHeat"), + }); renderView(); }); + dom.view.querySelector("[data-series-add-existing]")?.addEventListener("change", (event) => { + if (!event.target.value) return; + series.eventIds = [...new Set([...series.eventIds, event.target.value])]; + saveState(); + renderView(); + }); + dom.view.querySelectorAll("tr[data-round]").forEach((row) => { + const eventId = row.dataset.round; + row.querySelectorAll(".series-round-number").forEach((input) => input.addEventListener("change", () => { + setSeriesRoundFormat(eventId, { [input.name]: input.value }); + renderView(); + })); + row.querySelectorAll("[data-round-action]").forEach((button) => button.addEventListener("click", () => { + const action = button.dataset.roundAction; + if (action === "toggle") expandedRoundId = expandedRoundId === eventId ? "" : eventId; + if (action === "open") { openSeriesRound(eventId); return; } + if (action === "qualifying" && !generateSeriesRoundQualifying(eventId)) window.alert(t("series.no_participants")); + if (action === "finals") generateSeriesRoundFinals(eventId); + if (action === "remove") { + if (!window.confirm(t("series.remove_round_confirm"))) return; + series.eventIds = series.eventIds.filter((id) => id !== eventId); + saveState(); + } + renderView(); + })); + }); + const detail = dom.view.querySelector("[data-round-participants]"); + if (detail) { + const eventId = detail.dataset.roundParticipants; + const ticked = () => [...detail.querySelectorAll("[data-round-driver]:checked")].map((box) => box.dataset.roundDriver); + detail.querySelectorAll("[data-round-driver]").forEach((box) => box.addEventListener("change", () => { setSeriesRoundParticipants(eventId, ticked()); renderView(); })); + detail.querySelectorAll("[data-round-action]").forEach((button) => button.addEventListener("click", () => { + const action = button.dataset.roundAction; + if (action === "all") setSeriesRoundParticipants(eventId, [...detail.querySelectorAll("[data-round-driver]")].map((box) => box.dataset.roundDriver)); + if (action === "none") setSeriesRoundParticipants(eventId, []); + if (action === "copy" && !copySeriesRoundParticipants(series, eventId)) window.alert(t("series.no_previous_round")); + renderView(); + })); + detail.querySelector("[data-round-add-driver]")?.addEventListener("submit", (event) => { + event.preventDefault(); + const data = new FormData(event.currentTarget); + if (addDriverToSeriesRound(series, eventId, { name: data.get("name"), transponder: data.get("transponder") })) renderView(); + }); + } document.getElementById("seriesPrint")?.addEventListener("click", () => printSeries(series, tables)); document.getElementById("seriesPdf")?.addEventListener("click", () => exportSeriesPdf(series, tables)); } diff --git a/src/styles.css b/src/styles.css index dbe3dfc..950ee8f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -4602,3 +4602,22 @@ tr.is-removed td.lap-actions { /* Training stats: race laps (shown under "Races" / "All") carry their origin */ .training-race-origin { color: var(--muted); font-size: 0.72rem; } + +/* Series hub: rounds table */ +.series-create { display: grid; grid-template-columns: minmax(200px, 2fr) repeat(3, minmax(90px, 1fr)) minmax(160px, 2fr) auto; gap: 12px; align-items: end; } +.series-create label { display: grid; gap: 4px; } +.series-create textarea { font-family: inherit; } +.series-rounds-table { width: 100%; border-collapse: collapse; } +.series-rounds-table th, .series-rounds-table td { padding: 8px 10px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--line); } +.series-rounds-table th { font-size: 0.78rem; color: var(--muted); white-space: nowrap; } +.series-round-actions { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; } +.series-round-number { width: 64px; } +.series-rounds-table tr.is-expanded td { border-bottom-color: transparent; } +.series-round-detail td { background: color-mix(in srgb, var(--accent) 4%, transparent); } +.series-round-participants { display: grid; gap: 10px; padding: 6px 2px; } +.series-add-driver { display: flex; gap: 8px; flex-wrap: wrap; } +.series-add-driver input { max-width: 220px; } +.series-add-existing { display: grid; gap: 4px; margin-top: 14px; max-width: 520px; } +.series-status-done { border-color: var(--success, #2e9d5b); } +.series-status-qual_running, .series-status-finals { border-color: var(--accent); } +@media (max-width: 900px) { .series-create { grid-template-columns: 1fr 1fr; } } diff --git a/tests/browser_series.js b/tests/browser_series.js index 06dbabb..8aaeecc 100644 --- a/tests/browser_series.js +++ b/tests/browser_series.js @@ -53,13 +53,49 @@ export async function runSeriesSmoke({ evaluate, navigate, savedState, waitFor, assert.equal(saved.scoring, "club"); 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 evaluate(`(() => { const form = document.getElementById("seriesCreateEvents"); form.elements.dates.value = "2026-11-15\\n2026-11-01"; form.elements.qualifyingRounds.value = "2"; form.elements.finalLegs.value = "1"; form.elements.carsPerHeat.value = "2"; 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"]]); + assert.deepEqual(rounds.map((event) => [event.raceConfig.qualifyingRounds, event.raceConfig.finalLegs, event.raceConfig.carsPerHeat]), [[2, 1, 2], [2, 1, 2]], "the chosen format is applied to every new round"); console.log("PASS a series is created with club, class and count rule, and its rounds are created from dates"); + // The series is the hub for each round: drivers, a late entry, copy to the next round, + // format per round and qualifying - heats split by how many take part. + const roundOne = rounds.find((event) => event.date === "2026-11-01"); + const roundTwo = rounds.find((event) => event.date === "2026-11-15"); + const inRound = (eventId, js) => evaluate(`(() => { const row = document.querySelector('tr[data-round="${eventId}"]'); ${js} })()`); + await inRound(roundOne.id, `row.querySelector('[data-round-action="toggle"]').click();`); + await waitFor(async () => Boolean(await evaluate(`document.querySelector('[data-round-participants="${roundOne.id}"]')`)), "round 1 drivers open"); + await evaluate(`(() => { const box = document.querySelector('[data-round-driver="sr_bo"]'); box.checked = false; box.dispatchEvent(new Event("change")); })()`); + await waitFor(async () => { const event = (await savedState()).events.find((item) => item.id === roundOne.id); return event.raceConfig.participantsConfigured && !event.raceConfig.driverIds.includes("sr_bo"); }, "Bo unticked for round 1"); + await evaluate(`(() => { const form = document.querySelector('[data-round-add-driver]'); form.elements.name.value = "SR Cia"; form.elements.transponder.value = "8660004"; form.requestSubmit(); })()`); + await waitFor(async () => (await savedState()).drivers.some((driver) => driver.name === "SR Cia"), "late entry registered"); + const cia = (await savedState()).drivers.find((driver) => driver.name === "SR Cia"); + assert.deepEqual([cia.classId, cia.club, cia.transponder], ["sr_2wd", "Östersunds RC", "8660004"], "in the round's class and the series' club"); + const roundOneIds = (await savedState()).events.find((item) => item.id === roundOne.id).raceConfig.driverIds; + assert.ok(roundOneIds.includes(cia.id) && !roundOneIds.includes("sr_bo"), "the late entry is ticked for this round"); + + await inRound(roundTwo.id, `row.querySelector('[data-round-action="toggle"]').click();`); + await waitFor(async () => Boolean(await evaluate(`document.querySelector('[data-round-participants="${roundTwo.id}"]')`)), "round 2 drivers open"); + await evaluate(`document.querySelector('[data-round-participants="${roundTwo.id}"] [data-round-action="copy"]').click()`); + await waitFor(async () => JSON.stringify([...(await savedState()).events.find((item) => item.id === roundTwo.id).raceConfig.driverIds].sort()) === JSON.stringify([...roundOneIds].sort()), "round 2 copied from round 1"); + + // Round 1 gets three qualifying rounds (round 2 keeps two). Three drivers with max two per + // heat give two heats - of two and one - in each round. + await inRound(roundOne.id, `const input = row.querySelector('input[name="qualifyingRounds"]'); input.value = "3"; input.dispatchEvent(new Event("change"));`); + await waitFor(async () => (await savedState()).events.find((item) => item.id === roundOne.id).raceConfig.qualifyingRounds === 3, "format changed for round 1 only"); + assert.equal((await savedState()).events.find((item) => item.id === roundTwo.id).raceConfig.qualifyingRounds, 2, "round 2 keeps its own format"); + await inRound(roundOne.id, `row.querySelector('[data-round-action="qualifying"]').click();`); + await waitFor(async () => (await savedState()).sessions.filter((session) => session.eventId === roundOne.id && session.type === "qualification").length === 6, "3 rounds x 2 heats created"); + const quals = (await savedState()).sessions.filter((session) => session.eventId === roundOne.id && session.type === "qualification"); + assert.deepEqual([...new Set(quals.map((session) => session.driverIds.length))].sort(), [1, 2], "3 drivers, max 2 per heat: heats of 2 and 1"); + await waitFor(async () => (await inRound(roundOne.id, `return row.querySelector('.pill').textContent.trim() + "|" + row.querySelector('input[name="qualifyingRounds"]').disabled;`)) === "Qualifying created|true", "status shown and format locked"); + console.log("PASS each round is run from the series: drivers, late entry, copy from previous, own format, qualifying split by the field"); + // The rest of this scenario seeds a finished final for round 1 - drop these unrun heats first. + await patchState({ sessions: (await savedState()).sessions.filter((session) => !(session.eventId === roundOne.id && session.type === "qualification")) }); + // 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"); @@ -106,8 +142,8 @@ export async function runSeriesSmoke({ evaluate, navigate, savedState, waitFor, 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_")), + sessions: current.sessions.filter((session) => session.id !== "sr_final" && !seriesEventIds.has(session.eventId)), + drivers: current.drivers.filter((driver) => !driver.id.startsWith("sr_") && !String(driver.name).startsWith("SR ")), classes: current.classes.filter((item) => !item.id.startsWith("sr_")), }); } diff --git a/tests/race_timing_logic.test.js b/tests/race_timing_logic.test.js index 1289dbd..1b90384 100644 --- a/tests/race_timing_logic.test.js +++ b/tests/race_timing_logic.test.js @@ -352,7 +352,9 @@ test("qualifying generation splits seeded drivers into every configured round", assert.equal(created, 6); assert.deepEqual(state.sessions.map((session) => session.name), ["Q1 H1", "Q2 H1", "Q1 H2", "Q2 H2", "Q1 H3", "Q2 H3"]); assert.deepEqual(state.sessions[0].driverIds, ["driver_1", "driver_2", "driver_3"]); - assert.deepEqual(state.sessions[4].driverIds, ["driver_7"]); + // Seven drivers, max three per heat: 3 + 2 + 2 (never someone driving alone). + assert.deepEqual(state.sessions[2].driverIds, ["driver_4", "driver_5"]); + assert.deepEqual(state.sessions[4].driverIds, ["driver_6", "driver_7"]); assert.equal(state.sessions[0].durationMin, 7); assert.equal(state.sessions[0].seedMethod, "average"); assert.equal(state.sessions[0].followUpSec, 12); diff --git a/tests/series_logic.test.js b/tests/series_logic.test.js index 6326614..e2313da 100644 --- a/tests/series_logic.test.js +++ b/tests/series_logic.test.js @@ -105,3 +105,25 @@ test("each chosen class gets its own table; events of other classes are ignored" }); assert.deepEqual(tables.map((table) => [table.className, table.rows.map((entry) => entry.driverId).join(",")]), [["2WD", "a"], ["4WD", "a,b"]]); }); + +test("round status follows the race's sessions", async () => { + const { getSeriesRoundStatus } = await import("../src/series_logic.js"); + const q = (status) => ({ type: "qualification", status }); + const f = (status) => ({ type: "final", status }); + assert.equal(getSeriesRoundStatus([]), "not_started"); + assert.equal(getSeriesRoundStatus([q("ready"), q("ready")]), "qual_ready"); + assert.equal(getSeriesRoundStatus([q("finished"), q("ready")]), "qual_running"); + assert.equal(getSeriesRoundStatus([q("finished"), q("finished")]), "qual_done"); + assert.equal(getSeriesRoundStatus([q("finished"), f("ready")]), "finals"); + assert.equal(getSeriesRoundStatus([q("finished"), f("finished")]), "done"); +}); + +test("qualifying heats are split as evenly as possible - the heat count follows the field", async () => { + const { splitIntoBalancedGroups } = await import("../src/event_race_logic.js"); + const sizes = (n, max) => splitIntoBalancedGroups(Array.from({ length: n }, (_, i) => i), max).map((group) => group.length); + assert.deepEqual(sizes(9, 8), [5, 4], "never 8 + 1"); + assert.deepEqual(sizes(6, 6), [6]); + assert.deepEqual(sizes(13, 6), [5, 4, 4]); + assert.deepEqual(sizes(0, 6), []); + assert.deepEqual(splitIntoBalancedGroups(["a", "b", "c", "d", "e"], 3), [["a", "b", "c"], ["d", "e"]], "order is kept"); +}); diff --git a/tests/track_layouts_logic.test.js b/tests/track_layouts_logic.test.js index a8f8973..9c98574 100644 --- a/tests/track_layouts_logic.test.js +++ b/tests/track_layouts_logic.test.js @@ -145,7 +145,8 @@ test("practice uses its layout's min/max lap time where set; races always keep t assert.deepEqual(getSessionLapWindow(practice("layout_3"), { state }), { minLapMs: 15000, maxLapMs: 60000 }, "only min set -> the event's max stays"); assert.deepEqual(getSessionLapWindow({ eventId: "event_1", type: "heat", trackLayoutId: "layout_2" }, { state }), { minLapMs: 12000, maxLapMs: 60000 }, "a heat ignores the layout"); assert.deepEqual(getLayoutLapWindowOverride(state.trackLayouts, { type: "free_practice", trackLayoutId: "layout_2" }), { minLapMs: 8000, maxLapMs: 40000 }); - assert.deepEqual(normalizeTrackLayout({ id: "x", minLapMs: -5, maxLapMs: "40000" }), { ...normalizeTrackLayout({ id: "x" }), minLapMs: 0, maxLapMs: 40000, startedAt: normalizeTrackLayout({ id: "x", minLapMs: -5, maxLapMs: "40000" }).startedAt }); + // Fixed startedAt: without one each call takes Date.now(), which made this flaky. + assert.deepEqual(normalizeTrackLayout({ id: "x", startedAt: 1000, minLapMs: -5, maxLapMs: "40000" }), { id: "x", label: "", startedAt: 1000, minLapMs: 0, maxLapMs: 40000 }); }); test("Race Day preflight shows the layout's lap window for practice", () => {