Add GPS trip reports and route maps
This commit is contained in:
+1
-1
@@ -168,7 +168,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
|
||||
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'"
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+46
-14
@@ -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) => {
|
||||
|
||||
@@ -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) }
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user