Add data export/import, CSV exports and race packages

This commit is contained in:
larssand
2026-03-20 19:04:28 +01:00
parent 1637f63dad
commit 94df6ece34
3 changed files with 239 additions and 19 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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) {
<button id="clearGeneratedFinals" class="btn btn-danger" type="button">${t("events.clear_generated_finals")}</button>
<button id="applyBumps" class="btn" type="button">${t("events.apply_bumps")}</button>
</div>
<div class="actions mt-16">
<button id="exportRacePackage" class="btn" type="button">${t("events.export_race_package")}</button>
<input id="importRacePackage" type="file" accept="application/json" aria-label="${t("events.import_race_package")}" />
</div>
<p class="hint">${t("events.race_package_hint")}</p>
</div>
</section>
@@ -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() {
<div class="guide-section-grid mt-16">
${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"])}
</div>
<div class="guide-section-grid mt-16">
@@ -6044,6 +6237,7 @@ function renderGuide() {
<li>${t("guide.sqlite_2")}</li>
<li>${t("guide.sqlite_3")}</li>
<li>${t("guide.sqlite_4")}</li>
<li>${t("guide.sqlite_5")}</li>
</ul>
<p><a href="https://www.ammconverter.eu/docs/intro/quick-start/" target="_blank" rel="noreferrer">${t("guide.ammc_ref")}</a></p>
</div>
@@ -6794,6 +6988,13 @@ function renderSettings() {
<input id="importDirectoryData" type="file" accept="application/json" />
</div>
</div>
<div class="mt-16">
<p class="hint">${t("settings.storage_note_csv")}</p>
<div class="actions">
<button id="exportDriversCsv" class="btn" type="button">${t("settings.export_drivers_csv")}</button>
<button id="exportCarsCsv" class="btn" type="button">${t("settings.export_cars_csv")}</button>
</div>
</div>
${settingsStorageNotice ? `<p class="${settingsStorageNoticeIsError ? "error" : "hint"}">${escapeHtml(settingsStorageNotice)}</p>` : ""}
</div>
</section>
@@ -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);