From e501895e56b9d769e03caf506b2ec851b733bbe6 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 1 Aug 2026 00:56:19 +0200 Subject: [PATCH] Add GPS trip reports and route maps --- package-lock.json | 25 ++++ server/src/app.ts | 2 +- server/src/database.ts | 46 +++++++ server/src/mobile.ts | 60 +++++++-- server/src/trips.ts | 264 +++++++++++++++++++++++++++++++++++++ server/tests/app.test.ts | 12 ++ server/tests/trips.test.ts | 85 ++++++++++++ web/package.json | 2 + web/src/App.tsx | 19 ++- web/src/TripsPage.tsx | 198 ++++++++++++++++++++++++++++ web/src/api.ts | 55 ++++++++ web/src/styles.css | 70 ++++++++++ 12 files changed, 817 insertions(+), 21 deletions(-) create mode 100644 server/src/trips.ts create mode 100644 server/tests/trips.test.ts create mode 100644 web/src/TripsPage.tsx diff --git a/package-lock.json b/package-lock.json index 2cf833e..ede6bc7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1732,6 +1732,13 @@ "dev": true, "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": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1739,6 +1746,16 @@ "dev": true, "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": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -3616,6 +3633,12 @@ "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": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -5137,10 +5160,12 @@ "@phosphor-icons/react": "^2.1.10", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "leaflet": "^1.9.4", "react": "^19.1.1", "react-dom": "^19.1.1" }, "devDependencies": { + "@types/leaflet": "^1.9.21", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^4.7.0", diff --git a/server/src/app.ts b/server/src/app.ts index 1b5ecaf..6485240 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -168,7 +168,7 @@ export async function buildApp(config: AppConfig): Promise { reply.header("X-Frame-Options", "DENY"); reply.header( "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'" ); }); diff --git a/server/src/database.ts b/server/src/database.ts index 5cd45af..3d88f21 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -101,6 +101,52 @@ function migrate(database: CompanionDatabase): void { 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 ( recorded_at INTEGER PRIMARY KEY, speed_kph REAL, diff --git a/server/src/mobile.ts b/server/src/mobile.ts index afcbe02..8b4c1d2 100644 --- a/server/src/mobile.ts +++ b/server/src/mobile.ts @@ -8,6 +8,7 @@ import type { CarTelemetryRecorder } from "./car-telemetry.js"; import type { CompanionDatabase } from "./database.js"; import { hashToken, randomToken } from "./security.js"; import { collectSystemStatus } from "./status.js"; +import { TripStore } from "./trips.js"; const pairingSchema = z.object({ code: z.string().regex(/^\d{6}$/), @@ -71,6 +72,8 @@ export function registerMobileRoutes( carTelemetry: CarTelemetryRecorder, requireWebUser: (request: FastifyRequest, reply: FastifyReply) => boolean ): void { + const trips = new TripStore(database); + app.post("/api/mobile/pairing", async (request, reply) => { if (!requireWebUser(request, reply)) return; const code = String(randomInt(100_000, 1_000_000)); @@ -94,6 +97,22 @@ export function registerMobileRoutes( 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) => { if (!requireWebUser(request, reply)) return; return readBluetoothStatus(); @@ -170,20 +189,33 @@ export function registerMobileRoutes( 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" } }); const value = parsed.data; - database.prepare( - `INSERT INTO mobile_phone_state - (device_id, received_at, captured_at, latitude, longitude, accuracy_meters, altitude_meters, - speed_kph, bearing_degrees, battery_percent, network_type) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(device_id) DO UPDATE SET - received_at = excluded.received_at, captured_at = excluded.captured_at, - latitude = excluded.latitude, longitude = excluded.longitude, accuracy_meters = excluded.accuracy_meters, - altitude_meters = excluded.altitude_meters, speed_kph = excluded.speed_kph, - bearing_degrees = excluded.bearing_degrees, battery_percent = excluded.battery_percent, - network_type = excluded.network_type` - ).run(device.id, new Date().toISOString(), value.capturedAt, value.latitude, value.longitude, value.accuracyMeters, - value.altitudeMeters ?? null, value.speedKph ?? null, value.bearingDegrees ?? null, - value.batteryPercent ?? null, value.networkType ?? null); - return { accepted: true }; + const trip = database.transaction(() => { + database.prepare( + `INSERT INTO mobile_phone_state + (device_id, received_at, captured_at, latitude, longitude, accuracy_meters, altitude_meters, + speed_kph, bearing_degrees, battery_percent, network_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(device_id) DO UPDATE SET + received_at = excluded.received_at, captured_at = excluded.captured_at, + latitude = excluded.latitude, longitude = excluded.longitude, accuracy_meters = excluded.accuracy_meters, + altitude_meters = excluded.altitude_meters, speed_kph = excluded.speed_kph, + bearing_degrees = excluded.bearing_degrees, battery_percent = excluded.battery_percent, + network_type = excluded.network_type` + ).run(device.id, new Date().toISOString(), value.capturedAt, value.latitude, value.longitude, value.accuracyMeters, + value.altitudeMeters ?? null, value.speedKph ?? null, value.bearingDegrees ?? null, + value.batteryPercent ?? null, value.networkType ?? null); + 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) => { diff --git a/server/src/trips.ts b/server/src/trips.ts new file mode 100644 index 0000000..8a034a9 --- /dev/null +++ b/server/src/trips.ts @@ -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[]; + 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 | 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) { + 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) } + }; + } +} diff --git a/server/tests/app.test.ts b/server/tests/app.test.ts index 6a0d983..dd77e24 100644 --- a/server/tests/app.test.ts +++ b/server/tests/app.test.ts @@ -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/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 } }); expect(bluetooth.statusCode).toBe(200); expect(bluetooth.json()).toMatchObject({ powered: expect.any(Boolean), discoverable: expect.any(Boolean) }); @@ -407,6 +408,17 @@ describe("authentication boundary", () => { 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 } }); 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); diff --git a/server/tests/trips.test.ts b/server/tests/trips.test.ts new file mode 100644 index 0000000..a3cb999 --- /dev/null +++ b/server/tests/trips.test.ts @@ -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([]); + }); +}); diff --git a/web/package.json b/web/package.json index e9ab6e3..0fe1d17 100644 --- a/web/package.json +++ b/web/package.json @@ -14,10 +14,12 @@ "@phosphor-icons/react": "^2.1.10", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", + "leaflet": "^1.9.4", "react": "^19.1.1", "react-dom": "^19.1.1" }, "devDependencies": { + "@types/leaflet": "^1.9.21", "@types/react": "^19.1.10", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^4.7.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index 6a876ee..d5731ac 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -13,6 +13,7 @@ import { GitBranch, DownloadSimple, Power, + Path, SignOut, TerminalWindow, Trash, @@ -46,10 +47,15 @@ const ShellTerminal = lazy(async () => { return { default: module.ShellTerminal }; }); -type Screen = "loading" | "setup" | "login" | "dashboard" | "fatal"; -type DashboardPage = "home" | "car" | "adb" | "network" | "settings"; +const TripsPage = lazy(async () => { + const module = await import("./TripsPage"); + return { default: module.TripsPage }; +}); -const dashboardPages = new Set(["home", "car", "adb", "network", "settings"]); +type Screen = "loading" | "setup" | "login" | "dashboard" | "fatal"; +type DashboardPage = "home" | "car" | "trips" | "adb" | "network" | "settings"; + +const dashboardPages = new Set(["home", "car", "trips", "adb", "network", "settings"]); function pageFromLocation(): DashboardPage { const page = window.location.hash.replace(/^#\/?/, ""); @@ -233,6 +239,7 @@ function Dashboard({ auth, onLoggedOut }: { auth: AuthState; onLoggedOut: () =>