diff --git a/README.md b/README.md index 9d0eb83..eb830d3 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ JMK RB RaceController is an RC timing and race-control system with support for s - Storage/import tools: - full race-data export/import for backup or moving to another machine - directory export/import for only classes, drivers and cars + - CSV export for drivers and cars when you want simple spreadsheet lists + - single race-package export/import from `Race Setup -> Manage -> Race Actions` - Race Setup includes: - grouped `Participants` and `Teams` sections in `Manage` - a four-step `Create Race Wizard` for new races @@ -156,6 +158,16 @@ JMK RB RaceController is an RC timing and race-control system with support for s - `Generation` is kept separate so actions like qualifying generation, reseeding, finals generation, and bump-up do not clutter the format form. - `Live / results` is where grid, standings, print, PDF, and finals matrix are used once the structure is ready. +## Export / Import +- `Settings -> Storage` now supports: + - full JSON export/import for the complete competition database + - directory JSON export/import for only classes, drivers, and cars + - CSV export for drivers and cars +- `Race Setup -> Manage -> Race Actions` now supports single race packages: + - export one race with its sessions, results, and referenced classes/drivers/cars + - import that package into another installation as a new race entry +- JSON export files now include export metadata and a schema version field so future imports can be handled more safely. + ## Race features ### Follow-up time diff --git a/README.sv.md b/README.sv.md index ebcf08f..0de5178 100644 --- a/README.sv.md +++ b/README.sv.md @@ -35,6 +35,8 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he - Lagring/import: - full export/import av race-data för backup eller flytt till annan maskin - register-export/import för bara klasser, förare och bilar + - CSV-export för förare och bilar när du vill ha enkla listor i kalkylark + - export/import av ett enskilt racepaket via `Race Setup -> Hantera -> Race actions` - `Race Setup` innehåller nu även: - grupperade sektioner för `Deltagare` och `Lag` i `Hantera` - en fyrstegs `Create Race Wizard` för nya race @@ -145,6 +147,16 @@ RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika he - `Generering` ligger separat så knappar för kval, reseeding, finaler och bump-up inte blandas ihop med raceformatet. - `Live / resultat` är där grid, standings, print, PDF och finalmatris används när upplägget väl är klart. +## Export / Import +- `Inställningar -> Lagring` stöder nu: + - full JSON-export/import för hela tävlingsdatabasen + - register-JSON för bara klasser, förare och bilar + - CSV-export för förare och bilar +- `Race Setup -> Hantera -> Race actions` stöder nu enskilda racepaket: + - exportera ett race med dess sessioner, resultat och refererade klasser/förare/bilar + - importera paketet på en annan installation som ett nytt race +- JSON-exporter innehåller nu även exportmetadata och ett schema-/versionsfält för säkrare import framåt. + ## Nya racefunktioner ### Follow-up time diff --git a/src/app.js b/src/app.js index bdf855a..2b24c95 100644 --- a/src/app.js +++ b/src/app.js @@ -17,6 +17,7 @@ const STORAGE_KEY = "rc_timing_control_v1"; const DEFAULT_LANGUAGE = "sv"; const DEFAULT_THEME = "dark"; const AVAILABLE_THEMES = ["dark", "nord", "light"]; +const EXPORT_SCHEMA_VERSION = 1; const TRANSLATIONS = { sv: { @@ -203,6 +204,9 @@ const TRANSLATIONS = { "events.race_summary_generation": "Generering", "events.race_actions_title": "Race actions", "events.race_actions_hint": "Generering och reseeding ligger separat från formatinställningarna så setupen blir lättare att läsa.", + "events.export_race_package": "Exportera racepaket", + "events.import_race_package": "Importera racepaket", + "events.race_package_hint": "Exportera eller importera ett enskilt race med dess sessioner, resultat och refererade registerdata.", "events.context_standard_title": "Klubbrace-läge", "events.context_standard_hint": "Börja med preset och Grundläge. När deltagare och tider ser rätt ut använder du Race actions för kval, reseeding och finaler.", "events.context_endurance_title": "Endurance-läge", @@ -234,6 +238,7 @@ const TRANSLATIONS = { "guide.race_wizard_4": "4. Steg 3 väljer vilka sessioner som ska skapas direkt: practice, kval och/eller team race beroende på preset.", "guide.race_wizard_5": "5. Wizarden skapar de första sessionerna automatiskt. Finaler skapas senare från Race actions när kval eller practice är klara.", "guide.race_wizard_6": "6. Efter skapandet går du vidare till Hantera för raceformat, generering, grid, standings och utskrift.", + "guide.race_wizard_7": "7. I Hantera -> Race actions kan du exportera eller importera ett enskilt racepaket om du vill flytta just det racet till en annan installation.", "guide.manage_steps_title": "Hantera race i fyra steg", "guide.manage_steps_1": "1. Setup: välj racedeltagare och bygg eventuella lag för team race eller endurance.", "guide.manage_steps_2": "2. Format: justera Race Format i Grundläge eller Avancerat. Practice/kval, finaler och validering ligger i egna block.", @@ -555,8 +560,11 @@ const TRANSLATIONS = { "settings.import_all_data": "Importera all race-data", "settings.export_directory": "Exportera förare/klasser/bilar", "settings.import_directory": "Importera förare/klasser/bilar", + "settings.export_drivers_csv": "Exportera förare CSV", + "settings.export_cars_csv": "Exportera bilar CSV", "settings.storage_note_full": "Fullt datapaket innehåller klasser, förare, bilar, race/event, sessioner och resultat.", "settings.storage_note_directory": "Registerpaket innehåller bara klasser, förare och bilar och mergeas in i befintlig data.", + "settings.storage_note_csv": "CSV-exporten är snabbast för registerlistor och enklare att öppna i Excel eller LibreOffice.", "settings.import_full_success": "Race-data importerad och ersatte nuvarande tävlingsdata.", "settings.import_directory_success": "Klasser, förare och bilar importerades in i befintlig data.", "settings.import_failed": "Import misslyckades: {msg}", @@ -754,6 +762,7 @@ const TRANSLATIONS = { "guide.sqlite_2": "API: /api/state och /api/passings", "guide.sqlite_3": "I Inställningar -> Lagring kan du exportera hela racepaket för backup eller flytt till annan server/laptop.", "guide.sqlite_4": "Du kan också exportera eller importera bara klasser, förare och bilar om du vill börja om med race men behålla registret.", + "guide.sqlite_5": "Exportera förare och bilar som CSV om du snabbt vill öppna registret i Excel eller skriva ut en enkel lista.", "guide.ammc_ref": "AMMC referens: https://www.ammconverter.eu/docs/intro/quick-start/", "error.backend_offline": "Backend offline: {msg}", "error.sync_failed": "Synk misslyckades: {msg}", @@ -952,6 +961,9 @@ const TRANSLATIONS = { "events.race_summary_generation": "Generation", "events.race_actions_title": "Race actions", "events.race_actions_hint": "Generation and reseeding live separately from the format fields so setup is easier to read.", + "events.export_race_package": "Export race package", + "events.import_race_package": "Import race package", + "events.race_package_hint": "Export or import a single race with its sessions, results, and referenced directory data.", "events.context_standard_title": "Club race mode", "events.context_standard_hint": "Start with a preset and Basic mode. When participants and timings look right, use Race Actions for qualifying, reseeding and finals.", "events.context_endurance_title": "Endurance mode", @@ -983,6 +995,7 @@ const TRANSLATIONS = { "guide.race_wizard_4": "4. Step 3 chooses which sessions should be created immediately: practice, qualifying and/or team race depending on the preset.", "guide.race_wizard_5": "5. The wizard creates the initial sessions automatically. Finals are generated later from Race Actions after qualifying or practice is complete.", "guide.race_wizard_6": "6. After creation, continue in Manage for race format, generation, grid, standings and print/export.", + "guide.race_wizard_7": "7. In Manage -> Race Actions you can export or import a single race package when you want to move just that race to another installation.", "guide.manage_steps_title": "Manage Race In Four Steps", "guide.manage_steps_1": "1. Setup: choose race participants and build any teams for team race or endurance.", "guide.manage_steps_2": "2. Format: adjust Race Format in Basic or Advanced mode. Practice/qualifying, finals and validation are split into separate blocks.", @@ -1304,8 +1317,11 @@ const TRANSLATIONS = { "settings.import_all_data": "Import all race data", "settings.export_directory": "Export drivers/classes/cars", "settings.import_directory": "Import drivers/classes/cars", + "settings.export_drivers_csv": "Export drivers CSV", + "settings.export_cars_csv": "Export cars CSV", "settings.storage_note_full": "Full data package contains classes, drivers, cars, races/events, sessions and results.", "settings.storage_note_directory": "Directory package only contains classes, drivers and cars and merges into existing data.", + "settings.storage_note_csv": "CSV export is the quickest way to open the directory in Excel or LibreOffice.", "settings.import_full_success": "Race data imported and replaced the current competition data.", "settings.import_directory_success": "Classes, drivers and cars were imported into the existing data.", "settings.import_failed": "Import failed: {msg}", @@ -1503,6 +1519,7 @@ const TRANSLATIONS = { "guide.sqlite_2": "API endpoints: /api/state and /api/passings", "guide.sqlite_3": "In Settings -> Storage you can export a full race package for backup or moving to another server or laptop.", "guide.sqlite_4": "You can also export or import only classes, drivers and cars if you want to reset race data but keep the directory.", + "guide.sqlite_5": "Export drivers and cars as CSV if you want to open the directory quickly in Excel or print a simple list.", "guide.ammc_ref": "AMMC reference: https://www.ammconverter.eu/docs/intro/quick-start/", "error.backend_offline": "Backend offline: {msg}", "error.sync_failed": "Sync failed: {msg}", @@ -1885,24 +1902,123 @@ function normalizeImportedClass(classItem) { }; } +function buildExportMeta(exportType) { + return { + app: "JMK RB RaceController", + schemaVersion: EXPORT_SCHEMA_VERSION, + exportType, + exportedAt: new Date().toISOString(), + }; +} + +function sanitizeFilenameSegment(value) { + return String(value || "") + .trim() + .replace(/[^a-z0-9_-]+/gi, "_") + .replace(/^_+|_+$/g, "") || "export"; +} + +function downloadBlobFile(filename, blob) { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +} + +function downloadJsonFile(filename, payload) { + downloadBlobFile(filename, new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" })); +} + +function rowToCsv(row) { + return row.map((value) => `"${String(value || "").replaceAll('"', '""')}"`).join(","); +} + +function downloadCsvFile(filename, rows) { + const csv = rows.map((row) => rowToCsv(row)).join("\n"); + downloadBlobFile(filename, new Blob([csv], { type: "text/csv;charset=utf-8" })); +} + function buildDataExportPayload() { return { + ...buildExportMeta("full_data"), classes: state.classes, drivers: state.drivers, cars: state.cars, events: state.events, sessions: state.sessions, resultsBySession: state.resultsBySession, - exportedAt: new Date().toISOString(), }; } function buildDirectoryExportPayload() { return { + ...buildExportMeta("directory"), classes: state.classes, drivers: state.drivers, cars: state.cars, - exportedAt: new Date().toISOString(), + }; +} + +function buildDriversCsvRows() { + return [ + ["id", "name", "class", "brand", "transponder"], + ...state.drivers.map((driver) => [driver.id, driver.name, getClassName(driver.classId), driver.brand || "", driver.transponder || ""]), + ]; +} + +function buildCarsCsvRows() { + return [ + ["id", "name", "brand", "transponder"], + ...state.cars.map((car) => [car.id, car.name, car.brand || "", car.transponder || ""]), + ]; +} + +function collectEventPackageReferences(event, sessions) { + const driverIds = new Set(event.classId ? getDriversForClass(event.classId).map((driver) => driver.id) : []); + const carIds = new Set(); + (event?.raceConfig?.driverIds || []).forEach((driverId) => driverIds.add(driverId)); + (event?.raceConfig?.teams || []).forEach((team) => { + (team.driverIds || []).forEach((driverId) => driverIds.add(driverId)); + (team.carIds || []).forEach((carId) => carIds.add(carId)); + }); + (sessions || []).forEach((session) => { + (session.driverIds || []).forEach((driverId) => driverIds.add(driverId)); + (session.manualGridIds || []).forEach((driverId) => driverIds.add(driverId)); + (session.assignments || []).forEach((assignment) => { + if (assignment.driverId) driverIds.add(assignment.driverId); + if (assignment.carId) carIds.add(assignment.carId); + }); + }); + return { + classes: state.classes.filter((item) => item.id === event.classId), + drivers: state.drivers.filter((driver) => driverIds.has(driver.id)), + cars: state.cars.filter((car) => carIds.has(car.id)), + }; +} + +function buildRacePackagePayload(eventId) { + const event = state.events.find((item) => item.id === eventId); + if (!event) { + throw new Error("Event not found"); + } + const sessions = getSessionsForEvent(eventId); + const refs = collectEventPackageReferences(event, sessions); + const resultsBySession = {}; + sessions.forEach((session) => { + if (state.resultsBySession[session.id]) { + resultsBySession[session.id] = state.resultsBySession[session.id]; + } + }); + return { + ...buildExportMeta("race_package"), + classes: refs.classes, + drivers: refs.drivers, + cars: refs.cars, + event, + sessions, + resultsBySession, }; } @@ -1916,6 +2032,55 @@ function mergeCollectionById(currentItems, incomingItems, normalizer) { return [...map.values()]; } +function importRacePackagePayload(parsed) { + const importedEvent = parsed?.event ? normalizeEvent(parsed.event) : null; + const incomingClasses = Array.isArray(parsed?.classes) ? parsed.classes : []; + const incomingDrivers = Array.isArray(parsed?.drivers) ? parsed.drivers : []; + const incomingCars = Array.isArray(parsed?.cars) ? parsed.cars : []; + const incomingSessions = Array.isArray(parsed?.sessions) ? parsed.sessions.map((item) => normalizeSession(item)) : []; + if (!importedEvent || !incomingSessions.length) { + throw new Error("No race package found"); + } + + state.classes = mergeCollectionById(state.classes, incomingClasses, normalizeImportedClass).filter((item) => item.name); + state.drivers = mergeCollectionById(state.drivers, incomingDrivers, normalizeDriver).filter((driver) => driver.name); + state.cars = mergeCollectionById(state.cars, incomingCars, normalizeCar).filter((car) => car.name); + + const newEventId = uid("event"); + const sessionIdMap = new Map(); + const clonedEvent = normalizeEvent({ + ...importedEvent, + id: newEventId, + }); + const clonedSessions = incomingSessions.map((session) => { + const newSessionId = uid("session"); + sessionIdMap.set(session.id, newSessionId); + return normalizeSession({ + ...session, + id: newSessionId, + eventId: newEventId, + status: "ready", + startedAt: null, + endedAt: null, + finishedByTimer: false, + }); + }); + + const importedResults = parsed?.resultsBySession && typeof parsed.resultsBySession === "object" ? parsed.resultsBySession : {}; + Object.entries(importedResults).forEach(([oldSessionId, value]) => { + const newSessionId = sessionIdMap.get(oldSessionId); + if (newSessionId) { + state.resultsBySession[newSessionId] = value; + } + }); + + state.events.push(clonedEvent); + state.sessions.push(...clonedSessions); + saveState(); + renderView(); + renderEventManager(newEventId); +} + function importDataPayload(parsed, mode = "full") { const incomingClasses = Array.isArray(parsed?.classes) ? parsed.classes : []; const incomingDrivers = Array.isArray(parsed?.drivers) ? parsed.drivers : []; @@ -4492,6 +4657,11 @@ function renderEventManager(eventId) { +
+ + +
+

