full sync

This commit is contained in:
larssand
2026-03-14 09:51:35 +01:00
commit 3b0af41466
41 changed files with 102303 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
data/
*.log

11
.project Normal file
View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>Live_RC</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
</buildSpec>
<natures>
</natures>
</projectDescription>

11
AMMC/LICENCE Normal file
View File

@@ -0,0 +1,11 @@
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to
whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

25
AMMC/README.md Normal file
View File

@@ -0,0 +1,25 @@
# Amb Mylaps My Converter (AMMC) application readme
This is utility converting data from race timing devices to JSON or database. See
website [AMMC](http://www.ammconverter.eu) for
more details about AMM converter.
Content of the package:
- `passing.schema.json` - JSON schema of the passing record
- `windows64` - directory with Windows executable
- `linux_x86-64` - directory with Linux executable
- `apple_m` - directory with Apple M CPU executables
- `libammc.h` - AMMC header file for C/C++ developers
- `passing.schema.json` - Passing record JSON schema
Binary commands:
- ammc-amb(.exe) - converts data from AMB / MyLaps devices
- ammc-prochip(.exe) - converts data from Prochip devices
- ammc-vostok(.exe) - converts data from Vostok decoders
- ammc-x2(.exe) - converts data from MyLaps X2 server
- ammc-sim(.exe) - device simulator
All details on website https://ammconverter.eu or https://ammconverter.com

87977
AMMC/THIRDPARTY.toml Normal file

File diff suppressed because it is too large Load Diff

BIN
AMMC/ammc-latest.zip Normal file

Binary file not shown.

BIN
AMMC/apple_m/ammc-amb Normal file

Binary file not shown.

BIN
AMMC/apple_m/ammc-prochip Normal file

Binary file not shown.

BIN
AMMC/apple_m/ammc-sim Normal file

Binary file not shown.

BIN
AMMC/apple_m/ammc-vostok Normal file

Binary file not shown.

BIN
AMMC/apple_m/libammc.dylib Normal file

Binary file not shown.

41
AMMC/libammc.h Normal file
View File

@@ -0,0 +1,41 @@
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
char *p3_to_json(const char *msg);
/**
* # Safety
*
* convert p3 binary data to JSON for jvm based languages
*/
jstring Java_com_skoky_AmmcBridge_p3_1to_1json(JNIEnv env, JClass, JString p3_bin);
/**
* # Safety
*
* converts p3 network response to json fo rjvm languages
*/
jstring Java_com_skoky_AmmcBridge_p3_1network_1to_1json(JNIEnv env, JClass, JString p3_bin);
/**
* # Safety
*
* encodes json message to p3 hex binary
*/
jstring Java_com_skoky_AmmcBridge_encode(JNIEnv env, JClass, JString json_str);
/**
* # Safety
*
* converts P3 time to millis
*/
jstring Java_com_skoky_AmmcBridge_time_1to_1millis(JNIEnv env, JClass, JString str_time);
/**
* # Safety
*
* returns p3 lib version
*/
jstring Java_com_skoky_AmmcBridge_version(JNIEnv env, JClass);

BIN
AMMC/linux_x86-64/ammc-amb Normal file

Binary file not shown.

Binary file not shown.

BIN
AMMC/linux_x86-64/ammc-sim Executable file

Binary file not shown.

Binary file not shown.

BIN
AMMC/linux_x86-64/ammc-x2 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

167
AMMC/passing.schema.json Normal file
View File

@@ -0,0 +1,167 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Passing",
"type": "object",
"properties": {
"msg": {
"type": "string"
},
"decoder_id": {
"type": [
"string",
"null"
]
},
"controller_id": {
"type": [
"string",
"null"
]
},
"request_id": {
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 0
},
"passing_number": {
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 0
},
"transponder": {
"type": [
"integer",
"null"
],
"format": "uint32",
"minimum": 0
},
"rtc_time": {
"type": [
"string",
"null"
]
},
"strength": {
"type": [
"number",
"null"
],
"format": "double"
},
"hits": {
"type": [
"integer",
"null"
],
"format": "uint16",
"minimum": 0,
"maximum": 65535
},
"low_battery": {
"type": [
"boolean",
"null"
]
},
"resend": {
"type": [
"boolean",
"null"
]
},
"modified": {
"type": [
"boolean",
"null"
]
},
"gps_locked": {
"type": [
"boolean",
"null"
]
},
"tran_code": {
"type": [
"string",
"null"
]
},
"user_flags": {
"type": [
"integer",
"null"
],
"format": "uint32",
"minimum": 0
},
"driver_id": {
"type": [
"integer",
"null"
],
"format": "uint8",
"minimum": 0,
"maximum": 255
},
"sport": {
"type": [
"integer",
"null"
],
"format": "uint8",
"minimum": 0,
"maximum": 255
},
"voltage": {
"type": [
"number",
"null"
],
"format": "double"
},
"temperature": {
"type": [
"integer",
"null"
],
"format": "int8",
"minimum": -128,
"maximum": 127
},
"car_id": {
"type": [
"integer",
"null"
],
"format": "uint8",
"minimum": 0,
"maximum": 255
},
"loop_id": {
"type": [
"string",
"null"
]
},
"empty_fields": {
"type": [
"array",
"null"
],
"items": {
"type": "string"
}
}
},
"required": [
"msg"
]
}

Binary file not shown.

BIN
AMMC/windows64/ammc-amb.exe Normal file

Binary file not shown.

Binary file not shown.

BIN
AMMC/windows64/ammc-sim.exe Normal file

Binary file not shown.

Binary file not shown.

BIN
AMMC/windows64/ammc-x2.exe Normal file

Binary file not shown.

BIN
AMMC/windows64/ammc.dll Normal file

Binary file not shown.

182
README.md Normal file
View File

