Add GPS trip reports and route maps

This commit is contained in:
2026-08-01 00:56:19 +02:00
parent cb0ac06091
commit e501895e56
12 changed files with 817 additions and 21 deletions
+25
View File
@@ -1732,6 +1732,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1739,6 +1746,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.20.1", "version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
@@ -3616,6 +3633,12 @@
"json-buffer": "3.0.1" "json-buffer": "3.0.1"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -5137,10 +5160,12 @@
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"leaflet": "^1.9.4",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
+1 -1
View File
@@ -168,7 +168,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
reply.header("X-Frame-Options", "DENY"); reply.header("X-Frame-Options", "DENY");
reply.header( reply.header(
"Content-Security-Policy", "Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'" "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://*.tile.openstreetmap.org; connect-src 'self'"
); );
}); });
+46
View File
@@ -101,6 +101,52 @@ function migrate(database: CompanionDatabase): void {
network_type TEXT network_type TEXT
); );
CREATE TABLE IF NOT EXISTS trips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL REFERENCES mobile_devices(id) ON DELETE CASCADE,
started_at INTEGER NOT NULL,
ended_at INTEGER,
last_moving_at INTEGER NOT NULL,
start_latitude REAL NOT NULL,
start_longitude REAL NOT NULL,
end_latitude REAL NOT NULL,
end_longitude REAL NOT NULL,
distance_km REAL NOT NULL DEFAULT 0,
moving_seconds REAL NOT NULL DEFAULT 0,
point_count INTEGER NOT NULL DEFAULT 0,
speed_sum REAL NOT NULL DEFAULT 0,
speed_count INTEGER NOT NULL DEFAULT 0,
peak_speed_kph REAL,
peak_speed_at INTEGER,
peak_speed_latitude REAL,
peak_speed_longitude REAL,
vehicle_battery_start REAL,
vehicle_battery_end REAL,
vehicle_consumption_start_kwh REAL,
vehicle_consumption_end_kwh REAL,
phone_battery_start REAL,
phone_battery_end REAL
);
CREATE INDEX IF NOT EXISTS trips_started_idx ON trips(started_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS trips_active_device_idx ON trips(device_id) WHERE ended_at IS NULL;
CREATE TABLE IF NOT EXISTS trip_points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
captured_at INTEGER NOT NULL,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
accuracy_meters REAL NOT NULL,
altitude_meters REAL,
speed_kph REAL,
bearing_degrees REAL,
vehicle_battery_percent REAL,
UNIQUE(trip_id, captured_at)
);
CREATE INDEX IF NOT EXISTS trip_points_trip_time_idx ON trip_points(trip_id, captured_at);
CREATE TABLE IF NOT EXISTS car_telemetry_samples ( CREATE TABLE IF NOT EXISTS car_telemetry_samples (
recorded_at INTEGER PRIMARY KEY, recorded_at INTEGER PRIMARY KEY,
speed_kph REAL, speed_kph REAL,
+33 -1
View File
@@ -8,6 +8,7 @@ import type { CarTelemetryRecorder } from "./car-telemetry.js";
import type { CompanionDatabase } from "./database.js"; import type { CompanionDatabase } from "./database.js";
import { hashToken, randomToken } from "./security.js"; import { hashToken, randomToken } from "./security.js";
import { collectSystemStatus } from "./status.js"; import { collectSystemStatus } from "./status.js";
import { TripStore } from "./trips.js";
const pairingSchema = z.object({ const pairingSchema = z.object({
code: z.string().regex(/^\d{6}$/), code: z.string().regex(/^\d{6}$/),
@@ -71,6 +72,8 @@ export function registerMobileRoutes(
carTelemetry: CarTelemetryRecorder, carTelemetry: CarTelemetryRecorder,
requireWebUser: (request: FastifyRequest, reply: FastifyReply) => boolean requireWebUser: (request: FastifyRequest, reply: FastifyReply) => boolean
): void { ): void {
const trips = new TripStore(database);
app.post("/api/mobile/pairing", async (request, reply) => { app.post("/api/mobile/pairing", async (request, reply) => {
if (!requireWebUser(request, reply)) return; if (!requireWebUser(request, reply)) return;
const code = String(randomInt(100_000, 1_000_000)); const code = String(randomInt(100_000, 1_000_000));
@@ -94,6 +97,22 @@ export function registerMobileRoutes(
return { devices }; return { devices };
}); });
app.get("/api/trips", async (request, reply) => {
if (!requireWebUser(request, reply)) return;
return trips.list();
});
app.get<{ Params: { id: string } }>("/api/trips/:id", async (request, reply) => {
if (!requireWebUser(request, reply)) return;
const id = Number(request.params.id);
if (!Number.isInteger(id) || id < 1) {
return reply.code(400).send({ error: { code: "INVALID_TRIP", message: "That trip is invalid" } });
}
const trip = trips.detail(id);
if (!trip) return reply.code(404).send({ error: { code: "TRIP_NOT_FOUND", message: "Trip not found" } });
return trip;
});
app.get("/api/mobile/bluetooth", async (request, reply) => { app.get("/api/mobile/bluetooth", async (request, reply) => {
if (!requireWebUser(request, reply)) return; if (!requireWebUser(request, reply)) return;
return readBluetoothStatus(); return readBluetoothStatus();
@@ -170,6 +189,7 @@ export function registerMobileRoutes(
const parsed = locationSchema.safeParse(request.body); const parsed = locationSchema.safeParse(request.body);
if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_PHONE_LOCATION", message: "The phone location was invalid" } }); if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_PHONE_LOCATION", message: "The phone location was invalid" } });
const value = parsed.data; const value = parsed.data;
const trip = database.transaction(() => {
database.prepare( database.prepare(
`INSERT INTO mobile_phone_state `INSERT INTO mobile_phone_state
(device_id, received_at, captured_at, latitude, longitude, accuracy_meters, altitude_meters, (device_id, received_at, captured_at, latitude, longitude, accuracy_meters, altitude_meters,
@@ -183,7 +203,19 @@ export function registerMobileRoutes(
).run(device.id, new Date().toISOString(), value.capturedAt, value.latitude, value.longitude, value.accuracyMeters, ).run(device.id, new Date().toISOString(), value.capturedAt, value.latitude, value.longitude, value.accuracyMeters,
value.altitudeMeters ?? null, value.speedKph ?? null, value.bearingDegrees ?? null, value.altitudeMeters ?? null, value.speedKph ?? null, value.bearingDegrees ?? null,
value.batteryPercent ?? null, value.networkType ?? null); value.batteryPercent ?? null, value.networkType ?? null);
return { accepted: true }; return trips.record({
deviceId: device.id,
capturedAt: value.capturedAt,
latitude: value.latitude,
longitude: value.longitude,
accuracyMeters: value.accuracyMeters,
altitudeMeters: value.altitudeMeters ?? null,
speedKph: value.speedKph ?? null,
bearingDegrees: value.bearingDegrees ?? null,
phoneBatteryPercent: value.batteryPercent ?? null
});
})();
return { accepted: true, trip };
}); });
app.post<{ Params: { id: string } }>("/internal/mobile/shortcuts/:id/launch", async (request, reply) => { app.post<{ Params: { id: string } }>("/internal/mobile/shortcuts/:id/launch", async (request, reply) => {
+264
View File
@@ -0,0 +1,264 @@
import type { CompanionDatabase } from "./database.js";
const START_SPEED_KPH = 5;
const STOP_TIMEOUT_MS = 3 * 60_000;
const GAP_TIMEOUT_MS = 10 * 60_000;
const MAX_LOCATION_ACCURACY_METERS = 100;
const CAR_SAMPLE_WINDOW_MS = 30_000;
export type LocationSample = {
deviceId: string;
capturedAt: string;
latitude: number;
longitude: number;
accuracyMeters: number;
altitudeMeters?: number | null;
speedKph?: number | null;
bearingDegrees?: number | null;
phoneBatteryPercent?: number | null;
};
type CarSample = {
speed_kph: number | null;
battery_percent: number | null;
total_consumption_kwh: number | null;
};
type ActiveTrip = {
id: number;
started_at: number;
last_moving_at: number;
end_latitude: number;
end_longitude: number;
};
type PreviousPoint = {
captured_at: number;
latitude: number;
longitude: number;
speed_kph: number | null;
};
function finite(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function haversineKm(a: { latitude: number; longitude: number }, b: { latitude: number; longitude: number }): number {
const radians = Math.PI / 180;
const lat1 = a.latitude * radians;
const lat2 = b.latitude * radians;
const deltaLat = (b.latitude - a.latitude) * radians;
const deltaLon = (b.longitude - a.longitude) * radians;
const value = Math.sin(deltaLat / 2) ** 2
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
return 6_371 * 2 * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
}
export class TripStore {
constructor(private readonly database: CompanionDatabase) {}
record(sample: LocationSample): { tripId: number | null; active: boolean } {
const capturedAt = Date.parse(sample.capturedAt);
if (!Number.isFinite(capturedAt) || sample.accuracyMeters > MAX_LOCATION_ACCURACY_METERS) {
return { tripId: null, active: false };
}
const car = this.database.prepare(
`SELECT speed_kph, battery_percent, total_consumption_kwh
FROM car_telemetry_samples WHERE recorded_at BETWEEN ? AND ?
ORDER BY ABS(recorded_at - ?) LIMIT 1`
).get(capturedAt - CAR_SAMPLE_WINDOW_MS, capturedAt + CAR_SAMPLE_WINDOW_MS, capturedAt) as CarSample | undefined;
const speedKph = finite(car?.speed_kph) ?? finite(sample.speedKph);
const moving = (speedKph ?? 0) >= START_SPEED_KPH;
let active = this.activeTrip(sample.deviceId);
if (active && capturedAt <= active.started_at) return { tripId: active.id, active: true };
if (active && capturedAt - active.last_moving_at >= GAP_TIMEOUT_MS) {
this.finish(active.id, active.last_moving_at, active.end_latitude, active.end_longitude);
active = undefined;
}
if (!active && !moving) return { tripId: null, active: false };
if (!active) {
const result = this.database.prepare(
`INSERT INTO trips
(device_id, started_at, last_moving_at, start_latitude, start_longitude,
end_latitude, end_longitude, peak_speed_kph, peak_speed_at,
peak_speed_latitude, peak_speed_longitude, vehicle_battery_start,
vehicle_battery_end, vehicle_consumption_start_kwh, vehicle_consumption_end_kwh,
phone_battery_start, phone_battery_end)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
sample.deviceId, capturedAt, capturedAt, sample.latitude, sample.longitude,
sample.latitude, sample.longitude, speedKph, capturedAt, sample.latitude, sample.longitude,
finite(car?.battery_percent), finite(car?.battery_percent), finite(car?.total_consumption_kwh),
finite(car?.total_consumption_kwh), finite(sample.phoneBatteryPercent), finite(sample.phoneBatteryPercent)
);
active = {
id: Number(result.lastInsertRowid),
started_at: capturedAt,
last_moving_at: capturedAt,
end_latitude: sample.latitude,
end_longitude: sample.longitude
};
}
const previous = this.database.prepare(
`SELECT captured_at, latitude, longitude, speed_kph FROM trip_points
WHERE trip_id = ? ORDER BY captured_at DESC LIMIT 1`
).get(active.id) as PreviousPoint | undefined;
const elapsedSeconds = previous ? Math.max(0, (capturedAt - previous.captured_at) / 1_000) : 0;
const distanceKm = previous && elapsedSeconds <= GAP_TIMEOUT_MS / 1_000
? haversineKm(previous, sample)
: 0;
const plausibleDistanceKm = Math.max(0.25, elapsedSeconds / 3_600 * 250);
const segmentMoving = moving || (previous?.speed_kph ?? 0) >= START_SPEED_KPH;
const acceptedDistanceKm = segmentMoving && distanceKm <= plausibleDistanceKm ? distanceKm : 0;
const movingSeconds = segmentMoving && previous ? Math.min(elapsedSeconds, 30) : 0;
const transaction = this.database.transaction(() => {
const inserted = this.database.prepare(
`INSERT OR IGNORE INTO trip_points
(trip_id, captured_at, latitude, longitude, accuracy_meters, altitude_meters,
speed_kph, bearing_degrees, vehicle_battery_percent)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
active!.id, capturedAt, sample.latitude, sample.longitude, sample.accuracyMeters,
sample.altitudeMeters ?? null, speedKph, sample.bearingDegrees ?? null,
finite(car?.battery_percent)
);
if (inserted.changes === 0) return;
this.database.prepare(
`UPDATE trips SET
last_moving_at = CASE WHEN ? THEN ? ELSE last_moving_at END,
end_latitude = CASE WHEN ? THEN ? ELSE end_latitude END,
end_longitude = CASE WHEN ? THEN ? ELSE end_longitude END,
distance_km = distance_km + ?, moving_seconds = moving_seconds + ?,
point_count = point_count + 1,
speed_sum = speed_sum + ?, speed_count = speed_count + ?,
peak_speed_at = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_at END,
peak_speed_latitude = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_latitude END,
peak_speed_longitude = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_longitude END,
peak_speed_kph = CASE WHEN ? IS NOT NULL AND (peak_speed_kph IS NULL OR ? > peak_speed_kph) THEN ? ELSE peak_speed_kph END,
vehicle_battery_end = COALESCE(?, vehicle_battery_end),
vehicle_consumption_end_kwh = COALESCE(?, vehicle_consumption_end_kwh),
phone_battery_end = COALESCE(?, phone_battery_end)
WHERE id = ?`
).run(
moving ? 1 : 0, capturedAt, moving ? 1 : 0, sample.latitude, moving ? 1 : 0, sample.longitude,
acceptedDistanceKm, movingSeconds, moving && speedKph !== null ? speedKph : 0, moving && speedKph !== null ? 1 : 0,
speedKph, speedKph, capturedAt,
speedKph, speedKph, sample.latitude,
speedKph, speedKph, sample.longitude,
speedKph, speedKph, speedKph,
finite(car?.battery_percent), finite(car?.total_consumption_kwh), finite(sample.phoneBatteryPercent), active!.id
);
});
transaction();
if (!moving && capturedAt - active.last_moving_at >= STOP_TIMEOUT_MS) {
this.finish(active.id, active.last_moving_at, active.end_latitude, active.end_longitude);
return { tripId: active.id, active: false };
}
return { tripId: active.id, active: true };
}
list() {
this.finalizeStale();
const rows = this.database.prepare(
`SELECT trips.*, mobile_devices.name AS device_name FROM trips
JOIN mobile_devices ON mobile_devices.id = trips.device_id
ORDER BY started_at DESC`
).all() as Record<string, unknown>[];
return { trips: rows.map((row) => this.summary(row)) };
}
detail(id: number) {
this.finalizeStale();
const row = this.database.prepare(
`SELECT trips.*, mobile_devices.name AS device_name FROM trips
JOIN mobile_devices ON mobile_devices.id = trips.device_id WHERE trips.id = ?`
).get(id) as Record<string, unknown> | undefined;
if (!row) return null;
const endedAt = finite(row.ended_at);
const points = this.database.prepare(
`SELECT captured_at AS capturedAt, latitude, longitude, accuracy_meters AS accuracyMeters,
altitude_meters AS altitudeMeters, speed_kph AS speedKph, bearing_degrees AS bearingDegrees,
vehicle_battery_percent AS vehicleBatteryPercent
FROM trip_points WHERE trip_id = ? AND captured_at <= ? ORDER BY captured_at`
).all(id, endedAt ?? Date.now());
return {
...this.summary(row),
points,
pins: [
{ type: "start", latitude: Number(row.start_latitude), longitude: Number(row.start_longitude), capturedAt: Number(row.started_at) },
finite(row.peak_speed_latitude) === null ? null : {
type: "peak-speed", latitude: Number(row.peak_speed_latitude), longitude: Number(row.peak_speed_longitude),
capturedAt: finite(row.peak_speed_at), speedKph: finite(row.peak_speed_kph)
},
{ type: "end", latitude: Number(row.end_latitude), longitude: Number(row.end_longitude), capturedAt: endedAt }
].filter(Boolean)
};
}
private activeTrip(deviceId: string): ActiveTrip | undefined {
return this.database.prepare(
`SELECT id, started_at, last_moving_at, end_latitude, end_longitude
FROM trips WHERE device_id = ? AND ended_at IS NULL`
).get(deviceId) as ActiveTrip | undefined;
}
private finish(id: number, endedAt: number, latitude: number, longitude: number): void {
this.database.transaction(() => {
this.database.prepare("DELETE FROM trip_points WHERE trip_id = ? AND captured_at > ?").run(id, endedAt);
this.database.prepare(
`UPDATE trips SET ended_at = ?, end_latitude = ?, end_longitude = ?,
point_count = (SELECT COUNT(*) FROM trip_points WHERE trip_id = ?)
WHERE id = ? AND ended_at IS NULL`
).run(endedAt, latitude, longitude, id, id);
})();
}
private finalizeStale(): void {
const cutoff = Date.now() - STOP_TIMEOUT_MS;
const stale = this.database.prepare(
`SELECT id, last_moving_at, end_latitude, end_longitude FROM trips
WHERE ended_at IS NULL AND last_moving_at < ?`
).all(cutoff) as Array<{ id: number; last_moving_at: number; end_latitude: number; end_longitude: number }>;
for (const trip of stale) this.finish(trip.id, trip.last_moving_at, trip.end_latitude, trip.end_longitude);
}
private summary(row: Record<string, unknown>) {
const startedAt = Number(row.started_at);
const endedAt = finite(row.ended_at);
const vehicleStart = finite(row.vehicle_battery_start);
const vehicleEnd = finite(row.vehicle_battery_end);
const consumptionStart = finite(row.vehicle_consumption_start_kwh);
const consumptionEnd = finite(row.vehicle_consumption_end_kwh);
const speedCount = Number(row.speed_count ?? 0);
return {
id: Number(row.id),
deviceId: String(row.device_id),
deviceName: String(row.device_name),
startedAt,
endedAt,
active: endedAt === null,
durationSeconds: Math.max(0, ((endedAt ?? Date.now()) - startedAt) / 1_000),
movingSeconds: Number(row.moving_seconds ?? 0),
distanceKm: Number(row.distance_km ?? 0),
pointCount: Number(row.point_count ?? 0),
averageSpeedKph: speedCount > 0 ? Number(row.speed_sum) / speedCount : null,
peakSpeedKph: finite(row.peak_speed_kph),
vehicleBatteryStartPercent: vehicleStart,
vehicleBatteryEndPercent: vehicleEnd,
batteryConsumedPercent: vehicleStart !== null && vehicleEnd !== null ? Math.max(0, vehicleStart - vehicleEnd) : null,
energyConsumedKwh: consumptionStart !== null && consumptionEnd !== null
? Math.max(0, consumptionEnd - consumptionStart)
: null,
phoneBatteryStartPercent: finite(row.phone_battery_start),
phoneBatteryEndPercent: finite(row.phone_battery_end),
start: { latitude: Number(row.start_latitude), longitude: Number(row.start_longitude) },
end: { latitude: Number(row.end_latitude), longitude: Number(row.end_longitude) }
};
}
}
+12
View File
@@ -357,6 +357,7 @@ describe("authentication boundary", () => {
expect((await app.inject({ method: "GET", url: "/api/mobile/devices" })).statusCode).toBe(401); expect((await app.inject({ method: "GET", url: "/api/mobile/devices" })).statusCode).toBe(401);
expect((await app.inject({ method: "GET", url: "/api/mobile/bluetooth" })).statusCode).toBe(401); expect((await app.inject({ method: "GET", url: "/api/mobile/bluetooth" })).statusCode).toBe(401);
expect((await app.inject({ method: "GET", url: "/api/trips" })).statusCode).toBe(401);
const bluetooth = await app.inject({ method: "GET", url: "/api/mobile/bluetooth", headers: { cookie } }); const bluetooth = await app.inject({ method: "GET", url: "/api/mobile/bluetooth", headers: { cookie } });
expect(bluetooth.statusCode).toBe(200); expect(bluetooth.statusCode).toBe(200);
expect(bluetooth.json()).toMatchObject({ powered: expect.any(Boolean), discoverable: expect.any(Boolean) }); expect(bluetooth.json()).toMatchObject({ powered: expect.any(Boolean), discoverable: expect.any(Boolean) });
@@ -407,6 +408,17 @@ describe("authentication boundary", () => {
shortcuts: [] shortcuts: []
}); });
const trips = await app.inject({ method: "GET", url: "/api/trips", headers: { cookie } });
expect(trips.statusCode).toBe(200);
const trip = trips.json<{ trips: Array<{ id: number; peakSpeedKph: number; pointCount: number }> }>().trips[0];
expect(trip).toMatchObject({ peakSpeedKph: 42.5, pointCount: 1 });
const tripDetail = await app.inject({ method: "GET", url: `/api/trips/${trip!.id}`, headers: { cookie } });
expect(tripDetail.statusCode).toBe(200);
expect(tripDetail.json()).toMatchObject({
points: [expect.objectContaining({ latitude: 55.6761, longitude: 12.5683 })],
pins: expect.arrayContaining([expect.objectContaining({ type: "peak-speed", speedKph: 42.5 })])
});
const devices = await app.inject({ method: "GET", url: "/api/mobile/devices", headers: { cookie } }); const devices = await app.inject({ method: "GET", url: "/api/mobile/devices", headers: { cookie } });
expect(devices.json()).toMatchObject({ devices: [{ id: deviceId, name: "Owner phone", revokedAt: null }] }); expect(devices.json()).toMatchObject({ devices: [{ id: deviceId, name: "Owner phone", revokedAt: null }] });
expect((await app.inject({ method: "DELETE", url: `/api/mobile/devices/${deviceId}`, headers: webHeaders })).statusCode).toBe(204); expect((await app.inject({ method: "DELETE", url: `/api/mobile/devices/${deviceId}`, headers: webHeaders })).statusCode).toBe(204);
+85
View File
@@ -0,0 +1,85 @@
import { afterEach, describe, expect, it } from "vitest";
import { openDatabase, type CompanionDatabase } from "../src/database.js";
import { TripStore } from "../src/trips.js";
let database: CompanionDatabase | null = null;
function setup() {
database = openDatabase(":memory:");
database.prepare(
`INSERT INTO mobile_devices (id, name, token_hash, created_at, last_seen_at)
VALUES ('phone', 'Test phone', 'token', '2026-08-01T10:00:00.000Z', '2026-08-01T10:00:00.000Z')`
).run();
return new TripStore(database);
}
function location(capturedAt: string, longitude: number, speedKph: number) {
return {
deviceId: "phone",
capturedAt,
latitude: 55.6761,
longitude,
accuracyMeters: 4,
speedKph,
phoneBatteryPercent: 80
};
}
afterEach(() => {
database?.close();
database = null;
});
describe("trip recording", () => {
it("starts on movement, records a route, and ends after stationary timeout", () => {
const trips = setup();
expect(trips.record(location("2026-08-01T10:00:00.000Z", 12.5683, 0))).toEqual({ tripId: null, active: false });
const started = trips.record(location("2026-08-01T10:00:10.000Z", 12.5684, 20));
expect(started.active).toBe(true);
trips.record(location("2026-08-01T10:01:10.000Z", 12.5784, 45));
trips.record(location("2026-08-01T10:04:11.000Z", 12.5784, 0));
const list = trips.list().trips;
expect(list).toHaveLength(1);
expect(list[0]).toMatchObject({ active: false, pointCount: 2, peakSpeedKph: 45 });
expect(list[0]!.distanceKm).toBeGreaterThan(0.5);
const detail = trips.detail(started.tripId!);
expect(detail?.points).toHaveLength(2);
expect(detail?.pins).toEqual(expect.arrayContaining([
expect.objectContaining({ type: "start" }),
expect.objectContaining({ type: "peak-speed", speedKph: 45 }),
expect.objectContaining({ type: "end" })
]));
});
it("uses nearby vehicle telemetry for speed and battery statistics", () => {
const trips = setup();
const first = Date.parse("2026-08-01T12:00:00.000Z");
database!.prepare(
`INSERT INTO car_telemetry_samples
(recorded_at, speed_kph, battery_percent, total_consumption_kwh)
VALUES (?, 60, 78, 12.4), (?, 40, 74, 14.1)`
).run(first, first + 60_000);
const started = trips.record(location("2026-08-01T12:00:00.000Z", 12.5683, 0));
trips.record(location("2026-08-01T12:01:00.000Z", 12.5783, 0));
const detail = trips.detail(started.tripId!);
expect(detail).toMatchObject({
peakSpeedKph: 60,
vehicleBatteryStartPercent: 78,
vehicleBatteryEndPercent: 74,
batteryConsumedPercent: 4,
energyConsumedKwh: expect.closeTo(1.7, 5)
});
});
it("does not create trips from inaccurate or stationary locations", () => {
const trips = setup();
trips.record({ ...location("2026-08-01T13:00:00.000Z", 12.5, 80), accuracyMeters: 250 });
trips.record(location("2026-08-01T13:01:00.000Z", 12.5, 2));
expect(trips.list().trips).toEqual([]);
});
});
+2
View File
@@ -14,10 +14,12 @@
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"leaflet": "^1.9.4",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1" "react-dom": "^19.1.1"
}, },
"devDependencies": { "devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.7.0",
+13 -6
View File
@@ -13,6 +13,7 @@ import {
GitBranch, GitBranch,
DownloadSimple, DownloadSimple,
Power, Power,
Path,
SignOut, SignOut,
TerminalWindow, TerminalWindow,
Trash, Trash,
@@ -46,10 +47,15 @@ const ShellTerminal = lazy(async () => {
return { default: module.ShellTerminal }; return { default: module.ShellTerminal };
}); });
type Screen = "loading" | "setup" | "login" | "dashboard" | "fatal"; const TripsPage = lazy(async () => {
type DashboardPage = "home" | "car" | "adb" | "network" | "settings"; const module = await import("./TripsPage");
return { default: module.TripsPage };
});
const dashboardPages = new Set<DashboardPage>(["home", "car", "adb", "network", "settings"]); type Screen = "loading" | "setup" | "login" | "dashboard" | "fatal";
type DashboardPage = "home" | "car" | "trips" | "adb" | "network" | "settings";
const dashboardPages = new Set<DashboardPage>(["home", "car", "trips", "adb", "network", "settings"]);
function pageFromLocation(): DashboardPage { function pageFromLocation(): DashboardPage {
const page = window.location.hash.replace(/^#\/?/, ""); const page = window.location.hash.replace(/^#\/?/, "");
@@ -233,6 +239,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
<nav aria-label="Primary navigation"> <nav aria-label="Primary navigation">
<button className={`nav-item ${page === "home" ? "active" : ""}`} onClick={() => navigate("home")}><House size={24} weight={page === "home" ? "fill" : "regular"} /><span>Home</span></button> <button className={`nav-item ${page === "home" ? "active" : ""}`} onClick={() => navigate("home")}><House size={24} weight={page === "home" ? "fill" : "regular"} /><span>Home</span></button>
<button className={`nav-item ${page === "car" ? "active" : ""}`} onClick={() => navigate("car")}><CarProfile size={24} weight={page === "car" ? "fill" : "regular"} /><span>Car</span></button> <button className={`nav-item ${page === "car" ? "active" : ""}`} onClick={() => navigate("car")}><CarProfile size={24} weight={page === "car" ? "fill" : "regular"} /><span>Car</span></button>
<button className={`nav-item ${page === "trips" ? "active" : ""}`} onClick={() => navigate("trips")}><Path size={24} weight={page === "trips" ? "fill" : "regular"} /><span>Trips</span></button>
<button className={`nav-item ${page === "adb" ? "active" : ""}`} onClick={() => navigate("adb")}><AndroidLogo size={24} weight={page === "adb" ? "fill" : "regular"} /><span>ADB</span></button> <button className={`nav-item ${page === "adb" ? "active" : ""}`} onClick={() => navigate("adb")}><AndroidLogo size={24} weight={page === "adb" ? "fill" : "regular"} /><span>ADB</span></button>
<button className={`nav-item ${page === "network" ? "active" : ""}`} onClick={() => navigate("network")}><WifiHigh size={24} weight={page === "network" ? "fill" : "regular"} /><span>Network</span></button> <button className={`nav-item ${page === "network" ? "active" : ""}`} onClick={() => navigate("network")}><WifiHigh size={24} weight={page === "network" ? "fill" : "regular"} /><span>Network</span></button>
<button className={`nav-item ${page === "settings" ? "active" : ""}`} onClick={() => navigate("settings")}><Gear size={24} weight={page === "settings" ? "fill" : "regular"} /><span>Settings</span></button> <button className={`nav-item ${page === "settings" ? "active" : ""}`} onClick={() => navigate("settings")}><Gear size={24} weight={page === "settings" ? "fill" : "regular"} /><span>Settings</span></button>
@@ -242,8 +249,8 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
<main className="dashboard"> <main className="dashboard">
<header className="dashboard-header"> <header className="dashboard-header">
<div> <div>
<p className="eyebrow">{page === "home" ? "System overview" : page === "car" ? "Live vehicle signals" : page === "adb" ? "Android device bridge" : page === "network" ? "Live diagnostics" : "Companion settings"}</p> <p className="eyebrow">{page === "home" ? "System overview" : page === "car" ? "Live vehicle signals" : page === "trips" ? "Drive history" : page === "adb" ? "Android device bridge" : page === "network" ? "Live diagnostics" : "Companion settings"}</p>
<h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : page === "car" ? "Car" : page === "adb" ? "ADB" : page === "network" ? "Network activity" : "Settings"}</h1> <h1>{page === "home" ? status?.hostname ?? "Your Raspberry Pi" : page === "car" ? "Car" : page === "trips" ? "Trips" : page === "adb" ? "ADB" : page === "network" ? "Network activity" : "Settings"}</h1>
</div> </div>
<div className="header-actions"> <div className="header-actions">
<div className={`connection-state ${connection}`}> <div className={`connection-state ${connection}`}>
@@ -258,7 +265,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>
{page === "home" ? <> {page === "home" ? <>
{error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>} {error && <div className="status-error" role="alert"><WarningCircle size={22} />{error}<button onClick={() => void refresh()}>Try again</button></div>}
{!status ? <DashboardSkeleton /> : <StatusContent status={status} />} {!status ? <DashboardSkeleton /> : <StatusContent status={status} />}
</> : page === "car" ? <CarPage csrfToken={auth.csrfToken} /> : page === "adb" ? <AdbPage csrfToken={auth.csrfToken} /> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />} </> : page === "car" ? <CarPage csrfToken={auth.csrfToken} /> : page === "trips" ? <Suspense fallback={<div className="trips-skeleton"><div /><div /><div /></div>}><TripsPage /></Suspense> : page === "adb" ? <AdbPage csrfToken={auth.csrfToken} /> : page === "network" ? <NetworkActivityPage /> : <SettingsPage csrfToken={auth.csrfToken} />}
</main> </main>
</div> </div>
); );
+198
View File
@@ -0,0 +1,198 @@
import { useEffect, useRef, useState } from "react";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import {
ArrowClockwise,
BatteryMedium,
Clock,
FlagCheckered,
MapPin,
NavigationArrow,
Path,
Speedometer
} from "@phosphor-icons/react";
import { getTrip, getTrips, type TripDetail, type TripSummary } from "./api";
function formatDistance(value: number): string {
return value < 1 ? `${Math.round(value * 1_000)} m` : `${value.toFixed(1)} km`;
}
function formatDuration(seconds: number): string {
const minutes = Math.max(0, Math.round(seconds / 60));
const hours = Math.floor(minutes / 60);
return hours ? `${hours}h ${minutes % 60}m` : `${minutes} min`;
}
function formatDate(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, {
weekday: "short",
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit"
}).format(timestamp);
}
function value(value_: number | null, suffix: string, digits = 0): string {
return value_ === null ? "Unavailable" : `${value_.toFixed(digits)}${suffix}`;
}
export function TripsPage() {
const [trips, setTrips] = useState<TripSummary[] | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [detail, setDetail] = useState<TripDetail | null>(null);
const [error, setError] = useState("");
const [detailError, setDetailError] = useState("");
const [refreshing, setRefreshing] = useState(false);
async function refresh() {
setRefreshing(true);
try {
const nextTrips = await getTrips();
setTrips(nextTrips);
setError("");
if (selectedId !== null && !nextTrips.some((trip) => trip.id === selectedId)) {
setSelectedId(null);
setDetail(null);
}
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Trips could not be loaded");
} finally {
setRefreshing(false);
}
}
useEffect(() => { void refresh(); }, []);
useEffect(() => {
if (selectedId === null) return;
setDetail(null);
setDetailError("");
void getTrip(selectedId)
.then(setDetail)
.catch((caught) => setDetailError(caught instanceof Error ? caught.message : "Trip details could not be loaded"));
}, [selectedId]);
return (
<section className="trips-page">
<div className="trips-toolbar">
<div>
<strong>{trips?.length ?? 0} recorded trips</strong>
<span>Trips start above 5 km/h and close after three stationary minutes.</span>
</div>
<button className="secondary-button trips-refresh" onClick={() => void refresh()} disabled={refreshing}>
<ArrowClockwise size={21} className={refreshing ? "spin" : ""} /> Refresh
</button>
</div>
{error && <div className="status-error" role="alert">{error}<button onClick={() => void refresh()}>Try again</button></div>}
{trips === null && !error ? <TripsSkeleton /> : trips?.length === 0 ? <EmptyTrips /> : (
<div className="trips-workspace">
<div className="trip-list" aria-label="Recorded trips">
{trips?.map((trip) => (
<button
key={trip.id}
className={`trip-row ${selectedId === trip.id ? "selected" : ""}`}
onClick={() => setSelectedId(trip.id)}
aria-pressed={selectedId === trip.id}
>
<span className="trip-row-route"><NavigationArrow size={20} weight="fill" /></span>
<span className="trip-row-main">
<strong>{formatDate(trip.startedAt)}</strong>
<small>{trip.active ? "Recording now" : `${formatDuration(trip.durationSeconds)} · ${trip.deviceName}`}</small>
</span>
<span className="trip-row-distance">{formatDistance(trip.distanceKm)}</span>
<span className="trip-row-speed">{value(trip.peakSpeedKph, " km/h")}</span>
</button>
))}
</div>
<div className="trip-detail">
{detailError ? <div className="trip-detail-state" role="alert"><MapPin size={34} />{detailError}</div>
: selectedId === null ? <div className="trip-detail-state"><Path size={42} /><strong>Select a trip</strong><span>Open a drive to inspect its route, statistics, and notable points.</span></div>
: detail === null ? <TripDetailSkeleton /> : <TripReport trip={detail} />}
</div>
</div>
)}
</section>
);
}
function TripReport({ trip }: { trip: TripDetail }) {
return (
<article className="trip-report">
<div className="trip-report-heading">
<div>
<span>{trip.active ? "Live trip" : "Trip report"}</span>
<h2>{formatDate(trip.startedAt)}</h2>
<p>{formatDuration(trip.durationSeconds)} recorded by {trip.deviceName}</p>
</div>
{trip.active && <span className="trip-live"><i /> Recording</span>}
</div>
<TripMap trip={trip} />
<div className="trip-stat-grid">
<TripStat icon={<Path size={22} />} label="Distance" value={formatDistance(trip.distanceKm)} />
<TripStat icon={<Speedometer size={22} />} label="Peak speed" value={value(trip.peakSpeedKph, " km/h")} />
<TripStat icon={<NavigationArrow size={22} />} label="Average speed" value={value(trip.averageSpeedKph, " km/h")} />
<TripStat icon={<Clock size={22} />} label="Duration" value={formatDuration(trip.durationSeconds)} />
<TripStat icon={<BatteryMedium size={22} />} label="Vehicle battery used" value={value(trip.batteryConsumedPercent, "%", 1)} />
<TripStat icon={<BatteryMedium size={22} />} label="Energy used" value={value(trip.energyConsumedKwh, " kWh", 2)} />
</div>
<div className="trip-report-footer">
<span><MapPin size={18} /> {trip.pointCount.toLocaleString()} GPS samples</span>
<span><FlagCheckered size={18} /> {trip.endedAt ? `Ended ${formatDate(trip.endedAt)}` : "Trip in progress"}</span>
</div>
</article>
);
}
function TripStat({ icon, label, value: displayValue }: { icon: React.ReactNode; label: string; value: string }) {
return <div className="trip-stat"><span>{icon}{label}</span><strong>{displayValue}</strong></div>;
}
function TripMap({ trip }: { trip: TripDetail }) {
const host = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!host.current || trip.points.length === 0) return;
const map = L.map(host.current, { zoomControl: true, attributionControl: true });
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
const coordinates = trip.points.map((point) => L.latLng(point.latitude, point.longitude));
L.polyline(coordinates, { color: "#0b1005", weight: 9, opacity: 0.72, lineCap: "round" }).addTo(map);
L.polyline(coordinates, { color: "#b9e769", weight: 5, opacity: 0.95, lineCap: "round" }).addTo(map);
const bounds = L.latLngBounds(coordinates);
if (bounds.isValid() && coordinates.length > 1) map.fitBounds(bounds, { padding: [34, 34], maxZoom: 16 });
else map.setView(coordinates[0]!, 16);
for (const pin of trip.pins) {
const peak = pin.type === "peak-speed";
const marker = L.circleMarker([pin.latitude, pin.longitude], {
radius: peak ? 9 : 7,
color: peak ? "#10130f" : "#f0f2eb",
weight: 3,
fillColor: peak ? "#b9e769" : pin.type === "start" ? "#f0f2eb" : "#8f9a88",
fillOpacity: 1
}).addTo(map);
marker.bindTooltip(peak ? `Top speed · ${value(pin.speedKph ?? null, " km/h")}` : pin.type === "start" ? "Trip start" : "Trip end");
}
return () => { map.remove(); };
}, [trip]);
if (trip.points.length === 0) return <div className="trip-map-empty"><MapPin size={34} />No route points were retained for this trip.</div>;
return <div className="trip-map" ref={host} aria-label="Map showing the trip route" />;
}
function EmptyTrips() {
return <div className="trips-empty"><Path size={46} /><h2>No trips recorded yet</h2><p>Pair the Android companion and leave its foreground service running. Your first drive will appear here automatically.</p></div>;
}
function TripsSkeleton() {
return <div className="trips-skeleton" aria-label="Loading trips"><div /><div /><div /></div>;
}
function TripDetailSkeleton() {
return <div className="trip-detail-skeleton" aria-label="Loading trip report"><div /><div /><div /></div>;
}
+55
View File
@@ -130,6 +130,50 @@ export type BluetoothStatus = {
message: string; message: string;
}; };
export type TripSummary = {
id: number;
deviceId: string;
deviceName: string;
startedAt: number;
endedAt: number | null;
active: boolean;
durationSeconds: number;
movingSeconds: number;
distanceKm: number;
pointCount: number;
averageSpeedKph: number | null;
peakSpeedKph: number | null;
vehicleBatteryStartPercent: number | null;
vehicleBatteryEndPercent: number | null;
batteryConsumedPercent: number | null;
energyConsumedKwh: number | null;
phoneBatteryStartPercent: number | null;
phoneBatteryEndPercent: number | null;
start: { latitude: number; longitude: number };
end: { latitude: number; longitude: number };
};
export type TripPoint = {
capturedAt: number;
latitude: number;
longitude: number;
accuracyMeters: number;
altitudeMeters: number | null;
speedKph: number | null;
bearingDegrees: number | null;
vehicleBatteryPercent: number | null;
};
export type TripPin = {
type: "start" | "end" | "peak-speed";
latitude: number;
longitude: number;
capturedAt: number | null;
speedKph?: number | null;
};
export type TripDetail = TripSummary & { points: TripPoint[]; pins: TripPin[] };
export type CarTelemetry = { export type CarTelemetry = {
available: true; available: true;
collectedAt: string; collectedAt: string;
@@ -274,6 +318,17 @@ export async function getBluetoothStatus(): Promise<BluetoothStatus> {
return parseResponse<BluetoothStatus>(await fetch("/api/mobile/bluetooth", { credentials: "same-origin" })); return parseResponse<BluetoothStatus>(await fetch("/api/mobile/bluetooth", { credentials: "same-origin" }));
} }
export async function getTrips(): Promise<TripSummary[]> {
const response = await parseResponse<{ trips: TripSummary[] }>(
await fetch("/api/trips", { credentials: "same-origin" })
);
return response.trips;
}
export async function getTrip(id: number): Promise<TripDetail> {
return parseResponse<TripDetail>(await fetch(`/api/trips/${id}`, { credentials: "same-origin" }));
}
export async function revokeMobileDevice(id: string, csrfToken: string): Promise<void> { export async function revokeMobileDevice(id: string, csrfToken: string): Promise<void> {
await parseResponse(await fetch(`/api/mobile/devices/${encodeURIComponent(id)}`, { await parseResponse(await fetch(`/api/mobile/devices/${encodeURIComponent(id)}`, {
method: "DELETE", method: "DELETE",
+70
View File
@@ -408,6 +408,63 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.clear-statistics-options .danger-button small { color: #e7b8b1; } .clear-statistics-options .danger-button small { color: #e7b8b1; }
.status-success { display: flex; align-items: center; min-height: 52px; padding: 12px 16px; color: var(--accent); background: rgba(185, 231, 105, 0.08); border: 1px solid rgba(185, 231, 105, 0.24); border-radius: 10px; } .status-success { display: flex; align-items: center; min-height: 52px; padding: 12px 16px; color: var(--accent); background: rgba(185, 231, 105, 0.08); border: 1px solid rgba(185, 231, 105, 0.24); border-radius: 10px; }
.trips-page { min-width: 0; display: grid; gap: 18px; }
.trips-toolbar { min-height: 76px; display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 14px 18px 14px 22px; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.trips-toolbar > div { min-width: 0; display: grid; gap: 5px; }
.trips-toolbar strong { font-size: 0.95rem; }
.trips-toolbar span { color: var(--muted); font-size: 0.76rem; line-height: 1.4; }
.trips-refresh { min-height: 46px; padding: 0 17px; }
.trips-workspace { min-width: 0; display: grid; grid-template-columns: minmax(300px, 0.72fr) minmax(510px, 1.28fr); gap: 18px; align-items: start; }
.trip-list { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.trip-row { width: 100%; min-width: 0; min-height: 84px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 5px 13px; padding: 13px 16px; color: var(--text); background: transparent; border: 0; border-bottom: 1px solid var(--line); text-align: left; cursor: pointer; transition: background 140ms ease; }
.trip-row:last-child { border-bottom: 0; }
.trip-row:hover { background: var(--surface-raised); }
.trip-row.selected { background: rgba(185, 231, 105, 0.09); box-shadow: inset 3px 0 var(--accent); }
.trip-row-route { grid-row: 1 / 3; width: 38px; height: 38px; display: grid; place-items: center; color: var(--accent); background: rgba(185, 231, 105, 0.08); border-radius: 9px; }
.trip-row-main { min-width: 0; display: grid; gap: 5px; }
.trip-row-main strong, .trip-row-main small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trip-row-main strong { font-size: 0.84rem; }
.trip-row-main small { color: var(--muted); font-size: 0.7rem; }
.trip-row-distance { justify-self: end; font: 750 0.88rem/1 ui-monospace, "Cascadia Code", monospace; }
.trip-row-speed { grid-column: 3; justify-self: end; color: var(--muted); font: 0.67rem/1 ui-monospace, "Cascadia Code", monospace; }
.trip-detail { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.trip-detail-state { min-height: 540px; display: grid; place-content: center; justify-items: center; gap: 12px; padding: 32px; color: var(--muted); text-align: center; }
.trip-detail-state svg { color: var(--accent); }
.trip-detail-state strong { color: var(--text); font-size: 1.15rem; }
.trip-detail-state span { max-width: 370px; font-size: 0.8rem; line-height: 1.5; }
.trip-report-heading { min-height: 106px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 20px 23px; }
.trip-report-heading > div > span { color: var(--accent); font: 700 0.67rem/1.2 ui-monospace, "Cascadia Code", monospace; letter-spacing: 0.1em; text-transform: uppercase; }
.trip-report-heading h2 { margin: 7px 0 5px; font-size: 1.3rem; }
.trip-report-heading p { margin: 0; color: var(--muted); font-size: 0.76rem; }
.trip-live { display: flex; align-items: center; gap: 8px; color: var(--accent); font-size: 0.75rem; font-weight: 750; }
.trip-live i { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 5px rgba(185, 231, 105, 0.1); }
.trip-map { position: relative; z-index: 0; width: 100%; height: clamp(340px, 46dvh, 500px); background: #222820; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.trip-map .leaflet-tile-pane { filter: saturate(0.45) brightness(0.72) contrast(1.08); }
.trip-map .leaflet-control-zoom a { color: var(--text); background: #171b16; border-color: var(--line); }
.trip-map .leaflet-control-zoom a:hover { background: #242a22; }
.trip-map .leaflet-control-attribution { color: #cbd3c5; background: rgba(16, 19, 15, 0.86); }
.trip-map .leaflet-control-attribution a { color: var(--accent); }
.trip-map .leaflet-tooltip { color: var(--text); background: #171b16; border: 1px solid #465043; box-shadow: none; font-weight: 700; }
.trip-map .leaflet-tooltip::before { border-top-color: #465043; }
.trip-map-empty { min-height: 340px; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--muted); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.trip-map-empty svg { color: var(--accent); }
.trip-stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; background: var(--line); }
.trip-stat { min-width: 0; min-height: 106px; display: grid; align-content: center; gap: 12px; padding: 17px 19px; background: var(--surface); }
.trip-stat span { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.72rem; }
.trip-stat svg { flex: none; color: var(--accent); }
.trip-stat strong { overflow: hidden; font: 750 1.16rem/1 ui-monospace, "Cascadia Code", monospace; text-overflow: ellipsis; white-space: nowrap; }
.trip-report-footer { min-height: 58px; display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 20px; color: var(--muted); font-size: 0.7rem; }
.trip-report-footer span { display: flex; align-items: center; gap: 7px; }
.trip-report-footer svg { color: var(--accent); }
.trips-empty { min-height: 460px; display: grid; place-content: center; justify-items: center; padding: 36px; color: var(--muted); text-align: center; background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.trips-empty svg { margin-bottom: 19px; color: var(--accent); }
.trips-empty h2 { margin: 0 0 10px; color: var(--text); }
.trips-empty p { max-width: 500px; margin: 0; font-size: 0.84rem; line-height: 1.55; }
.trips-skeleton { min-height: 420px; display: grid; grid-template-columns: 0.72fr 1.28fr; gap: 18px; }
.trips-skeleton div, .trip-detail-skeleton div { border-radius: var(--radius); background: linear-gradient(100deg, var(--surface) 30%, var(--surface-raised) 50%, var(--surface) 70%); background-size: 220% 100%; animation: shimmer 1.4s ease infinite; }
.trips-skeleton div:first-child { grid-row: 1 / 3; }
.trip-detail-skeleton { min-height: 540px; display: grid; grid-template-rows: 90px 1fr 120px; gap: 1px; padding: 18px; }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.adb-command-grid { grid-template-columns: 1fr; } .adb-command-grid { grid-template-columns: 1fr; }
.drive-stage { grid-template-columns: 1fr 1.25fr; } .drive-stage { grid-template-columns: 1fr 1.25fr; }
@@ -417,6 +474,8 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.telemetry-source { grid-column: 1 / -1; } .telemetry-source { grid-column: 1 / -1; }
.record-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .record-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.chart-grid { grid-template-columns: 1fr; } .chart-grid { grid-template-columns: 1fr; }
.trips-workspace { grid-template-columns: minmax(280px, 0.7fr) minmax(440px, 1.3fr); }
.trip-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
@media (max-width: 767px) { @media (max-width: 767px) {
@@ -446,4 +505,15 @@ button:disabled { opacity: 0.55; cursor: not-allowed; }
.telemetry-storage { grid-template-columns: auto minmax(0, 1fr); } .telemetry-storage { grid-template-columns: auto minmax(0, 1fr); }
.telemetry-storage-summary { grid-column: 1 / -1; text-align: left; } .telemetry-storage-summary { grid-column: 1 / -1; text-align: left; }
.telemetry-clear-button { grid-column: 1 / -1; width: 100%; padding: 0 17px; font-size: inherit; } .telemetry-clear-button { grid-column: 1 / -1; width: 100%; padding: 0 17px; font-size: inherit; }
.trips-toolbar { align-items: flex-start; }
.trips-toolbar span { display: none; }
.trips-refresh { width: 46px; padding: 0; font-size: 0; }
.trips-workspace { grid-template-columns: 1fr; }
.trip-detail { min-height: 420px; }
.trip-detail-state { min-height: 420px; }
.trip-map { height: 52dvh; min-height: 330px; }
.trip-stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.trip-report-footer { align-items: flex-start; flex-direction: column; }
.trips-skeleton { grid-template-columns: 1fr; }
.trips-skeleton div:first-child { grid-row: auto; min-height: 260px; }
} }