${t("events.race_package_hint")}

@@ -5178,6 +5348,29 @@ function renderEventManager(eventId) { alert(t(applied > 0 ? "events.bumps_applied" : "events.no_bumps_applied")); }); + document.getElementById("exportRacePackage")?.addEventListener("click", () => { + const payload = buildRacePackagePayload(eventId); + downloadJsonFile(`${sanitizeFilenameSegment(event.name)}_race_package.json`, payload); + }); + + document.getElementById("importRacePackage")?.addEventListener("change", (importEvent) => { + const input = importEvent.currentTarget; + const file = input instanceof HTMLInputElement ? input.files?.[0] : null; + if (!file) { + return; + } + const reader = new FileReader(); + reader.onload = () => { + try { + const parsed = JSON.parse(String(reader.result || "{}")); + importRacePackagePayload(parsed); + } catch (error) { + alert(t("settings.import_failed", { msg: error instanceof Error ? error.message : String(error) })); + } + }; + reader.readAsText(file); + }); + document.getElementById("printStartlists")?.addEventListener("click", () => { openPrintWindow(`${event.name} - ${t("events.start_lists")}`, buildRaceStartListsHtml(event)); }); @@ -6003,7 +6196,7 @@ function renderGuide() {
${renderGuidePanel("guide.sponsor_title", ["guide.sponsor_1", "guide.sponsor_2", "guide.sponsor_3", "guide.sponsor_4", "guide.sponsor_5", "guide.sponsor_6"])} - ${renderGuidePanel("guide.race_wizard_title", ["guide.race_wizard_1", "guide.race_wizard_2", "guide.race_wizard_3", "guide.race_wizard_4", "guide.race_wizard_5", "guide.race_wizard_6"])} + ${renderGuidePanel("guide.race_wizard_title", ["guide.race_wizard_1", "guide.race_wizard_2", "guide.race_wizard_3", "guide.race_wizard_4", "guide.race_wizard_5", "guide.race_wizard_6", "guide.race_wizard_7"])}
@@ -6044,6 +6237,7 @@ function renderGuide() {
  • ${t("guide.sqlite_2")}
  • ${t("guide.sqlite_3")}
  • ${t("guide.sqlite_4")}
  • +
  • ${t("guide.sqlite_5")}
  • ${t("guide.ammc_ref")}

    @@ -6794,6 +6988,13 @@ function renderSettings() { +
    +

    ${t("settings.storage_note_csv")}

    +
    + + +
    +
    ${settingsStorageNotice ? `

    ${escapeHtml(settingsStorageNotice)}

    ` : ""} @@ -6877,16 +7078,9 @@ function renderSettings() { document.getElementById("exportData")?.addEventListener("click", () => { const payload = buildDataExportPayload(); - const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = "rc_timing_export.json"; - link.click(); - URL.revokeObjectURL(url); + downloadJsonFile("live_rc_full_export.json", payload); }); - document.getElementById("importData")?.addEventListener("change", (event) => { const input = event.currentTarget; const file = input instanceof HTMLInputElement ? input.files?.[0] : null; @@ -6909,13 +7103,15 @@ function renderSettings() { document.getElementById("exportDirectoryData")?.addEventListener("click", () => { const payload = buildDirectoryExportPayload(); - const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = "live_rc_directory_export.json"; - link.click(); - URL.revokeObjectURL(url); + downloadJsonFile("live_rc_directory_export.json", payload); + }); + + document.getElementById("exportDriversCsv")?.addEventListener("click", () => { + downloadCsvFile("live_rc_drivers.csv", buildDriversCsvRows()); + }); + + document.getElementById("exportCarsCsv")?.addEventListener("click", () => { + downloadCsvFile("live_rc_cars.csv", buildCarsCsvRows()); }); document.getElementById("importDirectoryData")?.addEventListener("change", (event) => { @@ -6940,8 +7136,8 @@ function renderSettings() { document.getElementById("exportRacePresets")?.addEventListener("click", () => { const payload = { + ...buildExportMeta("race_presets"), racePresets: (state.settings.racePresets || []).map((preset) => normalizeStoredRacePreset(preset)), - exportedAt: new Date().toISOString(), }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" }); const url = URL.createObjectURL(blob);