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
+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/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);
+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([]);
});
});