@@ -0,0 +1,182 @@
# JMK RB Live Event
RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika heat/finaler), AMMC WebSocket och lokal SQLite-lagring på Windows.
## Vad som ingår
- Event-lägen:
- `Race (driver transponders)`
- `Track Event (shared cars)`
- UI-separering:
- `Event` = sponsor-event med delade bilar/transpondrar
- `Race Setup` = riktiga race med personlig transponder per förare
- `Race Setup` innehåller nu även:
- välj exakt vilka förare som är med i racet
- practice-ranking
- kval-ranking med `poäng` eller `bästa resultat`
- inbyggd guide för hur man skapar race steg för steg
- beskrivningar direkt i alla fält under `Raceformat`
- sessionstyp `Free Practice` för löpande varvtider utan seedning
- auto-generering av kvalheat från practice-ranking eller klasslista
- reseeding av kommande kvalheat från aktuell ranking
- auto-generering av `A/B/C...` finaler från ranking
- sparad manuell grid per session via dragbar grid-editor
- auto-reseed hoppar över heat där manuell grid har låsts tills du återställer den
- tydlig `Lås/Lås upp grid` i grid-editorn per session
- final-ranking över flera leg med räknade finalheat
- valbar `bump-up` mellan finaler
- reserverade bump-platser i högre finaler
- visuell finalmatris med reserverade bump-platser
- dragbar grid-editor för positionsstart
- utskrift/export av heatsheets per kval/final
- overlay-vy för extern leaderboard-skärm
- flera overlay-lägen: leaderboard, speaker och results
- speaker-overlay med eventmarkörer och separata speaker-cues
- speaker-cues och klubbinfo/PDF-header styrs från `Settings`
- logo-upload för overlay från `Settings`
- extra speaker-cues för `session start`, `new best lap` och `top 3 change`
- valbart PDF-tema: `classic`, `minimal`, `motorsport`
- utskrift av startlistor och resultat
- servergenererad PDF-export för startlistor, heatsheets och resultat
- genererade kval/finaler ärver tid och starttyp från raceformatet
- finish-ljud som siren i stället för browser-röst
- Sessioner: `practice`, `qualification`, `heat`, `final`
- Sponsor-verktyg:
- Skapa rundor automatiskt (`qualification`, `heat`, `final`)
- Auto-assign förare -> bil per session
- Live timing från AMMC WebSocket (`msg: "PASSING"`)
- Hanterad AMMC från webbgränssnittet:
- backend kan starta/stoppa lokal `ammc-amb` på Windows, Linux och macOS
- läser bundlade binärer från `AMMC/windows64`, `AMMC/linux_x86-64`, `AMMC/apple_m`
- Redigering i UI:
- Klasser, eventnamn/datum, förare och bilar kan redigeras direkt
- Live race-kontroll:
- Nedräkning under pågående session
- Auto-finish vid tidslut med status `Race is finished`
- Leaderboard-sortering: varv först, därefter närmast måltid för sessionen (t.ex. 5 min = 600s)
- Browserljud för passing (`blipp` eller tala förarnamn) och målgång
- Sessioninställningar för `Mass start`, `Position start`, `Staggered`
- `Timing` visar grid/startordning för aktiv `Position start`-session
- leaderboard visar både `gap till ledaren`, `gap till bilen framför` och `eget delta` mot förra varvet
- Practice/Kval kan seedas på bästa `2` eller `3` varv i sessionsinställningar
- Persistens:
- Frontend state i browser (`localStorage`)
- Samma state + passeringar sparas i lokal SQLite via Node-backend
- Inbyggd `Guide`-meny i appen med steg-för-steg för:
- Sponsor-event (10 personer / 4 bilar)
- Vanligt race
- AMMC + npm setup på Windows och Linux
- Språkval i UI: `SV` / `EN`
## Windows installation
Kör i PowerShell i projektmappen.
1. Installera Node.js LTS (18+).
2. Installera dependencies:
```powershell
npm install
```
3. Starta servern i bakgrunden:
```powershell
npm start
```
4. Öppna:
- `http://localhost:8081`
- eller från annan dator: `http://<server-ip>:8081`
Vanliga kommandon:
```powershell
npm start
npm stop
npm restart
npm run status
npm run start:fg
```
- `npm start` startar `live_event` i bakgrunden
- `npm stop` stoppar processen via `data/server.pid`
- `npm restart` startar om backend
- `npm run status` visar om backend kör
- `npm run start:fg` kör i foreground för felsökning
### Windows scripts (bakgrundsstart)
Det finns färdiga `.bat`-filer i mappen `windows/`:
- `windows\\start_ammc.bat` startar AMMC i bakgrunden
- `windows\\start_backend.bat` startar `npm start` i bakgrunden (logg: `logs\\backend.log`)
- `windows\\start_all.bat` startar både AMMC + backend och öppnar webbsidan
- `windows\\stop_all.bat` stoppar backend på port `8081` och `ammc-amb.exe`
SQLite-filen skapas automatiskt här:
- `data\\rc_timing.sqlite`
## Koppla mot AMMC
Det finns nu två sätt:
### A. Hanterad AMMC i webbgränssnittet
Viktigt:
- AMMC körs på samma host där `npm start` / `node server.js` körs.
- Om du öppnar sidan från en annan laptop startas ingen AMMC där.
- Fältet `AMMC binär` i `Settings` är en sökväg på backend-hosten, inte på klienten som surfar in.
1. Lägg AMMC-binärerna i projektmappen `AMMC/` (redan gjort i denna repo).
2. Öppna `Settings`.
3. Aktivera `Hanterad AMMC / Managed AMMC`.
4. Sätt `Decoder IP / host`, kontrollera port `9000`, spara.
5. Klicka `Starta AMMC / Start AMMC`.
6. Klicka `Använd serverns WS-url / Use server WS URL` så sätts klienten till t.ex. `ws://<server-ip>:9000`.
Standardbinärer:
- Linux-host: `AMMC/linux_x86-64/ammc-amb`
- Windows-host: `AMMC/windows64/ammc-amb.exe`
- macOS-host: `AMMC/apple_m/ammc-amb`
### B. Manuell start
Starta AMMC med WebSocket (exempel):
```powershell
ammc-amb.exe -w 9000 192.168.1.11
```
I appen:
1. `Settings`
2. Sätt `WebSocket URL` till t.ex. `ws://127.0.0.1:9000`
3. Sätt `Backend URL` till `http://127.0.0.1:8081`
4. Klicka `Test Backend`
5. Gå till `Timing` och klicka `Connect Decoder`
Om du kör Linux-brandvägg (UFW), öppna porten:
```bash
sudo ufw allow 8081/tcp
```
## Auto reload vid uppdatering
- Servern bevakar `index.html`, `src/app.js` och `src/styles.css`.
- När du uppdaterar filer i `live_event` och sparar, laddar klienten om sidan automatiskt.
- Om backendkoden ändras, kör `npm restart`.
## Verifiera att SQLite sparar
Hämta senaste passeringar via API:
- `http://localhost:8081/api/passings`
Hämta sparad app-state:
- `http://localhost:8081/api/state`
## Viktig regel för sponsor-event
- I **samma pågående session** måste varje aktiv bil ha unikt transponder-ID.
- Samma transponder-ID kan återanvändas i **nästa session** (Heat 1 -> Heat 2 -> Heat 3 -> Final 1 ...).
## Referens AMMC JSON
Officiell quick-start:
- https://www.ammconverter.eu/docs/intro/quick-start/
Exempelmeddelande:
```json
{
"msg": "PASSING",
"passing_number": 1,
"transponder": 232323,
"rtc_time": "2022-10-11T22:57:36.099+02:00",
"strength": 0.0,
"resend": false,
"tran_code": "ID:232323",
"loop_id": "55"
}
```

63
index.html Normal file
View File

@@ -0,0 +1,63 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JMK RB Live Event</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700&family=Orbitron:wght@500;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="./src/styles.css" />
</head>
<body>
<div class="app-shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark">RC</div>
<div>
<h1 id="brandTitle">JMK RB</h1>
<p id="brandSubtitle">Live Event</p>
</div>
</div>
<nav id="nav" class="nav"></nav>
<div class="sidebar-footer">
<p id="connectionBadge" class="badge badge-offline">Decoder Offline</p>
<small id="clock"></small>
</div>
</aside>
<main class="content">
<header class="topbar">
<div>
<h2 id="pageTitle">Dashboard</h2>
<p id="pageSubtitle">RC race timing with AMMC integration</p>
</div>
<div class="topbar-right">
<label class="lang-wrap">
<span id="languageLabel">Language</span>
<select id="languageSelect" class="lang-select">
<option value="sv">SV</option>
<option value="en">EN</option>
</select>
</label>
<p class="chip" id="activeSessionChip">No Active Session</p>
</div>
</header>
<section id="view" class="view"></section>
</main>
</div>
<template id="tableTemplate">
<table class="data-table">
<thead></thead>
<tbody></tbody>
</table>
</template>
<script type="module" src="./src/app.js"></script>
</body>
</html>

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "rc-timing-control",
"version": "1.1.0",
"private": true,
"description": "RC timing app with AMMC websocket ingest and local SQLite persistence",
"scripts": {
"start": "node scripts/serverctl.js start",
"start:fg": "node server.js",
"stop": "node scripts/serverctl.js stop",
"status": "node scripts/serverctl.js status",
"restart": "node scripts/serverctl.js restart"
},
"dependencies": {
"better-sqlite3": "^11.8.1",
"express": "^4.21.2"
}
}

153
scripts/serverctl.js Normal file
View File

@@ -0,0 +1,153 @@
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
const ROOT = path.join(__dirname, "..");
const DATA_DIR = path.join(ROOT, "data");
const LOG_DIR = path.join(ROOT, "logs");
const PID_FILE = path.join(DATA_DIR, "server.pid");
const OUT_LOG = path.join(LOG_DIR, "server.out.log");
const ERR_LOG = path.join(LOG_DIR, "server.err.log");
fs.mkdirSync(DATA_DIR, { recursive: true });
fs.mkdirSync(LOG_DIR, { recursive: true });
const command = process.argv[2] || "status";
if (command === "start") {
start();
} else if (command === "stop") {
stop();
} else if (command === "restart") {
restart();
} else if (command === "status") {
status();
} else {
console.error(`Unknown command: ${command}`);
process.exit(1);
}
function start() {
const pid = readPid();
if (pid && isRunning(pid)) {
console.log(`Live Event server already running with PID ${pid}`);
console.log(`Logs: ${OUT_LOG}`);
return;
}
cleanupStalePid();
const stdoutFd = fs.openSync(OUT_LOG, "a");
const stderrFd = fs.openSync(ERR_LOG, "a");
const child = spawn(process.execPath, ["server.js"], {
cwd: ROOT,
detached: true,
windowsHide: true,
stdio: ["ignore", stdoutFd, stderrFd],
env: {
...process.env,
RC_TIMING_PID_FILE: PID_FILE,
},
});
child.unref();
fs.writeFileSync(PID_FILE, `${child.pid}\n`);
console.log(`Live Event server started in background`);
console.log(`PID: ${child.pid}`);
console.log(`Logs: ${OUT_LOG}`);
}
async function stop() {
const pid = readPid();
if (!pid) {
console.log("Live Event server is not running");
return;
}
if (!isRunning(pid)) {
cleanupStalePid();
console.log("Removed stale pid file");
return;
}
try {
process.kill(pid, "SIGTERM");
} catch (error) {
console.error(`Could not stop PID ${pid}: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
}
const stopped = await waitForExit(pid, 5000);
if (!stopped && process.platform !== "win32") {
try {
process.kill(pid, "SIGKILL");
} catch {
// ignore
}
await waitForExit(pid, 1000);
}
cleanupStalePid();
console.log(`Live Event server stopped`);
}
async function restart() {
await stop();
start();
}
function status() {
const pid = readPid();
if (pid && isRunning(pid)) {
console.log(`Live Event server is running`);
console.log(`PID: ${pid}`);
console.log(`Logs: ${OUT_LOG}`);
return;
}
cleanupStalePid();
console.log("Live Event server is not running");
}
function readPid() {
if (!fs.existsSync(PID_FILE)) {
return null;
}
const raw = fs.readFileSync(PID_FILE, "utf8").trim();
const pid = Number(raw);
return Number.isFinite(pid) && pid > 0 ? pid : null;
}
function isRunning(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function cleanupStalePid() {
const pid = readPid();
if (!pid || !isRunning(pid)) {
fs.rmSync(PID_FILE, { force: true });
}
}
function waitForExit(pid, timeoutMs) {
return new Promise((resolve) => {
const started = Date.now();
const timer = setInterval(() => {
if (!isRunning(pid)) {
clearInterval(timer);
resolve(true);
return;
}
if (Date.now() - started >= timeoutMs) {
clearInterval(timer);
resolve(false);
}
}, 150);
});
}

796
server.js Normal file
View File

@@ -0,0 +1,796 @@
const fs = require("fs");
const path = require("path");
const os = require("os");
const { spawn } = require("child_process");
const express = require("express");
const Database = require("better-sqlite3");
const PORT = Number(process.env.PORT || 8081);
const HOST = process.env.HOST || "0.0.0.0";
const DB_DIR = path.join(__dirname, "data");
const DB_PATH = path.join(DB_DIR, "rc_timing.sqlite");
const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json");
const PID_FILE = process.env.RC_TIMING_PID_FILE || "";
const WATCHED_FILES = [
path.join(__dirname, "index.html"),
path.join(__dirname, "src", "app.js"),
path.join(__dirname, "src", "styles.css"),
];
fs.mkdirSync(DB_DIR, { recursive: true });
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
initSchema();
const appVersion = {
revision: 1,
updatedAt: new Date().toISOString(),
};
watchAppFiles();
const ammcState = {
process: null,
pid: null,
startedAt: null,
stoppedAt: null,
lastExitCode: null,
lastExitSignal: null,
lastError: "",
lastOutput: [],
config: loadAmmcConfig(),
};
const app = express();
app.use(express.json({ limit: "20mb" }));
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") {
res.status(204).end();
return;
}
next();
});
app.get("/api/health", (_req, res) => {
res.json({ ok: true, dbPath: DB_PATH });
});
app.get("/api/app-version", (_req, res) => {
res.json(appVersion);
});
app.get("/api/ammc/config", (_req, res) => {
res.json({
config: ammcState.config,
status: buildAmmcStatus(),
});
});
app.post("/api/ammc/config", (req, res) => {
if (!req.body || typeof req.body !== "object") {
res.status(400).json({ error: "Expected JSON body" });
return;
}
try {
ammcState.config = normalizeAmmcConfig(req.body, ammcState.config);
saveAmmcConfig(ammcState.config);
res.json({
ok: true,
config: ammcState.config,
status: buildAmmcStatus(),
});
} catch (error) {
res.status(400).json({
error: error instanceof Error ? error.message : String(error),
status: buildAmmcStatus(),
});
}
});
app.get("/api/ammc/status", (_req, res) => {
res.json(buildAmmcStatus());
});
app.post("/api/ammc/start", (_req, res) => {
try {
startAmmcProcess();
res.json({ ok: true, status: buildAmmcStatus() });
} catch (error) {
res.status(400).json({
error: error instanceof Error ? error.message : String(error),
status: buildAmmcStatus(),
});
}
});
app.post("/api/ammc/stop", async (_req, res) => {
try {
await stopAmmcProcess();
res.json({ ok: true, status: buildAmmcStatus() });
} catch (error) {
res.status(500).json({
error: error instanceof Error ? error.message : String(error),
status: buildAmmcStatus(),
});
}
});
app.get("/api/state", (_req, res) => {
const row = db.prepare("SELECT state_json, updated_at FROM app_state WHERE id = 1").get();
if (!row) {
res.json({ state: null, updatedAt: null });
return;
}
try {
res.json({ state: JSON.parse(row.state_json), updatedAt: row.updated_at });
} catch {
res.status(500).json({ error: "Stored app state is invalid JSON" });
}
});
app.post("/api/state", (req, res) => {
if (!req.body || typeof req.body !== "object") {
res.status(400).json({ error: "Expected JSON body" });
return;
}
const stateJson = JSON.stringify(req.body);
const nowIso = new Date().toISOString();
db.prepare(
`
INSERT INTO app_state (id, state_json, updated_at)
VALUES (1, ?, ?)
ON CONFLICT(id) DO UPDATE SET
state_json = excluded.state_json,
updated_at = excluded.updated_at
`
).run(stateJson, nowIso);
res.json({ ok: true, updatedAt: nowIso });
});
app.post("/api/passings", (req, res) => {
const { sessionId, passing } = req.body || {};
if (!sessionId || !passing || typeof passing !== "object") {
res.status(400).json({ error: "Expected { sessionId, passing }" });
return;
}
const stmt = db.prepare(
`
INSERT INTO passings (
session_id,
timestamp_ms,
transponder,
driver_id,
driver_name,
car_id,
car_name,
strength,
loop_id,
resend,
raw_json,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`
);
stmt.run(
String(sessionId),
Number(passing.timestamp || Date.now()),
String(passing.transponder || ""),
passing.driverId ? String(passing.driverId) : null,
passing.driverName ? String(passing.driverName) : null,
passing.carId ? String(passing.carId) : null,
passing.carName ? String(passing.carName) : null,
typeof passing.strength === "number" ? passing.strength : null,
passing.loopId ? String(passing.loopId) : null,
passing.resend ? 1 : 0,
JSON.stringify(passing),
new Date().toISOString()
);
res.json({ ok: true });
});
app.get("/api/passings", (req, res) => {
const sessionId = req.query.sessionId ? String(req.query.sessionId) : null;
const limit = Math.min(1000, Math.max(1, Number(req.query.limit || 200)));
const rows = sessionId
? db
.prepare(
`
SELECT *
FROM passings
WHERE session_id = ?
ORDER BY timestamp_ms DESC
LIMIT ?
`
)
.all(sessionId, limit)
: db
.prepare(
`
SELECT *
FROM passings
ORDER BY timestamp_ms DESC
LIMIT ?
`
)
.all(limit);
res.json({ rows });
});
app.post("/api/export/pdf", (req, res) => {
try {
const payload = normalizePdfExportPayload(req.body || {});
const pdf = buildSimplePdf(payload);
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `attachment; filename="${payload.filename}"`);
res.send(pdf);
} catch (error) {
res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
}
});
app.use(
express.static(__dirname, {
setHeaders: (res, filePath) => {
if (filePath.endsWith(".html") || filePath.endsWith(".js") || filePath.endsWith(".css")) {
res.setHeader("Cache-Control", "no-store");
}
},
})
);
app.get("*", (_req, res) => {
res.sendFile(path.join(__dirname, "index.html"));
});
app.listen(PORT, HOST, () => {
writePidFile();
console.log(`RC Timing server listening on http://${HOST}:${PORT}`);
const lanUrls = getLanUrls(PORT);
if (lanUrls.length) {
console.log(`LAN access: ${lanUrls.join(" , ")}`);
}
console.log(`SQLite database: ${DB_PATH}`);
console.log(`AMMC executable: ${buildAmmcStatus().resolvedExecutablePath}`);
maybeStartManagedAmmc();
});
function initSchema() {
db.exec(`
CREATE TABLE IF NOT EXISTS app_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
state_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS passings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
timestamp_ms INTEGER NOT NULL,
transponder TEXT NOT NULL,
driver_id TEXT,
driver_name TEXT,
car_id TEXT,
car_name TEXT,
strength REAL,
loop_id TEXT,
resend INTEGER NOT NULL DEFAULT 0,
raw_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_passings_session ON passings(session_id);
CREATE INDEX IF NOT EXISTS idx_passings_timestamp ON passings(timestamp_ms);
`);
}
function normalizePdfExportPayload(input = {}) {
const title = String(input.title || "JMK RB Live Event").trim();
const subtitle = String(input.subtitle || "").trim();
const brandName = String(input.brandName || "JMK RB").trim();
const brandTagline = String(input.brandTagline || "Live Event").trim();
const footer = String(input.footer || "").trim();
const theme = ["classic", "minimal", "motorsport"].includes(String(input.theme || "").toLowerCase())
? String(input.theme).toLowerCase()
: "classic";
const filenameBase = String(input.filename || "export.pdf").trim() || "export.pdf";
const filename = filenameBase.toLowerCase().endsWith(".pdf") ? filenameBase : `${filenameBase}.pdf`;
const sections = Array.isArray(input.sections)
? input.sections.map((section) => ({
title: String(section?.title || "").trim(),
headers: Array.isArray(section?.headers) ? section.headers.map((value) => String(value || "").trim()) : [],
rows: Array.isArray(section?.rows)
? section.rows.map((row) => (Array.isArray(row) ? row.map((value) => String(value ?? "").trim()) : [String(row ?? "").trim()]))
: [],
}))
: [];
return { title, subtitle, brandName, brandTagline, footer, theme, filename, sections };
}
function buildSimplePdf(payload) {
const pageWidth = 595;
const pageHeight = 842;
const marginLeft = 42;
const marginTop = 56;
const lineHeight = 15;
const maxChars = 86;
const lines = buildPdfLines(payload, maxChars);
const linesPerPage = Math.max(10, Math.floor((pageHeight - marginTop * 2) / lineHeight));
const pages = [];
for (let index = 0; index < lines.length; index += linesPerPage) {
pages.push(lines.slice(index, index + linesPerPage));
}
if (!pages.length) {
pages.push(["No content"]);
}
const objects = [];
objects.push("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
const kids = [];
const fontObjectId = 3 + pages.length * 2;
for (let index = 0; index < pages.length; index += 1) {
const pageObjectId = 3 + index * 2;
const contentObjectId = 4 + index * 2;
kids.push(`${pageObjectId} 0 R`);
const contentStream = buildPdfContentStream(pages[index], marginLeft, pageHeight - marginTop, lineHeight);
objects.push(
`${pageObjectId} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /Font << /F1 ${fontObjectId} 0 R >> >> /Contents ${contentObjectId} 0 R >>\nendobj\n`
);
objects.push(`${contentObjectId} 0 obj\n<< /Length ${Buffer.byteLength(contentStream, "utf8")} >>\nstream\n${contentStream}\nendstream\nendobj\n`);
}
objects.splice(1, 0, `2 0 obj\n<< /Type /Pages /Count ${pages.length} /Kids [${kids.join(" ")}] >>\nendobj\n`);
objects.push(`${fontObjectId} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n`);
let pdf = "%PDF-1.4\n";
const offsets = [0];
objects.forEach((object) => {
offsets.push(Buffer.byteLength(pdf, "utf8"));
pdf += object;
});
const xrefOffset = Buffer.byteLength(pdf, "utf8");
pdf += `xref\n0 ${objects.length + 1}\n`;
pdf += "0000000000 65535 f \n";
offsets.slice(1).forEach((offset) => {
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
});
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
return Buffer.from(pdf, "utf8");
}
function buildPdfLines(payload, maxChars) {
const lines = [];
const separator =
payload.theme === "minimal"
? ""
: payload.theme === "motorsport"
? "=".repeat(Math.min(maxChars, 70))
: "-".repeat(Math.min(maxChars, 70));
if (payload.brandName) {
lines.push(payload.brandName);
}
if (payload.brandTagline) {
lines.push(payload.brandTagline);
}
if (separator) {
lines.push(separator);
}
if (payload.brandName || payload.brandTagline) {
lines.push("");
}
lines.push(payload.title);
if (payload.subtitle) {
lines.push(payload.subtitle);
}
payload.sections.forEach((section) => {
lines.push("");
if (section.title) {
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));
section.rows.forEach((row) => {
lines.push(formatPdfRow(row, widths));
});
} else {
section.rows.forEach((row) => {
row.forEach((value) => {
wrapPdfText(value, maxChars).forEach((line) => lines.push(line));
});
});
}
});
if (payload.footer) {
lines.push("");
lines.push(payload.footer);
}
return lines;
}
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);
while (total > maxChars) {
const widestIndex = widths.findIndex((width) => width === Math.max(...widths));
if (widths[widestIndex] <= 4) {
break;
}
widths[widestIndex] -= 1;
total -= 1;
}
return widths;
}
function formatPdfRow(values, widths) {
return values
.map((value, index) => String(value || "").slice(0, widths[index]).padEnd(widths[index], " "))
.join(" | ");
}
function wrapPdfText(text, maxChars) {
const words = String(text || "").split(/\s+/).filter(Boolean);
if (!words.length) {
return [""];
}
const lines = [];
let current = "";
words.forEach((word) => {
const next = current ? `${current} ${word}` : word;
if (next.length > maxChars) {
if (current) {
lines.push(current);
current = word;
} else {
lines.push(word.slice(0, maxChars));
current = word.slice(maxChars);
}
} else {
current = next;
}
});
if (current) {
lines.push(current);
}
return lines;
}
function buildPdfContentStream(lines, x, y, lineHeight) {
const escaped = lines.map((line) => escapePdfText(line));
return [
"BT",
"/F1 11 Tf",
`${lineHeight} TL`,
`${x} ${y} Td`,
...escaped.map((line) => `(${line}) Tj\nT*`),
"ET",
].join("\n");
}
function escapePdfText(text) {
return String(text || "")
.replaceAll("\\", "\\\\")
.replaceAll("(", "\\(")
.replaceAll(")", "\\)");
}
function getLanUrls(port) {
const interfaces = os.networkInterfaces();
const urls = [];
Object.values(interfaces).forEach((records) => {
(records || []).forEach((rec) => {
if (rec && rec.family === "IPv4" && !rec.internal) {
urls.push(`http://${rec.address}:${port}`);
}
});
});
return urls;
}
function watchAppFiles() {
WATCHED_FILES.forEach((filePath) => {
if (!fs.existsSync(filePath)) {
return;
}
fs.watchFile(filePath, { interval: 1000 }, (curr, prev) => {
if (curr.mtimeMs !== prev.mtimeMs) {
bumpAppVersion();
}
});
});
}
function bumpAppVersion() {
appVersion.revision += 1;
appVersion.updatedAt = new Date().toISOString();
}
function getDefaultAmmcExecutablePath(platform = process.platform) {
if (platform === "win32") {
return path.join(__dirname, "AMMC", "windows64", "ammc-amb.exe");
}
if (platform === "darwin") {
return path.join(__dirname, "AMMC", "apple_m", "ammc-amb");
}
return path.join(__dirname, "AMMC", "linux_x86-64", "ammc-amb");
}
function normalizeAmmcConfig(input = {}, previous = {}) {
const decoderHost = String(input.decoderHost ?? previous.decoderHost ?? "").trim();
const executablePath = String(input.executablePath ?? previous.executablePath ?? getDefaultAmmcExecutablePath()).trim();
const workingDirectory = String(input.workingDirectory ?? previous.workingDirectory ?? "").trim();
const extraArgs = String(input.extraArgs ?? previous.extraArgs ?? "").trim();
const wsPortValue = Number(input.wsPort ?? previous.wsPort ?? 9000);
if (!Number.isFinite(wsPortValue) || wsPortValue < 1 || wsPortValue > 65535) {
throw new Error("AMMC WebSocket port must be between 1 and 65535");
}
return {
managedEnabled: Boolean(input.managedEnabled ?? previous.managedEnabled ?? false),
autoStart: Boolean(input.autoStart ?? previous.autoStart ?? false),
decoderHost,
wsPort: Math.round(wsPortValue),
executablePath,
workingDirectory,
extraArgs,
updatedAt: new Date().toISOString(),
};
}
function loadAmmcConfig() {
if (!fs.existsSync(AMMC_CONFIG_PATH)) {
return normalizeAmmcConfig({
managedEnabled: false,
autoStart: false,
decoderHost: "",
wsPort: 9000,
executablePath: getDefaultAmmcExecutablePath(),
workingDirectory: "",
extraArgs: "",
});
}
try {
const raw = fs.readFileSync(AMMC_CONFIG_PATH, "utf8");
return normalizeAmmcConfig(JSON.parse(raw));
} catch (error) {
console.error(`Failed to load AMMC config: ${error instanceof Error ? error.message : String(error)}`);
return normalizeAmmcConfig({
managedEnabled: false,
autoStart: false,
decoderHost: "",
wsPort: 9000,
executablePath: getDefaultAmmcExecutablePath(),
workingDirectory: "",
extraArgs: "",
});
}
}
function saveAmmcConfig(config) {
fs.writeFileSync(AMMC_CONFIG_PATH, JSON.stringify(config, null, 2));
}
function resolveAmmcExecutable(executablePath) {
const value = String(executablePath || "").trim();
if (!value) {
return getDefaultAmmcExecutablePath();
}
if (path.isAbsolute(value)) {
return value;
}
return path.join(__dirname, value);
}
function resolveAmmcWorkingDirectory(config, executablePath) {
const explicitDir = String(config.workingDirectory || "").trim();
if (!explicitDir) {
return path.dirname(executablePath);
}
return path.isAbsolute(explicitDir) ? explicitDir : path.join(__dirname, explicitDir);
}
function parseExtraArgs(extraArgs) {
return String(extraArgs || "")
.split(/\s+/)
.map((part) => part.trim())
.filter(Boolean);
}
function isAmmcRunning() {
return Boolean(ammcState.process && ammcState.process.exitCode == null && !ammcState.process.killed);
}
function buildAmmcStatus() {
const resolvedExecutablePath = resolveAmmcExecutable(ammcState.config.executablePath);
const workingDirectory = resolveAmmcWorkingDirectory(ammcState.config, resolvedExecutablePath);
return {
running: isAmmcRunning(),
pid: ammcState.pid,
startedAt: ammcState.startedAt,
stoppedAt: ammcState.stoppedAt,
lastExitCode: ammcState.lastExitCode,
lastExitSignal: ammcState.lastExitSignal,
lastError: ammcState.lastError,
lastOutput: ammcState.lastOutput,
serverPlatform: process.platform,
executableExists: fs.existsSync(resolvedExecutablePath),
resolvedExecutablePath,
workingDirectory,
};
}
function appendAmmcOutput(stream, chunk) {
const lines = String(chunk || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
lines.forEach((line) => {
ammcState.lastOutput.push({
ts: new Date().toISOString(),
stream,
line,
});
});
if (ammcState.lastOutput.length > 30) {
ammcState.lastOutput.splice(0, ammcState.lastOutput.length - 30);
}
}
function startAmmcProcess() {
if (isAmmcRunning()) {
return buildAmmcStatus();
}
const config = normalizeAmmcConfig(ammcState.config, ammcState.config);
ammcState.config = config;
saveAmmcConfig(config);
if (!config.managedEnabled) {
throw new Error("Enable managed AMMC first");
}
if (!config.decoderHost) {
throw new Error("Set decoder IP/host before starting AMMC");
}
const executablePath = resolveAmmcExecutable(config.executablePath);
if (!fs.existsSync(executablePath)) {
throw new Error(`AMMC executable not found: ${executablePath}`);
}
const args = ["-w", String(config.wsPort), config.decoderHost, ...parseExtraArgs(config.extraArgs)];
const workingDirectory = resolveAmmcWorkingDirectory(config, executablePath);
ammcState.lastError = "";
appendAmmcOutput("system", `Starting ${path.basename(executablePath)} ${args.join(" ")}`);
const child = spawn(executablePath, args, {
cwd: workingDirectory,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
});
ammcState.process = child;
ammcState.pid = child.pid || null;
ammcState.startedAt = new Date().toISOString();
ammcState.stoppedAt = null;
ammcState.lastExitCode = null;
ammcState.lastExitSignal = null;
child.stdout.on("data", (chunk) => appendAmmcOutput("stdout", chunk));
child.stderr.on("data", (chunk) => appendAmmcOutput("stderr", chunk));
child.on("error", (error) => {
ammcState.lastError = error instanceof Error ? error.message : String(error);
appendAmmcOutput("error", ammcState.lastError);
});
child.on("exit", (code, signal) => {
ammcState.lastExitCode = code;
ammcState.lastExitSignal = signal;
ammcState.stoppedAt = new Date().toISOString();
appendAmmcOutput("system", `AMMC stopped (code=${code}, signal=${signal || "none"})`);
ammcState.process = null;
ammcState.pid = null;
});
return buildAmmcStatus();
}
function stopAmmcProcess() {
if (!isAmmcRunning() || !ammcState.process) {
return Promise.resolve(buildAmmcStatus());
}
const child = ammcState.process;
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (!settled) {
settled = true;
resolve(buildAmmcStatus());
}
};
child.once("exit", finish);
child.kill();
setTimeout(() => {
if (!settled && isAmmcRunning()) {
if (process.platform !== "win32") {
child.kill("SIGKILL");
}
finish();
}
}, 3000);
});
}
function maybeStartManagedAmmc() {
if (!ammcState.config.managedEnabled || !ammcState.config.autoStart) {
return;
}
try {
startAmmcProcess();
console.log("Managed AMMC started");
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
ammcState.lastError = msg;
console.error(`Managed AMMC failed to start: ${msg}`);
}
}
async function shutdown() {
await stopAmmcProcess();
removePidFile();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
process.on("exit", removePidFile);
function writePidFile() {
if (!PID_FILE) {
return;
}
try {
fs.writeFileSync(PID_FILE, `${process.pid}\n`);
} catch (error) {
console.error(`Failed to write pid file: ${error instanceof Error ? error.message : String(error)}`);
}
}
function removePidFile() {
if (!PID_FILE || !fs.existsSync(PID_FILE)) {
return;
}
try {
const raw = fs.readFileSync(PID_FILE, "utf8").trim();
if (String(process.pid) === raw) {
fs.rmSync(PID_FILE, { force: true });
}
} catch {
// ignore pid cleanup errors on shutdown
}
}

5688
src/app.js Normal file

File diff suppressed because it is too large Load Diff

892
src/styles.css Normal file
View File

@@ -0,0 +1,892 @@
:root {
--bg: #07090e;
--bg-soft: #0f1420;
--panel: #131a28;
--panel-2: #0c1220;
--line: #273149;
--text: #f3f7ff;
--muted: #98a7c8;
--accent: #e10600;
--accent-2: #ff3b30;
--ok: #26c281;
--warn: #f5a623;
--shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Barlow, "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at 15% 0%, rgba(225, 6, 0, 0.18), transparent 30%),
radial-gradient(circle at 100% 80%, rgba(37, 59, 103, 0.22), transparent 30%),
linear-gradient(160deg, #05070b 0%, #090d16 55%, #07090e 100%);
min-height: 100vh;
}
.app-shell {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
.sidebar {
border-right: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(225, 6, 0, 0.1), transparent 25%),
repeating-linear-gradient(
-32deg,
rgba(255, 255, 255, 0.02) 0,
rgba(255, 255, 255, 0.02) 6px,
transparent 6px,
transparent 18px
),
var(--panel-2);
padding: 20px 16px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 16px;
border-bottom: 1px solid var(--line);
margin-bottom: 12px;
}
.brand-mark {
width: 46px;
height: 46px;
border-radius: 10px;
display: grid;
place-items: center;
font-family: Orbitron, sans-serif;
font-weight: 800;
background: linear-gradient(135deg, #ff574f 0%, var(--accent) 55%, #8b0000 100%);
box-shadow: 0 8px 20px rgba(225, 6, 0, 0.5);
}
.brand h1 {
margin: 0;
font-family: Orbitron, sans-serif;
font-size: 1.05rem;
letter-spacing: 0.5px;
}
.brand p {
margin: 4px 0 0;
color: var(--muted);
font-size: 0.84rem;
}
.nav {
display: grid;
gap: 8px;
padding-top: 6px;
}
.nav-item {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.02);
color: var(--text);
border-radius: 10px;
padding: 10px 12px;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 600;
}
.nav-item:hover {
border-color: #405076;
transform: translateX(3px);
}
.nav-item.active {
border-color: rgba(255, 88, 79, 0.55);
background: linear-gradient(90deg, rgba(225, 6, 0, 0.3), rgba(225, 6, 0, 0.1));
}
.sidebar-footer {
position: absolute;
left: 16px;
right: 16px;
bottom: 16px;
color: var(--muted);
}
.badge {
display: inline-block;
border-radius: 999px;
padding: 5px 10px;
font-size: 0.8rem;
font-weight: 700;
border: 1px solid;
}
.badge-online {
color: #b8ffd6;
border-color: rgba(38, 194, 129, 0.7);
background: rgba(38, 194, 129, 0.18);
}
.badge-offline {
color: #ffd2cf;
border-color: rgba(225, 6, 0, 0.7);
background: rgba(225, 6, 0, 0.15);
}
.content {
padding: 20px 24px 26px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--line);
padding-bottom: 14px;
}
.topbar h2 {
margin: 0;
font-family: Orbitron, sans-serif;
letter-spacing: 0.4px;
}
.topbar p {
margin: 6px 0 0;
color: var(--muted);
}
.chip {
margin: 0;
border: 1px solid var(--line);
border-radius: 999px;
padding: 8px 12px;
font-weight: 600;
background: rgba(255, 255, 255, 0.02);
}
.topbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.lang-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 0.86rem;
}
.lang-select {
width: 70px;
padding: 6px 8px;
border-radius: 8px;
}
.view {
margin-top: 16px;
}
.grid {
display: grid;
gap: 14px;
}
.cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.stat-card {
background:
linear-gradient(170deg, rgba(225, 6, 0, 0.12), transparent 40%),
linear-gradient(180deg, #151d2d 0%, #121927 100%);
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px;
box-shadow: var(--shadow);
}
.stat-card p {
margin: 0;
color: var(--muted);
}
.stat-card h3 {
margin: 6px 0;
font-family: Orbitron, sans-serif;
font-size: 1.5rem;
}
.panel-row {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 14px;
margin-top: 14px;
}
.panel {
background: linear-gradient(180deg, #131a28 0%, #101724 100%);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: var(--shadow);
overflow: hidden;
}
.panel-header {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header h3 {
margin: 0;
font-size: 1rem;
letter-spacing: 0.3px;
}
.panel-body {
padding: 12px 14px;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
border: 1px solid #425273;
background: linear-gradient(180deg, #1a2334 0%, #141c2b 100%);
color: var(--text);
border-radius: 10px;
font-weight: 700;
padding: 9px 12px;
cursor: pointer;
}
.btn:hover {
border-color: #60739b;
}
.btn-primary {
border-color: #b11714;
background: linear-gradient(180deg, #f20d07 0%, #d30702 52%, #8e0603 100%);
}
.btn-danger {
border-color: #843137;
background: linear-gradient(180deg, #8f222a, #66181e);
}
.btn-mini {
padding: 3px 8px;
font-size: 0.76rem;
}
.form-grid {
display: grid;
gap: 10px;
}
.field-card {
display: grid;
gap: 8px;
align-content: start;
padding: 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.025);
}
.field-card-checkbox {
grid-column: span 2;
}
.field-label {
font-weight: 700;
}
.field-hint {
color: var(--muted);
font-size: 0.84rem;
line-height: 1.4;
}
.cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
input,
select {
width: 100%;
background: #0f1522;
border: 1px solid var(--line);
border-radius: 10px;
color: var(--text);
padding: 9px 10px;
}
input:focus,
select:focus {
outline: none;
border-color: #6578a4;
box-shadow: 0 0 0 3px rgba(225, 6, 0, 0.18);
}
.log-box {
margin: 10px 0 0;
min-height: 120px;
max-height: 260px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
background: #0b1019;
color: #c8d6f5;
white-space: pre-wrap;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.82rem;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table thead {
background: rgba(255, 255, 255, 0.03);
}
.data-table th,
.data-table td {
text-align: left;
border-bottom: 1px solid var(--line);
padding: 9px 8px;
font-size: 0.95rem;
}
.data-table th {
color: #bdc8e3;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.55px;
}
.data-table tbody tr:hover {
background: rgba(255, 255, 255, 0.02);
}
.simple-list {
margin: 0;
padding: 0;
list-style: none;
}
.simple-list li {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--line);
padding: 8px 0;
}
.assignment-group {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px;
margin-bottom: 10px;
}
.assignment-group h4 {
margin: 0 0 8px;
}
.assignment-group ul {
margin: 0;
padding-left: 18px;
}
.assignment-group li {
margin-bottom: 6px;
}
.actions-inline {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.check-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.check-card {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
}
.check-card input {
width: auto;
}
.grid-editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.drag-list {
display: grid;
gap: 10px;
}
.drag-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
cursor: grab;
}
.drag-item:hover {
border-color: #60739b;
}
.drag-item-active {
opacity: 0.5;
}
.drag-item-over {
border-color: rgba(225, 6, 0, 0.8);
background: rgba(225, 6, 0, 0.12);
}
.logo-preview {
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.03);
}
.logo-preview img,
.overlay-logo {
max-width: 180px;
max-height: 90px;
object-fit: contain;
}
.overlay-logo {
display: block;
margin-bottom: 12px;
}
.position-grid h4,
.final-card h4 {
margin: 0;
}
.position-grid-list,
.matrix-slots {
display: grid;
gap: 10px;
margin-top: 12px;
}
.position-grid-list {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.position-grid-item,
.matrix-slot,
.matrix-session-row,
.final-card {
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
}
.position-grid-item,
.matrix-slot,
.matrix-session-row {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 12px;
}
.final-matrix {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
}
.final-card {
padding: 12px;
}
.final-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.matrix-slot {
justify-content: space-between;
}
.matrix-slot-reserved {
border-color: rgba(245, 166, 35, 0.6);
background: rgba(245, 166, 35, 0.12);
}
.matrix-session-list {
display: grid;
gap: 8px;
margin-top: 12px;
}
.matrix-session-row {
justify-content: space-between;
}
.overlay-mode .sidebar,
.overlay-mode .topbar {
display: none;
}
.overlay-mode .app-shell {
display: block;
}
.overlay-mode .content {
padding: 0;
}
.overlay-shell {
min-height: 100vh;
padding: 24px;
background:
radial-gradient(circle at 15% 0%, rgba(225, 6, 0, 0.18), transparent 30%),
radial-gradient(circle at 100% 80%, rgba(37, 59, 103, 0.22), transparent 30%),
linear-gradient(160deg, #05070b 0%, #090d16 55%, #07090e 100%);
}
.overlay-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-end;
margin-bottom: 18px;
}
.overlay-header h1 {
margin: 4px 0;
font-family: Orbitron, sans-serif;
font-size: clamp(2rem, 4vw, 3.8rem);
}
.overlay-kicker {
margin: 0;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.overlay-meta {
text-align: right;
}
.overlay-clock {
font-family: Orbitron, sans-serif;
font-size: clamp(2.6rem, 5vw, 4.8rem);
font-weight: 800;
}
.overlay-status {
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.overlay-board {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.7fr);
gap: 18px;
}
.overlay-speaker {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
gap: 18px;
}
.overlay-speaker-main {
border: 1px solid var(--line);
border-radius: 18px;
padding: 28px;
background: rgba(7, 12, 20, 0.82);
box-shadow: var(--shadow);
}
.overlay-speaker-label {
font-family: Orbitron, sans-serif;
font-size: 1rem;
color: var(--muted);
letter-spacing: 0.1em;
}
.overlay-speaker-main h2 {
margin: 12px 0;
font-family: Orbitron, sans-serif;
font-size: clamp(2.8rem, 6vw, 5rem);
}
.overlay-speaker-side,
.overlay-results {
display: grid;
gap: 16px;
}
.overlay-table-wrap,
.overlay-side-card,
.overlay-empty {
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(7, 12, 20, 0.82);
box-shadow: var(--shadow);
}
.overlay-table-wrap {
padding: 8px 12px 12px;
}
.overlay-side {
display: grid;
gap: 16px;
}
.overlay-side-card {
padding: 14px;
}
.overlay-side-card h3 {
margin: 0 0 10px;
}
.overlay-passing {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--line);
}
.overlay-passing:last-child {
border-bottom: 0;
}
.overlay-empty {
display: grid;
place-items: center;
min-height: calc(100vh - 48px);
text-align: center;
padding: 24px;
}
.pill {
border: 1px solid var(--line);
border-radius: 999px;
font-size: 0.78rem;
padding: 3px 8px;
color: var(--muted);
}
.pill-green {
border-color: rgba(38, 194, 129, 0.6);
color: #b7ffd4;
}
.pos-pill {
display: inline-grid;
place-items: center;
width: 32px;
height: 22px;
border-radius: 999px;
border: 1px solid var(--line);
font-weight: 800;
}
.pos-1 {
background: linear-gradient(180deg, #ffd95a, #d3a80e);
color: #1a1400;
border-color: #ffd95a;
}
.pos-2 {
background: linear-gradient(180deg, #dce4ef, #99a7b9);
color: #0f141b;
border-color: #dce4ef;
}
.pos-3 {
background: linear-gradient(180deg, #d19562, #8c5d34);
color: #1a0f05;
border-color: #d19562;
}
.best {
color: #dfb9ff;
font-weight: 700;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
}
.error {
color: #ffb5b5;
min-height: 18px;
}
.finish-banner {
margin-top: 10px;
display: inline-block;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgba(225, 6, 0, 0.7);
background: rgba(225, 6, 0, 0.2);
color: #ffd6d4;
font-weight: 700;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(4, 7, 12, 0.76);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 40;
}
.modal-card {
width: min(760px, 100%);
max-height: 88vh;
overflow: auto;
border: 1px solid var(--line);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(225, 6, 0, 0.08), transparent 20%),
linear-gradient(180deg, #131a28 0%, #101724 100%);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
}
.hint {
margin: 10px 0 0;
color: var(--muted);
font-size: 0.9rem;
}
.mt-16 {
margin-top: 16px;
}
@media (max-width: 1200px) {
.cols-4,
.cols-5 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.panel-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 820px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
}
.sidebar-footer {
position: static;
margin-top: 14px;
}
.cols-3,
.cols-4,
.cols-5 {
grid-template-columns: 1fr;
}
.topbar {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.field-card-checkbox {
grid-column: span 1;
}
.overlay-board {
grid-template-columns: 1fr;
}
.overlay-speaker {
grid-template-columns: 1fr;
}
.overlay-header {
align-items: flex-start;
flex-direction: column;
}
.modal-overlay {
padding: 12px;
}
}

174
windows/README.md Normal file
View File

@@ -0,0 +1,174 @@
# JMK RB Live Event
RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika heat/finaler), AMMC WebSocket och lokal SQLite-lagring på Windows.
## Vad som ingår
- Event-lägen:
- `Race (driver transponders)`
- `Track Event (shared cars)`
- UI-separering:
- `Event` = sponsor-event med delade bilar/transpondrar
- `Race Setup` = riktiga race med personlig transponder per förare
- `Race Setup` innehåller nu även:
- välj exakt vilka förare som är med i racet
- practice-ranking
- kval-ranking med `poäng` eller `bästa resultat`
- inbyggd guide för hur man skapar race steg för steg
- beskrivningar direkt i alla fält under `Raceformat`
- sessionstyp `Free Practice` för löpande varvtider utan seedning
- auto-generering av kvalheat från practice-ranking eller klasslista
- reseeding av kommande kvalheat från aktuell ranking
- auto-generering av `A/B/C...` finaler från ranking
- sparad manuell grid per session via dragbar grid-editor
- final-ranking över flera leg med räknade finalheat
- valbar `bump-up` mellan finaler
- reserverade bump-platser i högre finaler
- visuell finalmatris med reserverade bump-platser
- dragbar grid-editor för positionsstart
- utskrift/export av heatsheets per kval/final
- overlay-vy för extern leaderboard-skärm
- flera overlay-lägen: leaderboard, speaker och results
- utskrift av startlistor och resultat
- genererade kval/finaler ärver tid och starttyp från raceformatet
- finish-ljud som siren i stället för browser-röst
- Sessioner: `practice`, `qualification`, `heat`, `final`
- Sponsor-verktyg:
- Skapa rundor automatiskt (`qualification`, `heat`, `final`)
- Auto-assign förare -> bil per session
- Live timing från AMMC WebSocket (`msg: "PASSING"`)
- Hanterad AMMC från webbgränssnittet:
- backend kan starta/stoppa lokal `ammc-amb` på Windows, Linux och macOS
- läser bundlade binärer från `AMMC/windows64`, `AMMC/linux_x86-64`, `AMMC/apple_m`
- Redigering i UI:
- Klasser, eventnamn/datum, förare och bilar kan redigeras direkt
- Live race-kontroll:
- Nedräkning under pågående session
- Auto-finish vid tidslut med status `Race is finished`
- Leaderboard-sortering: varv först, därefter närmast måltid för sessionen (t.ex. 5 min = 600s)
- Browserljud för passing (`blipp` eller tala förarnamn) och målgång
- Sessioninställningar för `Mass start`, `Position start`, `Staggered`
- `Timing` visar grid/startordning för aktiv `Position start`-session
- leaderboard visar både `gap till ledaren`, `gap till bilen framför` och `eget delta` mot förra varvet
- Practice/Kval kan seedas på bästa `2` eller `3` varv i sessionsinställningar
- Persistens:
- Frontend state i browser (`localStorage`)
- Samma state + passeringar sparas i lokal SQLite via Node-backend
- Inbyggd `Guide`-meny i appen med steg-för-steg för:
- Sponsor-event (10 personer / 4 bilar)
- Vanligt race
- AMMC + npm setup på Windows och Linux
- Språkval i UI: `SV` / `EN`
## Windows installation
Kör i PowerShell i projektmappen.
1. Installera Node.js LTS (18+).
2. Installera dependencies:
```powershell
npm install
```
3. Starta servern i bakgrunden:
```powershell
npm start
```
4. Öppna:
- `http://localhost:8081`
- eller från annan dator: `http://<server-ip>:8081`
Vanliga kommandon:
```powershell
npm start
npm stop
npm restart
npm run status
npm run start:fg
```
- `npm start` startar `live_event` i bakgrunden
- `npm stop` stoppar processen via `data/server.pid`
- `npm restart` startar om backend
- `npm run status` visar om backend kör
- `npm run start:fg` kör i foreground för felsökning
### Windows scripts (bakgrundsstart)
Det finns färdiga `.bat`-filer i mappen `windows/`:
- `windows\\start_ammc.bat` startar AMMC i bakgrunden
- `windows\\start_backend.bat` startar `npm start` i bakgrunden (logg: `logs\\backend.log`)
- `windows\\start_all.bat` startar både AMMC + backend och öppnar webbsidan
- `windows\\stop_all.bat` stoppar backend på port `8081` och `ammc-amb.exe`
SQLite-filen skapas automatiskt här:
- `data\\rc_timing.sqlite`
## Koppla mot AMMC
Det finns nu två sätt:
### A. Hanterad AMMC i webbgränssnittet
Viktigt:
- AMMC körs på samma host där `npm start` / `node server.js` körs.
- Om du öppnar sidan från en annan laptop startas ingen AMMC där.
- Fältet `AMMC binär` i `Settings` är en sökväg på backend-hosten, inte på klienten som surfar in.
1. Lägg AMMC-binärerna i projektmappen `AMMC/` (redan gjort i denna repo).
2. Öppna `Settings`.
3. Aktivera `Hanterad AMMC / Managed AMMC`.
4. Sätt `Decoder IP / host`, kontrollera port `9000`, spara.
5. Klicka `Starta AMMC / Start AMMC`.
6. Klicka `Använd serverns WS-url / Use server WS URL` så sätts klienten till t.ex. `ws://<server-ip>:9000`.
Standardbinärer:
- Linux-host: `AMMC/linux_x86-64/ammc-amb`
- Windows-host: `AMMC/windows64/ammc-amb.exe`
- macOS-host: `AMMC/apple_m/ammc-amb`
### B. Manuell start
Starta AMMC med WebSocket (exempel):
```powershell
ammc-amb.exe -w 9000 192.168.1.11
```
I appen:
1. `Settings`
2. Sätt `WebSocket URL` till t.ex. `ws://127.0.0.1:9000`
3. Sätt `Backend URL` till `http://127.0.0.1:8081`
4. Klicka `Test Backend`
5. Gå till `Timing` och klicka `Connect Decoder`
Om du kör Linux-brandvägg (UFW), öppna porten:
```bash
sudo ufw allow 8081/tcp
```
## Auto reload vid uppdatering
- Servern bevakar `index.html`, `src/app.js` och `src/styles.css`.
- När du uppdaterar filer i `live_event` och sparar, laddar klienten om sidan automatiskt.
- Om backendkoden ändras, kör `npm restart`.
## Verifiera att SQLite sparar
Hämta senaste passeringar via API:
- `http://localhost:8081/api/passings`
Hämta sparad app-state:
- `http://localhost:8081/api/state`
## Viktig regel för sponsor-event
- I **samma pågående session** måste varje aktiv bil ha unikt transponder-ID.
- Samma transponder-ID kan återanvändas i **nästa session** (Heat 1 -> Heat 2 -> Heat 3 -> Final 1 ...).
## Referens AMMC JSON
Officiell quick-start:
- https://www.ammconverter.eu/docs/intro/quick-start/
Exempelmeddelande:
```json
{
"msg": "PASSING",
"passing_number": 1,
"transponder": 232323,
"rtc_time": "2022-10-11T22:57:36.099+02:00",
"strength": 0.0,
"resend": false,
"tran_code": "ID:232323",
"loop_id": "55"
}
```

5171
windows/src/app.js Normal file

File diff suppressed because it is too large Load Diff

873
windows/src/styles.css Normal file
View File

@@ -0,0 +1,873 @@
:root {
--bg: #07090e;
--bg-soft: #0f1420;
--panel: #131a28;
--panel-2: #0c1220;
--line: #273149;
--text: #f3f7ff;
--muted: #98a7c8;
--accent: #e10600;
--accent-2: #ff3b30;
--ok: #26c281;
--warn: #f5a623;
--shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Barlow, "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at 15% 0%, rgba(225, 6, 0, 0.18), transparent 30%),
radial-gradient(circle at 100% 80%, rgba(37, 59, 103, 0.22), transparent 30%),
linear-gradient(160deg, #05070b 0%, #090d16 55%, #07090e 100%);
min-height: 100vh;
}
.app-shell {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
.sidebar {
border-right: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(225, 6, 0, 0.1), transparent 25%),
repeating-linear-gradient(
-32deg,
rgba(255, 255, 255, 0.02) 0,
rgba(255, 255, 255, 0.02) 6px,
transparent 6px,
transparent 18px
),
var(--panel-2);
padding: 20px 16px;
position: sticky;
top: 0;
height: 100vh;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
padding-bottom: 16px;
border-bottom: 1px solid var(--line);
margin-bottom: 12px;
}
.brand-mark {
width: 46px;
height: 46px;
border-radius: 10px;
display: grid;
place-items: center;
font-family: Orbitron, sans-serif;
font-weight: 800;
background: linear-gradient(135deg, #ff574f 0%, var(--accent) 55%, #8b0000 100%);
box-shadow: 0 8px 20px rgba(225, 6, 0, 0.5);
}
.brand h1 {
margin: 0;
font-family: Orbitron, sans-serif;
font-size: 1.05rem;
letter-spacing: 0.5px;
}
.brand p {
margin: 4px 0 0;
color: var(--muted);
font-size: 0.84rem;
}
.nav {
display: grid;
gap: 8px;
padding-top: 6px;
}
.nav-item {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.02);
color: var(--text);
border-radius: 10px;
padding: 10px 12px;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
font-weight: 600;
}
.nav-item:hover {
border-color: #405076;
transform: translateX(3px);
}
.nav-item.active {
border-color: rgba(255, 88, 79, 0.55);
background: linear-gradient(90deg, rgba(225, 6, 0, 0.3), rgba(225, 6, 0, 0.1));
}
.sidebar-footer {
position: absolute;
left: 16px;
right: 16px;
bottom: 16px;
color: var(--muted);
}
.badge {
display: inline-block;
border-radius: 999px;
padding: 5px 10px;
font-size: 0.8rem;
font-weight: 700;
border: 1px solid;
}
.badge-online {
color: #b8ffd6;
border-color: rgba(38, 194, 129, 0.7);
background: rgba(38, 194, 129, 0.18);
}
.badge-offline {
color: #ffd2cf;
border-color: rgba(225, 6, 0, 0.7);
background: rgba(225, 6, 0, 0.15);
}
.content {
padding: 20px 24px 26px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--line);
padding-bottom: 14px;
}
.topbar h2 {
margin: 0;
font-family: Orbitron, sans-serif;
letter-spacing: 0.4px;
}
.topbar p {
margin: 6px 0 0;
color: var(--muted);
}
.chip {
margin: 0;
border: 1px solid var(--line);
border-radius: 999px;
padding: 8px 12px;
font-weight: 600;
background: rgba(255, 255, 255, 0.02);
}
.topbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.lang-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 0.86rem;
}
.lang-select {
width: 70px;
padding: 6px 8px;
border-radius: 8px;
}
.view {
margin-top: 16px;
}
.grid {
display: grid;
gap: 14px;
}
.cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.stat-card {
background:
linear-gradient(170deg, rgba(225, 6, 0, 0.12), transparent 40%),
linear-gradient(180deg, #151d2d 0%, #121927 100%);
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px;
box-shadow: var(--shadow);
}
.stat-card p {
margin: 0;
color: var(--muted);
}
.stat-card h3 {
margin: 6px 0;
font-family: Orbitron, sans-serif;
font-size: 1.5rem;
}
.panel-row {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 14px;
margin-top: 14px;
}
.panel {
background: linear-gradient(180deg, #131a28 0%, #101724 100%);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: var(--shadow);
overflow: hidden;
}
.panel-header {
padding: 12px 14px;
border-bottom: 1px solid var(--line);
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header h3 {
margin: 0;
font-size: 1rem;
letter-spacing: 0.3px;
}
.panel-body {
padding: 12px 14px;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn {
border: 1px solid #425273;
background: linear-gradient(180deg, #1a2334 0%, #141c2b 100%);
color: var(--text);
border-radius: 10px;
font-weight: 700;
padding: 9px 12px;
cursor: pointer;
}
.btn:hover {
border-color: #60739b;
}
.btn-primary {
border-color: #b11714;
background: linear-gradient(180deg, #f20d07 0%, #d30702 52%, #8e0603 100%);
}
.btn-danger {
border-color: #843137;
background: linear-gradient(180deg, #8f222a, #66181e);
}
.btn-mini {
padding: 3px 8px;
font-size: 0.76rem;
}
.form-grid {
display: grid;
gap: 10px;
}
.field-card {
display: grid;
gap: 8px;
align-content: start;
padding: 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.025);
}
.field-card-checkbox {
grid-column: span 2;
}
.field-label {
font-weight: 700;
}
.field-hint {
color: var(--muted);
font-size: 0.84rem;
line-height: 1.4;
}
.cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
input,
select {
width: 100%;
background: #0f1522;
border: 1px solid var(--line);
border-radius: 10px;
color: var(--text);
padding: 9px 10px;
}
input:focus,
select:focus {
outline: none;
border-color: #6578a4;
box-shadow: 0 0 0 3px rgba(225, 6, 0, 0.18);
}
.log-box {
margin: 10px 0 0;
min-height: 120px;
max-height: 260px;
overflow: auto;
border: 1px solid var(--line);
border-radius: 10px;
padding: 10px;
background: #0b1019;
color: #c8d6f5;
white-space: pre-wrap;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.82rem;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table thead {
background: rgba(255, 255, 255, 0.03);
}
.data-table th,
.data-table td {
text-align: left;
border-bottom: 1px solid var(--line);
padding: 9px 8px;
font-size: 0.95rem;
}
.data-table th {
color: #bdc8e3;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.55px;
}
.data-table tbody tr:hover {
background: rgba(255, 255, 255, 0.02);
}
.simple-list {
margin: 0;
padding: 0;
list-style: none;
}
.simple-list li {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--line);
padding: 8px 0;
}
.assignment-group {
background: rgba(255, 255, 255, 0.02);
border: 1px solid var(--line);
border-radius: 12px;
padding: 10px;
margin-bottom: 10px;
}
.assignment-group h4 {
margin: 0 0 8px;
}
.assignment-group ul {
margin: 0;
padding-left: 18px;
}
.assignment-group li {
margin-bottom: 6px;
}
.actions-inline {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.check-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.check-card {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
}
.check-card input {
width: auto;
}
.grid-editor-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.drag-list {
display: grid;
gap: 10px;
}
.drag-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
cursor: grab;
}
.drag-item:hover {
border-color: #60739b;
}
.drag-item-active {
opacity: 0.5;
}
.drag-item-over {
border-color: rgba(225, 6, 0, 0.8);
background: rgba(225, 6, 0, 0.12);
}
.position-grid h4,
.final-card h4 {
margin: 0;
}
.position-grid-list,
.matrix-slots {
display: grid;
gap: 10px;
margin-top: 12px;
}
.position-grid-list {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.position-grid-item,
.matrix-slot,
.matrix-session-row,
.final-card {
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
}
.position-grid-item,
.matrix-slot,
.matrix-session-row {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 12px;
}
.final-matrix {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 12px;
}
.final-card {
padding: 12px;
}
.final-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.matrix-slot {
justify-content: space-between;
}
.matrix-slot-reserved {
border-color: rgba(245, 166, 35, 0.6);
background: rgba(245, 166, 35, 0.12);
}
.matrix-session-list {
display: grid;
gap: 8px;
margin-top: 12px;
}
.matrix-session-row {
justify-content: space-between;
}
.overlay-mode .sidebar,
.overlay-mode .topbar {
display: none;
}
.overlay-mode .app-shell {
display: block;
}
.overlay-mode .content {
padding: 0;
}
.overlay-shell {
min-height: 100vh;
padding: 24px;
background:
radial-gradient(circle at 15% 0%, rgba(225, 6, 0, 0.18), transparent 30%),
radial-gradient(circle at 100% 80%, rgba(37, 59, 103, 0.22), transparent 30%),
linear-gradient(160deg, #05070b 0%, #090d16 55%, #07090e 100%);
}
.overlay-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-end;
margin-bottom: 18px;
}
.overlay-header h1 {
margin: 4px 0;
font-family: Orbitron, sans-serif;
font-size: clamp(2rem, 4vw, 3.8rem);
}
.overlay-kicker {
margin: 0;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.overlay-meta {
text-align: right;
}
.overlay-clock {
font-family: Orbitron, sans-serif;
font-size: clamp(2.6rem, 5vw, 4.8rem);
font-weight: 800;
}
.overlay-status {
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.overlay-board {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.7fr);
gap: 18px;
}
.overlay-speaker {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
gap: 18px;
}
.overlay-speaker-main {
border: 1px solid var(--line);
border-radius: 18px;
padding: 28px;
background: rgba(7, 12, 20, 0.82);
box-shadow: var(--shadow);
}
.overlay-speaker-label {
font-family: Orbitron, sans-serif;
font-size: 1rem;
color: var(--muted);
letter-spacing: 0.1em;
}
.overlay-speaker-main h2 {
margin: 12px 0;
font-family: Orbitron, sans-serif;
font-size: clamp(2.8rem, 6vw, 5rem);
}
.overlay-speaker-side,
.overlay-results {
display: grid;
gap: 16px;
}
.overlay-table-wrap,
.overlay-side-card,
.overlay-empty {
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(7, 12, 20, 0.82);
box-shadow: var(--shadow);
}
.overlay-table-wrap {
padding: 8px 12px 12px;
}
.overlay-side {
display: grid;
gap: 16px;
}
.overlay-side-card {
padding: 14px;
}
.overlay-side-card h3 {
margin: 0 0 10px;
}
.overlay-passing {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--line);
}
.overlay-passing:last-child {
border-bottom: 0;
}
.overlay-empty {
display: grid;
place-items: center;
min-height: calc(100vh - 48px);
text-align: center;
padding: 24px;
}
.pill {
border: 1px solid var(--line);
border-radius: 999px;
font-size: 0.78rem;
padding: 3px 8px;
color: var(--muted);
}
.pill-green {
border-color: rgba(38, 194, 129, 0.6);
color: #b7ffd4;
}
.pos-pill {
display: inline-grid;
place-items: center;
width: 32px;
height: 22px;
border-radius: 999px;
border: 1px solid var(--line);
font-weight: 800;
}
.pos-1 {
background: linear-gradient(180deg, #ffd95a, #d3a80e);
color: #1a1400;
border-color: #ffd95a;
}
.pos-2 {
background: linear-gradient(180deg, #dce4ef, #99a7b9);
color: #0f141b;
border-color: #dce4ef;
}
.pos-3 {
background: linear-gradient(180deg, #d19562, #8c5d34);
color: #1a0f05;
border-color: #d19562;
}
.best {
color: #dfb9ff;
font-weight: 700;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
}
.error {
color: #ffb5b5;
min-height: 18px;
}
.finish-banner {
margin-top: 10px;
display: inline-block;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgba(225, 6, 0, 0.7);
background: rgba(225, 6, 0, 0.2);
color: #ffd6d4;
font-weight: 700;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(4, 7, 12, 0.76);
backdrop-filter: blur(6px);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 40;
}
.modal-card {
width: min(760px, 100%);
max-height: 88vh;
overflow: auto;
border: 1px solid var(--line);
border-radius: 16px;
background:
linear-gradient(180deg, rgba(225, 6, 0, 0.08), transparent 20%),
linear-gradient(180deg, #131a28 0%, #101724 100%);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
}
.hint {
margin: 10px 0 0;
color: var(--muted);
font-size: 0.9rem;
}
.mt-16 {
margin-top: 16px;
}
@media (max-width: 1200px) {
.cols-4,
.cols-5 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.panel-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 820px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
}
.sidebar-footer {
position: static;
margin-top: 14px;
}
.cols-3,
.cols-4,
.cols-5 {
grid-template-columns: 1fr;
}
.topbar {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.field-card-checkbox {
grid-column: span 1;
}
.overlay-board {
grid-template-columns: 1fr;
}
.overlay-speaker {
grid-template-columns: 1fr;
}
.overlay-header {
align-items: flex-start;
flex-direction: column;
}
.modal-overlay {
padding: 12px;
}
}

10
windows/start_all.bat Normal file
View File

@@ -0,0 +1,10 @@
@echo off
setlocal
call "%~dp0start_ammc.bat"
call "%~dp0start_backend.bat"
start "" "http://localhost:8081"
echo [OK] AMMC + backend started
endlocal

23
windows/start_ammc.bat Normal file
View File

@@ -0,0 +1,23 @@
@echo off
setlocal
if "%AMMC_EXE%"=="" set "AMMC_EXE=%~dp0ammc-amb.exe"
if not exist "%AMMC_EXE%" if exist "%~dp0..\ammc-amb.exe" set "AMMC_EXE=%~dp0..\ammc-amb.exe"
if "%DECODER_IP%"=="" set "DECODER_IP=192.168.1.11"
if "%WS_PORT%"=="" set "WS_PORT=9000"
if not exist "%AMMC_EXE%" (
echo [ERROR] Could not find AMMC executable:
echo %AMMC_EXE%
echo Set AMMC_EXE in this file or as environment variable.
exit /b 1
)
start "AMMC" /min cmd /c "\"%AMMC_EXE%\" -w %WS_PORT% %DECODER_IP%"
echo [OK] AMMC started in background
echo Decoder IP: %DECODER_IP%
echo WebSocket: ws://127.0.0.1:%WS_PORT%
endlocal

15
windows/start_backend.bat Normal file
View File

@@ -0,0 +1,15 @@
@echo off
setlocal
for %%I in ("%~dp0..") do set "APP_DIR=%%~fI"
set "LOG_DIR=%APP_DIR%\logs"
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
start "RC Timing Backend" /min cmd /c "cd /d \"%APP_DIR%\" && npm start >> \"%LOG_DIR%\backend.log\" 2>&1"
echo [OK] Backend started in background
echo URL: http://localhost:8081
echo Log: %LOG_DIR%\backend.log
endlocal

11
windows/stop_all.bat Normal file
View File

@@ -0,0 +1,11 @@
@echo off
setlocal
for /f "tokens=5" %%p in ('netstat -ano ^| findstr ":8081" ^| findstr "LISTENING"') do (
taskkill /PID %%p /F >nul 2>&1
)
taskkill /IM ammc-amb.exe /F >nul 2>&1
echo [OK] Stopped backend on :8081 and ammc-amb.exe (if running)
endlocal