refactor: simplify GPS page, lean into Traccar for live map and trips

Remove ~2,300 lines of code that duplicated Traccar's native capabilities:
- Remove Leaflet live map, trip stats/playback, and OSRM route matching from frontend
- Delete osrm.service.ts entirely (415 lines)
- Remove 6 dead backend endpoints and unused service methods
- Clean up unused hooks and TypeScript types
- Keep device enrollment, QR codes, settings, and CommandCenter integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 19:53:57 +01:00
parent 14c6c9506f
commit 139cb4aebe
7 changed files with 52 additions and 2286 deletions

View File

@@ -109,7 +109,7 @@ export class GpsController {
} }
/** /**
* Get all active driver locations (Admin map view) * Get all active driver locations (used by CommandCenter)
*/ */
@Get('locations') @Get('locations')
@Roles(Role.ADMINISTRATOR) @Roles(Role.ADMINISTRATOR)
@@ -117,98 +117,6 @@ export class GpsController {
return this.gpsService.getActiveDriverLocations(); return this.gpsService.getActiveDriverLocations();
} }
/**
* Get a specific driver's location
*/
@Get('locations/:driverId')
@Roles(Role.ADMINISTRATOR)
async getDriverLocation(@Param('driverId') driverId: string) {
const location = await this.gpsService.getDriverLocation(driverId);
if (!location) {
throw new NotFoundException('Driver not found or not enrolled for GPS tracking');
}
return location;
}
/**
* Get a driver's location history (for route trail display)
* Query param 'matched=true' returns OSRM road-snapped route
*/
@Get('locations/:driverId/history')
@Roles(Role.ADMINISTRATOR)
async getDriverLocationHistory(
@Param('driverId') driverId: string,
@Query('from') fromStr?: string,
@Query('to') toStr?: string,
@Query('matched') matched?: string,
) {
const from = fromStr ? new Date(fromStr) : undefined;
const to = toStr ? new Date(toStr) : undefined;
// If matched=true, return OSRM road-matched route
if (matched === 'true') {
return this.gpsService.getMatchedRoute(driverId, from, to);
}
// Otherwise return raw GPS points
return this.gpsService.getDriverLocationHistory(driverId, from, to);
}
/**
* Get a driver's stats (Admin viewing any driver)
*/
@Get('stats/:driverId')
@Roles(Role.ADMINISTRATOR)
async getDriverStats(
@Param('driverId') driverId: string,
@Query('from') fromStr?: string,
@Query('to') toStr?: string,
) {
const from = fromStr ? new Date(fromStr) : undefined;
const to = toStr ? new Date(toStr) : undefined;
return this.gpsService.getDriverStats(driverId, from, to);
}
// ============================================
// Trip Management
// ============================================
/**
* Get trips for a driver (powered by Traccar)
*/
@Get('trips/:driverId')
@Roles(Role.ADMINISTRATOR)
async getDriverTrips(
@Param('driverId') driverId: string,
@Query('from') fromStr?: string,
@Query('to') toStr?: string,
) {
const from = fromStr ? new Date(fromStr) : undefined;
const to = toStr ? new Date(toStr) : undefined;
return this.gpsService.getDriverTrips(driverId, from, to);
}
/**
* Get active trip for a driver
*/
@Get('trips/:driverId/active')
@Roles(Role.ADMINISTRATOR)
async getActiveTrip(@Param('driverId') driverId: string) {
return this.gpsService.getActiveTrip(driverId);
}
/**
* Get a single trip with full detail (matchedRoute + rawPoints)
*/
@Get('trips/:driverId/:tripId')
@Roles(Role.ADMINISTRATOR)
async getTripDetail(
@Param('driverId') driverId: string,
@Param('tripId') tripId: string,
) {
return this.gpsService.getTripDetail(driverId, tripId);
}
// ============================================ // ============================================
// Traccar Admin Access // Traccar Admin Access
// ============================================ // ============================================

View File

@@ -3,7 +3,6 @@ import { ScheduleModule } from '@nestjs/schedule';
import { GpsController } from './gps.controller'; import { GpsController } from './gps.controller';
import { GpsService } from './gps.service'; import { GpsService } from './gps.service';
import { TraccarClientService } from './traccar-client.service'; import { TraccarClientService } from './traccar-client.service';
import { OsrmService } from './osrm.service';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { SignalModule } from '../signal/signal.module'; import { SignalModule } from '../signal/signal.module';
@@ -14,7 +13,7 @@ import { SignalModule } from '../signal/signal.module';
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
], ],
controllers: [GpsController], controllers: [GpsController],
providers: [GpsService, TraccarClientService, OsrmService], providers: [GpsService, TraccarClientService],
exports: [GpsService, TraccarClientService], exports: [GpsService, TraccarClientService],
}) })
export class GpsModule {} export class GpsModule {}

View File

@@ -10,7 +10,6 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SignalService } from '../signal/signal.service'; import { SignalService } from '../signal/signal.service';
import { TraccarClientService } from './traccar-client.service'; import { TraccarClientService } from './traccar-client.service';
import { OsrmService } from './osrm.service';
import { import {
DriverLocationDto, DriverLocationDto,
DriverStatsDto, DriverStatsDto,
@@ -30,7 +29,6 @@ export class GpsService implements OnModuleInit {
private traccarClient: TraccarClientService, private traccarClient: TraccarClientService,
private signalService: SignalService, private signalService: SignalService,
private configService: ConfigService, private configService: ConfigService,
private osrmService: OsrmService,
) {} ) {}
async onModuleInit() { async onModuleInit() {
@@ -198,20 +196,18 @@ export class GpsService implements OnModuleInit {
const settings = await this.getSettings(); const settings = await this.getSettings();
// Build QR code URL for Traccar Client app // Build QR code URL for Traccar Client app
// All supported params: id, interval, accuracy, distance, angle, heartbeat, buffer, stop_detection, wakelock
// Key iOS settings: accuracy=highest + stop_detection=false prevents iOS from pausing GPS updates
const devicePort = this.configService.get<number>('TRACCAR_DEVICE_PORT') || 5055; const devicePort = this.configService.get<number>('TRACCAR_DEVICE_PORT') || 5055;
const traccarPublicUrl = this.traccarClient.getTraccarUrl(); const traccarPublicUrl = this.traccarClient.getTraccarUrl();
const qrUrl = new URL(traccarPublicUrl); const qrUrl = new URL(traccarPublicUrl);
qrUrl.port = String(devicePort); qrUrl.port = String(devicePort);
qrUrl.searchParams.set('id', actualDeviceId); qrUrl.searchParams.set('id', actualDeviceId);
qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds)); qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds));
qrUrl.searchParams.set('accuracy', 'highest'); // iOS: kCLDesiredAccuracyBestForNavigation qrUrl.searchParams.set('accuracy', 'highest');
qrUrl.searchParams.set('distance', '0'); // Disable distance filter — rely on interval qrUrl.searchParams.set('distance', '0');
qrUrl.searchParams.set('angle', '30'); // Send update on 30° heading change (turns) qrUrl.searchParams.set('angle', '30');
qrUrl.searchParams.set('heartbeat', '300'); // 5 min heartbeat when stationary qrUrl.searchParams.set('heartbeat', '300');
qrUrl.searchParams.set('stop_detection', 'false'); // CRITICAL: prevent iOS from pausing GPS qrUrl.searchParams.set('stop_detection', 'false');
qrUrl.searchParams.set('buffer', 'true'); // Buffer points when offline qrUrl.searchParams.set('buffer', 'true');
const qrCodeUrl = qrUrl.toString(); const qrCodeUrl = qrUrl.toString();
this.logger.log(`QR code URL for driver: ${qrCodeUrl}`); this.logger.log(`QR code URL for driver: ${qrCodeUrl}`);
@@ -262,7 +258,7 @@ GPS Tracking Setup Instructions for ${driver.name}:
return { return {
success: true, success: true,
deviceIdentifier: actualDeviceId, // Return what Traccar actually stored deviceIdentifier: actualDeviceId,
serverUrl, serverUrl,
qrCodeUrl, qrCodeUrl,
instructions, instructions,
@@ -389,7 +385,7 @@ GPS Tracking Setup Instructions for ${driver.name}:
} }
/** /**
* Get all active driver locations (Admin only) * Get all active driver locations (used by CommandCenter + GPS page)
*/ */
async getActiveDriverLocations(): Promise<DriverLocationDto[]> { async getActiveDriverLocations(): Promise<DriverLocationDto[]> {
const devices = await this.prisma.gpsDevice.findMany({ const devices = await this.prisma.gpsDevice.findMany({
@@ -442,7 +438,7 @@ GPS Tracking Setup Instructions for ${driver.name}:
} }
/** /**
* Get a specific driver's location * Get a specific driver's location (used by driver self-service)
*/ */
async getDriverLocation(driverId: string): Promise<DriverLocationDto | null> { async getDriverLocation(driverId: string): Promise<DriverLocationDto | null> {
const device = await this.prisma.gpsDevice.findUnique({ const device = await this.prisma.gpsDevice.findUnique({
@@ -491,141 +487,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
}; };
} }
/**
* Get driver location history (for route trail display)
*/
async getDriverLocationHistory(
driverId: string,
fromDate?: Date,
toDate?: Date,
): Promise<LocationDataDto[]> {
const device = await this.prisma.gpsDevice.findUnique({
where: { driverId },
});
if (!device) {
throw new NotFoundException('Driver is not enrolled for GPS tracking');
}
// Default to last 12 hours if no date range specified
const to = toDate || new Date();
const from = fromDate || new Date(to.getTime() - 12 * 60 * 60 * 1000);
const locations = await this.prisma.gpsLocationHistory.findMany({
where: {
deviceId: device.id,
timestamp: {
gte: from,
lte: to,
},
},
orderBy: { timestamp: 'asc' },
});
return locations.map((loc) => ({
latitude: loc.latitude,
longitude: loc.longitude,
altitude: loc.altitude,
speed: loc.speed,
course: loc.course,
accuracy: loc.accuracy,
battery: loc.battery,
timestamp: loc.timestamp,
}));
}
/**
* Get road-matched route for a driver (snaps GPS points to actual roads)
* Returns OSRM-matched coordinates and accurate road distance
*/
async getMatchedRoute(
driverId: string,
fromDate?: Date,
toDate?: Date,
): Promise<{
rawPoints: LocationDataDto[];
matchedRoute: {
coordinates: Array<[number, number]>; // [lat, lng] for Leaflet
distance: number; // miles
duration: number; // seconds
confidence: number; // 0-1
} | null;
}> {
const device = await this.prisma.gpsDevice.findUnique({
where: { driverId },
});
if (!device) {
throw new NotFoundException('Driver is not enrolled for GPS tracking');
}
// Default to last 12 hours if no date range specified
const to = toDate || new Date();
const from = fromDate || new Date(to.getTime() - 12 * 60 * 60 * 1000);
const locations = await this.prisma.gpsLocationHistory.findMany({
where: {
deviceId: device.id,
timestamp: {
gte: from,
lte: to,
},
},
orderBy: { timestamp: 'asc' },
});
const rawPoints = locations.map((loc) => ({
latitude: loc.latitude,
longitude: loc.longitude,
altitude: loc.altitude,
speed: loc.speed,
course: loc.course,
accuracy: loc.accuracy,
battery: loc.battery,
timestamp: loc.timestamp,
}));
if (locations.length < 2) {
return { rawPoints, matchedRoute: null };
}
// Call OSRM to match route
this.logger.log(
`[OSRM] Matching route for driver ${driverId}: ${locations.length} points`,
);
const matchResult = await this.osrmService.matchRoute(
locations.map((loc) => ({
latitude: loc.latitude,
longitude: loc.longitude,
timestamp: loc.timestamp,
speed: loc.speed ?? undefined,
})),
);
if (!matchResult) {
this.logger.warn('[OSRM] Route matching failed, returning raw points only');
return { rawPoints, matchedRoute: null };
}
// Convert distance from meters to miles
const distanceMiles = matchResult.distance / 1609.34;
this.logger.log(
`[OSRM] Route matched: ${distanceMiles.toFixed(2)} miles, confidence ${(matchResult.confidence * 100).toFixed(1)}%`,
);
return {
rawPoints,
matchedRoute: {
coordinates: matchResult.coordinates,
distance: distanceMiles,
duration: matchResult.duration,
confidence: matchResult.confidence,
},
};
}
/** /**
* Calculate distance between two GPS coordinates using Haversine formula * Calculate distance between two GPS coordinates using Haversine formula
* Returns distance in miles * Returns distance in miles
@@ -651,23 +512,18 @@ GPS Tracking Setup Instructions for ${driver.name}:
return R * c; return R * c;
} }
/**
* Convert degrees to radians
*/
private toRadians(degrees: number): number { private toRadians(degrees: number): number {
return degrees * (Math.PI / 180); return degrees * (Math.PI / 180);
} }
/** /**
* Calculate total distance from position history * Calculate total distance from position history
* Uses stored GpsLocationHistory records (not Traccar API)
*/ */
private async calculateDistanceFromHistory( private async calculateDistanceFromHistory(
deviceId: string, deviceId: string,
from: Date, from: Date,
to: Date, to: Date,
): Promise<number> { ): Promise<number> {
// Get all positions in chronological order
const positions = await this.prisma.gpsLocationHistory.findMany({ const positions = await this.prisma.gpsLocationHistory.findMany({
where: { where: {
deviceId, deviceId,
@@ -692,25 +548,16 @@ GPS Tracking Setup Instructions for ${driver.name}:
let totalMiles = 0; let totalMiles = 0;
// Sum distances between consecutive positions
for (let i = 1; i < positions.length; i++) { for (let i = 1; i < positions.length; i++) {
const prev = positions[i - 1]; const prev = positions[i - 1];
const curr = positions[i]; const curr = positions[i];
// Calculate time gap between positions
const timeDiffMs = curr.timestamp.getTime() - prev.timestamp.getTime(); const timeDiffMs = curr.timestamp.getTime() - prev.timestamp.getTime();
const timeDiffMinutes = timeDiffMs / 60000; const timeDiffMinutes = timeDiffMs / 60000;
// Skip if gap is too large (more than 10 minutes) // Skip if gap is too large (more than 10 minutes)
// This prevents straight-line distance when device was off if (timeDiffMinutes > 10) continue;
if (timeDiffMinutes > 10) {
this.logger.debug(
`[Stats] Skipping gap: ${timeDiffMinutes.toFixed(1)} min between positions`,
);
continue;
}
// Calculate distance between consecutive points
const distance = this.calculateHaversineDistance( const distance = this.calculateHaversineDistance(
prev.latitude, prev.latitude,
prev.longitude, prev.longitude,
@@ -719,33 +566,20 @@ GPS Tracking Setup Instructions for ${driver.name}:
); );
// Sanity check: skip unrealistic distances (> 100 mph equivalent) // Sanity check: skip unrealistic distances (> 100 mph equivalent)
// Max distance = time_hours * 100 mph
const maxPossibleDistance = (timeDiffMinutes / 60) * 100; const maxPossibleDistance = (timeDiffMinutes / 60) * 100;
if (distance > maxPossibleDistance) { if (distance > maxPossibleDistance) continue;
this.logger.warn(
`[Stats] Skipping unrealistic distance: ${distance.toFixed(2)} miles in ${timeDiffMinutes.toFixed(1)} min (max: ${maxPossibleDistance.toFixed(2)})`,
);
continue;
}
// Filter out GPS jitter (movements < 0.01 miles / ~50 feet) // Filter out GPS jitter (movements < 0.01 miles / ~50 feet)
if (distance < 0.01) { if (distance < 0.01) continue;
continue;
}
totalMiles += distance; totalMiles += distance;
} }
this.logger.log(
`[Stats] Calculated ${totalMiles.toFixed(2)} miles from ${positions.length} positions`,
);
return totalMiles; return totalMiles;
} }
/** /**
* Get driver's own stats (for driver self-view) * Get driver stats (used by driver self-service via me/stats)
* UPDATED: Uses OSRM road-matched distance when available, falls back to Haversine
*/ */
async getDriverStats( async getDriverStats(
driverId: string, driverId: string,
@@ -772,34 +606,7 @@ GPS Tracking Setup Instructions for ${driver.name}:
const to = toDate || new Date(); const to = toDate || new Date();
const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000); const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000);
this.logger.log( const totalMiles = await this.calculateDistanceFromHistory(device.id, from, to);
`[Stats] Calculating stats for driver ${device.driver.name} from ${from.toISOString()} to ${to.toISOString()}`,
);
// Try to get OSRM-matched distance first, fall back to Haversine
let totalMiles = 0;
let distanceMethod = 'haversine';
try {
const matchedRoute = await this.getMatchedRoute(driverId, from, to);
if (matchedRoute.matchedRoute && matchedRoute.matchedRoute.confidence > 0.5) {
totalMiles = matchedRoute.matchedRoute.distance;
distanceMethod = 'osrm';
this.logger.log(
`[Stats] Using OSRM road distance: ${totalMiles.toFixed(2)} miles (confidence ${(matchedRoute.matchedRoute.confidence * 100).toFixed(1)}%)`,
);
} else {
throw new Error('OSRM confidence too low or matching failed');
}
} catch (error) {
this.logger.warn(
`[Stats] OSRM matching failed, falling back to Haversine: ${error.message}`,
);
totalMiles = await this.calculateDistanceFromHistory(device.id, from, to);
this.logger.log(
`[Stats] Using Haversine distance: ${totalMiles.toFixed(2)} miles`,
);
}
// Get all positions for speed/time analysis // Get all positions for speed/time analysis
const allPositions = await this.prisma.gpsLocationHistory.findMany({ const allPositions = await this.prisma.gpsLocationHistory.findMany({
@@ -813,33 +620,26 @@ GPS Tracking Setup Instructions for ${driver.name}:
orderBy: { timestamp: 'asc' }, orderBy: { timestamp: 'asc' },
}); });
// Calculate top speed and driving time from position history
let topSpeedMph = 0; let topSpeedMph = 0;
let topSpeedTimestamp: Date | null = null; let topSpeedTimestamp: Date | null = null;
let totalDrivingMinutes = 0; let totalDrivingMinutes = 0;
// Identify "trips" (sequences of positions with speed > 5 mph)
let currentTripStart: Date | null = null; let currentTripStart: Date | null = null;
let totalTrips = 0; let totalTrips = 0;
for (const pos of allPositions) { for (const pos of allPositions) {
const speedMph = pos.speed || 0; // Speed is already in mph (converted during sync) const speedMph = pos.speed || 0;
// Track top speed
if (speedMph > topSpeedMph) { if (speedMph > topSpeedMph) {
topSpeedMph = speedMph; topSpeedMph = speedMph;
topSpeedTimestamp = pos.timestamp; topSpeedTimestamp = pos.timestamp;
} }
// Trip detection: speed > 5 mph = driving
if (speedMph > 5) { if (speedMph > 5) {
if (!currentTripStart) { if (!currentTripStart) {
// Start of new trip
currentTripStart = pos.timestamp; currentTripStart = pos.timestamp;
totalTrips++; totalTrips++;
} }
} else if (currentTripStart) { } else if (currentTripStart) {
// End of trip (stopped)
const tripDurationMs = pos.timestamp.getTime() - currentTripStart.getTime(); const tripDurationMs = pos.timestamp.getTime() - currentTripStart.getTime();
totalDrivingMinutes += tripDurationMs / 60000; totalDrivingMinutes += tripDurationMs / 60000;
currentTripStart = null; currentTripStart = null;
@@ -871,10 +671,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
? totalMiles / (totalDrivingMinutes / 60) ? totalMiles / (totalDrivingMinutes / 60)
: 0; : 0;
this.logger.log(
`[Stats] Results (${distanceMethod}): ${totalMiles.toFixed(1)} miles, ${topSpeedMph.toFixed(0)} mph top speed, ${totalTrips} trips, ${totalDrivingMinutes.toFixed(0)} min driving`,
);
return { return {
driverId, driverId,
driverName: device.driver.name, driverName: device.driver.name,
@@ -889,7 +685,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
averageSpeedMph: Math.round(averageSpeedMph * 10) / 10, averageSpeedMph: Math.round(averageSpeedMph * 10) / 10,
totalTrips, totalTrips,
totalDrivingMinutes: Math.round(totalDrivingMinutes), totalDrivingMinutes: Math.round(totalDrivingMinutes),
distanceMethod, // 'osrm' or 'haversine'
}, },
recentLocations: recentLocations.map((loc) => ({ recentLocations: recentLocations.map((loc) => ({
latitude: loc.latitude, latitude: loc.latitude,
@@ -926,15 +721,10 @@ GPS Tracking Setup Instructions for ${driver.name}:
for (const device of devices) { for (const device of devices) {
try { try {
// Calculate "since" from device's last active time with 30s overlap buffer
// (increased from 5s to catch late-arriving positions)
// Falls back to 2 minutes ago if no lastActive
const since = device.lastActive const since = device.lastActive
? new Date(device.lastActive.getTime() - 30000) ? new Date(device.lastActive.getTime() - 30000)
: new Date(now.getTime() - 120000); : new Date(now.getTime() - 120000);
this.logger.debug(`[GPS Sync] Fetching positions for device ${device.traccarDeviceId} since ${since.toISOString()}`);
const positions = await this.traccarClient.getPositionHistory( const positions = await this.traccarClient.getPositionHistory(
device.traccarDeviceId, device.traccarDeviceId,
since, since,
@@ -945,7 +735,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
if (positions.length === 0) continue; if (positions.length === 0) continue;
// Batch insert with skipDuplicates (unique constraint on deviceId+timestamp)
const insertResult = await this.prisma.gpsLocationHistory.createMany({ const insertResult = await this.prisma.gpsLocationHistory.createMany({
data: positions.map((p) => ({ data: positions.map((p) => ({
deviceId: device.id, deviceId: device.id,
@@ -968,7 +757,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
`Inserted ${inserted} new positions, skipped ${skipped} duplicates` `Inserted ${inserted} new positions, skipped ${skipped} duplicates`
); );
// Update lastActive to the latest position timestamp
const latestPosition = positions.reduce((latest, p) => const latestPosition = positions.reduce((latest, p) =>
new Date(p.deviceTime) > new Date(latest.deviceTime) ? p : latest new Date(p.deviceTime) > new Date(latest.deviceTime) ? p : latest
); );
@@ -976,8 +764,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
where: { id: device.id }, where: { id: device.id },
data: { lastActive: new Date(latestPosition.deviceTime) }, data: { lastActive: new Date(latestPosition.deviceTime) },
}); });
// Trip detection handled by Traccar natively
} catch (error) { } catch (error) {
this.logger.error(`[GPS Sync] Failed to sync positions for device ${device.traccarDeviceId}: ${error}`); this.logger.error(`[GPS Sync] Failed to sync positions for device ${device.traccarDeviceId}: ${error}`);
} }
@@ -986,194 +772,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
this.logger.log('[GPS Sync] Sync completed'); this.logger.log('[GPS Sync] Sync completed');
} }
// ============================================
// Trip Management (powered by Traccar)
// ============================================
/**
* Generate a deterministic trip ID from device ID + start time
*/
private generateTripId(deviceId: string, startTime: string): string {
return crypto
.createHash('sha256')
.update(`${deviceId}:${startTime}`)
.digest('hex')
.substring(0, 24);
}
/**
* Map a Traccar trip to our frontend GpsTrip format
*/
private mapTraccarTrip(deviceId: string, trip: any, status = 'COMPLETED') {
return {
id: this.generateTripId(deviceId, trip.startTime),
deviceId,
status,
startTime: trip.startTime,
endTime: trip.endTime,
startLatitude: trip.startLat,
startLongitude: trip.startLon,
endLatitude: trip.endLat,
endLongitude: trip.endLon,
distanceMiles: Math.round((trip.distance / 1609.34) * 10) / 10,
durationSeconds: Math.round(trip.duration / 1000),
topSpeedMph: Math.round(this.traccarClient.knotsToMph(trip.maxSpeed)),
averageSpeedMph:
Math.round(this.traccarClient.knotsToMph(trip.averageSpeed) * 10) / 10,
pointCount: 0,
startAddress: trip.startAddress,
endAddress: trip.endAddress,
};
}
/**
* Get trips for a driver (from Traccar trip report)
*/
async getDriverTrips(driverId: string, fromDate?: Date, toDate?: Date) {
const device = await this.prisma.gpsDevice.findUnique({
where: { driverId },
});
if (!device) {
throw new NotFoundException('Driver is not enrolled for GPS tracking');
}
const to = toDate || new Date();
const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000);
const traccarTrips = await this.traccarClient.getTripReport(
device.traccarDeviceId,
from,
to,
);
return traccarTrips.map((t) => this.mapTraccarTrip(device.id, t));
}
/**
* Get a single trip with full detail (matchedRoute + rawPoints)
*/
async getTripDetail(driverId: string, tripId: string) {
const device = await this.prisma.gpsDevice.findUnique({
where: { driverId },
});
if (!device) {
throw new NotFoundException('Driver is not enrolled for GPS tracking');
}
// Search last 30 days for the matching trip
const to = new Date();
const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
const traccarTrips = await this.traccarClient.getTripReport(
device.traccarDeviceId,
from,
to,
);
const trip = traccarTrips.find(
(t) => this.generateTripId(device.id, t.startTime) === tripId,
);
if (!trip) {
throw new NotFoundException('Trip not found');
}
// Fetch raw points for this trip from our location history
const rawPoints = await this.prisma.gpsLocationHistory.findMany({
where: {
deviceId: device.id,
timestamp: {
gte: new Date(trip.startTime),
lte: new Date(trip.endTime),
},
},
orderBy: { timestamp: 'asc' },
select: {
latitude: true,
longitude: true,
speed: true,
course: true,
battery: true,
timestamp: true,
},
});
// Try OSRM map matching for the route visualization
let matchedRoute = null;
if (rawPoints.length >= 2) {
try {
const matchResult = await this.osrmService.matchRoute(
rawPoints.map((p) => ({
latitude: p.latitude,
longitude: p.longitude,
timestamp: p.timestamp,
speed: p.speed ?? undefined,
})),
);
if (matchResult) {
matchedRoute = {
coordinates: matchResult.coordinates,
distance: matchResult.distance / 1609.34,
duration: matchResult.duration,
confidence: matchResult.confidence,
};
}
} catch (error) {
this.logger.warn(`OSRM failed for trip ${tripId}: ${error.message}`);
}
}
return {
...this.mapTraccarTrip(device.id, trip),
pointCount: rawPoints.length,
matchedRoute,
rawPoints,
};
}
/**
* Get active trip for a driver (if any)
* Checks Traccar for a trip that ended very recently (still in progress)
*/
async getActiveTrip(driverId: string) {
const device = await this.prisma.gpsDevice.findUnique({
where: { driverId },
});
if (!device) {
return null;
}
try {
const to = new Date();
const from = new Date(to.getTime() - 60 * 60 * 1000); // Last hour
const traccarTrips = await this.traccarClient.getTripReport(
device.traccarDeviceId,
from,
to,
);
// A trip is "active" if it ended less than 5 minutes ago
const activeTrip = traccarTrips.find((t) => {
const endTime = new Date(t.endTime);
return to.getTime() - endTime.getTime() < 5 * 60 * 1000;
});
if (!activeTrip) return null;
return this.mapTraccarTrip(device.id, activeTrip, 'ACTIVE');
} catch (error) {
this.logger.warn(
`Failed to check active trip for ${driverId}: ${error.message}`,
);
return null;
}
}
/** /**
* Clean up old location history (runs daily at 2 AM) * Clean up old location history (runs daily at 2 AM)
*/ */
@@ -1198,11 +796,7 @@ GPS Tracking Setup Instructions for ${driver.name}:
// Traccar User Sync (VIP Admin -> Traccar Admin) // Traccar User Sync (VIP Admin -> Traccar Admin)
// ============================================ // ============================================
/**
* Generate a secure password for Traccar user
*/
private generateTraccarPassword(userId: string): string { private generateTraccarPassword(userId: string): string {
// Generate deterministic but secure password based on user ID + secret
const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-sync'; const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-sync';
return crypto return crypto
.createHmac('sha256', secret) .createHmac('sha256', secret)
@@ -1211,11 +805,7 @@ GPS Tracking Setup Instructions for ${driver.name}:
.substring(0, 24); .substring(0, 24);
} }
/**
* Generate a secure token for Traccar auto-login
*/
private generateTraccarToken(userId: string): string { private generateTraccarToken(userId: string): string {
// Generate deterministic token for auto-login
const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-token'; const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-token';
return crypto return crypto
.createHmac('sha256', secret + '-token') .createHmac('sha256', secret + '-token')
@@ -1224,9 +814,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
.substring(0, 32); .substring(0, 32);
} }
/**
* Sync a VIP user to Traccar
*/
async syncUserToTraccar(user: User): Promise<boolean> { async syncUserToTraccar(user: User): Promise<boolean> {
if (!user.email) return false; if (!user.email) return false;
@@ -1240,7 +827,7 @@ GPS Tracking Setup Instructions for ${driver.name}:
user.name || user.email, user.name || user.email,
password, password,
isAdmin, isAdmin,
token, // Include token for auto-login token,
); );
this.logger.log(`Synced user ${user.email} to Traccar (admin: ${isAdmin})`); this.logger.log(`Synced user ${user.email} to Traccar (admin: ${isAdmin})`);
@@ -1251,9 +838,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
} }
} }
/**
* Sync all VIP admins to Traccar
*/
async syncAllAdminsToTraccar(): Promise<{ synced: number; failed: number }> { async syncAllAdminsToTraccar(): Promise<{ synced: number; failed: number }> {
const admins = await this.prisma.user.findMany({ const admins = await this.prisma.user.findMany({
where: { where: {
@@ -1275,9 +859,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
return { synced, failed }; return { synced, failed };
} }
/**
* Get auto-login URL for Traccar (for admin users)
*/
async getTraccarAutoLoginUrl(user: User): Promise<{ async getTraccarAutoLoginUrl(user: User): Promise<{
url: string; url: string;
directAccess: boolean; directAccess: boolean;
@@ -1286,30 +867,22 @@ GPS Tracking Setup Instructions for ${driver.name}:
throw new BadRequestException('Only administrators can access Traccar admin'); throw new BadRequestException('Only administrators can access Traccar admin');
} }
// Ensure user is synced to Traccar (this also sets up their token)
await this.syncUserToTraccar(user); await this.syncUserToTraccar(user);
// Get the token for auto-login
const token = this.generateTraccarToken(user.id); const token = this.generateTraccarToken(user.id);
const baseUrl = this.traccarClient.getTraccarUrl(); const baseUrl = this.traccarClient.getTraccarUrl();
// Return URL with token parameter for auto-login
// Traccar supports ?token=xxx for direct authentication
return { return {
url: `${baseUrl}?token=${token}`, url: `${baseUrl}?token=${token}`,
directAccess: true, directAccess: true,
}; };
} }
/**
* Get Traccar session cookie for a user (for proxy/iframe auth)
*/
async getTraccarSessionForUser(user: User): Promise<string | null> { async getTraccarSessionForUser(user: User): Promise<string | null> {
if (user.role !== 'ADMINISTRATOR') { if (user.role !== 'ADMINISTRATOR') {
return null; return null;
} }
// Ensure user is synced
await this.syncUserToTraccar(user); await this.syncUserToTraccar(user);
const password = this.generateTraccarPassword(user.id); const password = this.generateTraccarPassword(user.id);
@@ -1318,9 +891,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
return session?.cookie || null; return session?.cookie || null;
} }
/**
* Check if Traccar needs initial setup
*/
async checkTraccarSetup(): Promise<{ async checkTraccarSetup(): Promise<{
needsSetup: boolean; needsSetup: boolean;
isAvailable: boolean; isAvailable: boolean;
@@ -1334,11 +904,7 @@ GPS Tracking Setup Instructions for ${driver.name}:
return { needsSetup, isAvailable }; return { needsSetup, isAvailable };
} }
/**
* Perform initial Traccar setup
*/
async performTraccarSetup(adminEmail: string): Promise<boolean> { async performTraccarSetup(adminEmail: string): Promise<boolean> {
// Generate a secure password for the service account
const servicePassword = crypto.randomBytes(16).toString('hex'); const servicePassword = crypto.randomBytes(16).toString('hex');
const success = await this.traccarClient.performInitialSetup( const success = await this.traccarClient.performInitialSetup(
@@ -1347,7 +913,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
); );
if (success) { if (success) {
// Save the service account credentials to settings
await this.updateSettings({ await this.updateSettings({
traccarAdminUser: adminEmail, traccarAdminUser: adminEmail,
traccarAdminPassword: servicePassword, traccarAdminPassword: servicePassword,

View File

@@ -1,415 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import axios from 'axios';
interface MatchedRoute {
coordinates: Array<[number, number]>; // [lat, lng] pairs for Leaflet
distance: number; // meters
duration: number; // seconds
confidence: number;
}
interface GpsPoint {
latitude: number;
longitude: number;
timestamp?: Date;
speed?: number;
}
@Injectable()
export class OsrmService {
private readonly logger = new Logger(OsrmService.name);
private readonly baseUrl = 'https://router.project-osrm.org';
// Max gap (seconds) between points before we switch from match → route
private readonly SPARSE_GAP_THRESHOLD = 120; // 2 minutes
/**
* Intelligently match/route GPS coordinates to actual road network.
*
* Strategy:
* 1. Split points into "dense" segments (points <2min apart) and "sparse" gaps
* 2. Dense segments: Use OSRM Match API (map matching - snaps to roads)
* 3. Sparse gaps: Use OSRM Route API (turn-by-turn directions between points)
* 4. Stitch everything together in order
*/
async matchRoute(points: GpsPoint[]): Promise<MatchedRoute | null> {
// Filter out stationary points (speed=0, same location) to reduce noise
const movingPoints = this.filterStationaryPoints(points);
if (movingPoints.length < 2) {
this.logger.debug('Not enough moving points for route matching');
return null;
}
this.logger.log(
`Processing ${movingPoints.length} moving points (filtered from ${points.length} total)`,
);
try {
// Split into segments based on time gaps
const segments = this.splitByTimeGaps(movingPoints);
this.logger.log(
`Split into ${segments.length} segments: ` +
segments
.map(
(s) =>
`${s.type}(${s.points.length}pts)`,
)
.join(', '),
);
let allCoordinates: Array<[number, number]> = [];
let totalDistance = 0;
let totalDuration = 0;
let confidenceSum = 0;
let confidenceCount = 0;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
// Rate limit between API calls
if (i > 0) {
await this.delay(1100);
}
let result: MatchedRoute | null = null;
if (segment.type === 'dense' && segment.points.length >= 2) {
// Dense data: use map matching for accuracy
result = await this.matchSegment(segment.points);
}
if (segment.type === 'sparse' || (!result && segment.points.length >= 2)) {
// Sparse data or match failed: use routing between waypoints
result = await this.routeSegment(segment.points);
}
if (result && result.coordinates.length > 0) {
allCoordinates.push(...result.coordinates);
totalDistance += result.distance;
totalDuration += result.duration;
confidenceSum += result.confidence;
confidenceCount++;
}
}
if (allCoordinates.length === 0) {
this.logger.warn('No coordinates produced from any segments');
return null;
}
const avgConfidence =
confidenceCount > 0 ? confidenceSum / confidenceCount : 0;
this.logger.log(
`Route complete: ${allCoordinates.length} coords, ` +
`${(totalDistance / 1609.34).toFixed(2)} miles, ` +
`confidence ${(avgConfidence * 100).toFixed(1)}%`,
);
return {
coordinates: allCoordinates,
distance: totalDistance,
duration: totalDuration,
confidence: avgConfidence,
};
} catch (error) {
this.logger.error(`Route processing failed: ${error.message}`);
return null;
}
}
/**
* OSRM Match API - for dense GPS traces with points close together.
* Snaps GPS points to the most likely road path.
*/
private async matchSegment(points: GpsPoint[]): Promise<MatchedRoute | null> {
if (points.length < 2) return null;
// Chunk to 100 points max (OSRM limit)
const chunks = this.chunkArray(points, 100);
let allCoords: Array<[number, number]> = [];
let totalDist = 0;
let totalDur = 0;
let confSum = 0;
let confCount = 0;
for (let ci = 0; ci < chunks.length; ci++) {
const chunk = chunks[ci];
if (ci > 0) await this.delay(1100);
const coordString = chunk
.map((p) => `${p.longitude},${p.latitude}`)
.join(';');
// Use larger radius for sparse data + gaps=split to handle discontinuities
const radiuses = chunk.map(() => 50).join(';');
const timestamps = chunk[0].timestamp
? chunk
.map((p) =>
Math.floor((p.timestamp?.getTime() || 0) / 1000),
)
.join(';')
: undefined;
const params: Record<string, string> = {
overview: 'full',
geometries: 'geojson',
radiuses,
gaps: 'split', // Split into separate legs at large gaps
};
if (timestamps) params.timestamps = timestamps;
const url = `${this.baseUrl}/match/v1/driving/${coordString}`;
try {
const response = await axios.get(url, { params, timeout: 15000 });
if (
response.data.code === 'Ok' &&
response.data.matchings?.length > 0
) {
for (const matching of response.data.matchings) {
const coords = matching.geometry.coordinates.map(
(c: [number, number]) => [c[1], c[0]] as [number, number],
);
allCoords.push(...coords);
totalDist += matching.distance || 0;
totalDur += matching.duration || 0;
confSum += matching.confidence || 0;
confCount++;
}
this.logger.debug(
`Match chunk ${ci + 1}/${chunks.length}: ` +
`${response.data.matchings.length} matchings, ` +
`${(totalDist / 1000).toFixed(2)} km`,
);
} else {
this.logger.warn(
`Match failed for chunk ${ci + 1}: ${response.data.code}`,
);
return null; // Signal caller to try routing instead
}
} catch (error) {
this.logger.warn(`Match API error chunk ${ci + 1}: ${error.message}`);
return null;
}
}
if (allCoords.length === 0) return null;
return {
coordinates: allCoords,
distance: totalDist,
duration: totalDur,
confidence: confCount > 0 ? confSum / confCount : 0,
};
}
/**
* OSRM Route API - for sparse GPS data with large gaps between points.
* Calculates actual driving route between waypoints (like Google Directions).
* This gives accurate road routes even with 5-10 minute gaps.
*/
private async routeSegment(
points: GpsPoint[],
): Promise<MatchedRoute | null> {
if (points.length < 2) return null;
// OSRM route supports up to 100 waypoints
// For very sparse data, we can usually fit all points
const chunks = this.chunkArray(points, 100);
let allCoords: Array<[number, number]> = [];
let totalDist = 0;
let totalDur = 0;
for (let ci = 0; ci < chunks.length; ci++) {
const chunk = chunks[ci];
if (ci > 0) await this.delay(1100);
const coordString = chunk
.map((p) => `${p.longitude},${p.latitude}`)
.join(';');
const url = `${this.baseUrl}/route/v1/driving/${coordString}`;
try {
const response = await axios.get(url, {
params: {
overview: 'full',
geometries: 'geojson',
},
timeout: 15000,
});
if (
response.data.code === 'Ok' &&
response.data.routes?.length > 0
) {
const route = response.data.routes[0];
const coords = route.geometry.coordinates.map(
(c: [number, number]) => [c[1], c[0]] as [number, number],
);
allCoords.push(...coords);
totalDist += route.distance || 0;
totalDur += route.duration || 0;
this.logger.debug(
`Route chunk ${ci + 1}/${chunks.length}: ` +
`${coords.length} coords, ${(route.distance / 1000).toFixed(2)} km`,
);
} else {
this.logger.warn(
`Route failed for chunk ${ci + 1}: ${response.data.code}`,
);
}
} catch (error) {
this.logger.warn(`Route API error chunk ${ci + 1}: ${error.message}`);
}
}
if (allCoords.length === 0) return null;
// Route API gives exact road distance, so confidence is high
return {
coordinates: allCoords,
distance: totalDist,
duration: totalDur,
confidence: 0.85, // Route is reliable but may not be exact path taken
};
}
/**
* Filter out stationary points (GPS jitter while parked).
* Keeps only the first and last of a stationary cluster.
*/
private filterStationaryPoints(points: GpsPoint[]): GpsPoint[] {
if (points.length <= 2) return points;
const filtered: GpsPoint[] = [points[0]];
let lastMoving = points[0];
for (let i = 1; i < points.length; i++) {
const p = points[i];
const dist = this.haversineMeters(
lastMoving.latitude,
lastMoving.longitude,
p.latitude,
p.longitude,
);
// Point has moved more than 30 meters from last moving point
if (dist > 30) {
filtered.push(p);
lastMoving = p;
}
}
// Always include last point
const last = points[points.length - 1];
if (filtered[filtered.length - 1] !== last) {
filtered.push(last);
}
this.logger.debug(
`Filtered ${points.length - filtered.length} stationary points`,
);
return filtered;
}
/**
* Split points into dense and sparse segments based on time gaps.
* Dense segments have points <SPARSE_GAP_THRESHOLD apart.
* Sparse segments bridge gaps between dense segments.
*/
private splitByTimeGaps(
points: GpsPoint[],
): Array<{ type: 'dense' | 'sparse'; points: GpsPoint[] }> {
if (points.length < 2) {
return [{ type: 'dense', points }];
}
const segments: Array<{ type: 'dense' | 'sparse'; points: GpsPoint[] }> =
[];
let currentDense: GpsPoint[] = [points[0]];
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const gapSeconds =
prev.timestamp && curr.timestamp
? (curr.timestamp.getTime() - prev.timestamp.getTime()) / 1000
: 0;
if (gapSeconds > this.SPARSE_GAP_THRESHOLD) {
// Large gap detected - save current dense segment, add sparse bridge
if (currentDense.length >= 2) {
segments.push({ type: 'dense', points: [...currentDense] });
}
// Create a sparse "bridge" segment from last dense point to next
segments.push({
type: 'sparse',
points: [prev, curr],
});
currentDense = [curr];
} else {
currentDense.push(curr);
}
}
// Save remaining dense segment
if (currentDense.length >= 2) {
segments.push({ type: 'dense', points: currentDense });
}
return segments;
}
/**
* Haversine distance in meters (for filtering)
*/
private haversineMeters(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number {
const R = 6371000; // Earth radius in meters
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Split array into overlapping chunks.
*/
private chunkArray<T>(array: T[], size: number): T[][] {
if (array.length <= size) return [array];
const chunks: T[][] = [];
const overlap = 3;
for (let i = 0; i < array.length; i += size - overlap) {
const chunk = array.slice(i, Math.min(i + size, array.length));
if (chunk.length >= 2) chunks.push(chunk);
if (i + size >= array.length) break;
}
return chunks;
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -9,9 +9,6 @@ import type {
EnrollmentResponse, EnrollmentResponse,
MyGpsStatus, MyGpsStatus,
DeviceQrInfo, DeviceQrInfo,
LocationHistoryResponse,
GpsTrip,
GpsTripDetail,
} from '@/types/gps'; } from '@/types/gps';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { queryKeys } from '@/lib/query-keys'; import { queryKeys } from '@/lib/query-keys';
@@ -98,7 +95,7 @@ export function useDeviceQr(driverId: string | null) {
} }
/** /**
* Get all active driver locations (for map) * Get all active driver locations (used by CommandCenter)
*/ */
export function useDriverLocations() { export function useDriverLocations() {
return useQuery<DriverLocation[]>({ return useQuery<DriverLocation[]>({
@@ -111,109 +108,6 @@ export function useDriverLocations() {
}); });
} }
/**
* Get a specific driver's location
*/
export function useDriverLocation(driverId: string) {
return useQuery<DriverLocation>({
queryKey: queryKeys.gps.locations.detail(driverId),
queryFn: async () => {
const { data } = await api.get(`/gps/locations/${driverId}`);
return data;
},
enabled: !!driverId,
refetchInterval: 15000,
});
}
/**
* Get driver location history (for route trail display)
* By default, requests road-snapped routes from OSRM map matching
*/
export function useDriverLocationHistory(driverId: string | null, from?: string, to?: string) {
return useQuery({
queryKey: ['gps', 'locations', driverId, 'history', from, to],
queryFn: async () => {
const params = new URLSearchParams();
if (from) params.append('from', from);
if (to) params.append('to', to);
params.append('matched', 'true'); // Request road-snapped route
const { data } = await api.get(`/gps/locations/${driverId}/history?${params}`);
return data;
},
enabled: !!driverId,
refetchInterval: 60000, // Match routes less frequently (60s) since OSRM has rate limits
staleTime: 30000, // Consider data fresh for 30s
});
}
/**
* Get driver stats
*/
export function useDriverStats(driverId: string, from?: string, to?: string) {
return useQuery<DriverStats>({
queryKey: queryKeys.gps.stats(driverId, from, to),
queryFn: async () => {
const params = new URLSearchParams();
if (from) params.append('from', from);
if (to) params.append('to', to);
const { data } = await api.get(`/gps/stats/${driverId}?${params.toString()}`);
return data;
},
enabled: !!driverId,
});
}
// ============================================
// Trip Hooks
// ============================================
/**
* Get trips for a driver (powered by Traccar)
*/
export function useDriverTrips(driverId: string | null, from?: string, to?: string) {
return useQuery<GpsTrip[]>({
queryKey: ['gps', 'trips', driverId, from, to],
queryFn: async () => {
const params = new URLSearchParams();
if (from) params.append('from', from);
if (to) params.append('to', to);
const { data } = await api.get(`/gps/trips/${driverId}?${params}`);
return data;
},
enabled: !!driverId,
});
}
/**
* Get a single trip with full detail (matchedRoute + rawPoints)
*/
export function useDriverTripDetail(driverId: string | null, tripId: string | null) {
return useQuery<GpsTripDetail>({
queryKey: ['gps', 'trips', driverId, tripId],
queryFn: async () => {
const { data } = await api.get(`/gps/trips/${driverId}/${tripId}`);
return data;
},
enabled: !!driverId && !!tripId,
});
}
/**
* Get active trip for a driver
*/
export function useActiveTrip(driverId: string | null) {
return useQuery<GpsTrip | null>({
queryKey: ['gps', 'trips', driverId, 'active'],
queryFn: async () => {
const { data } = await api.get(`/gps/trips/${driverId}/active`);
return data;
},
enabled: !!driverId,
refetchInterval: 15000,
});
}
/** /**
* Enroll a driver for GPS tracking * Enroll a driver for GPS tracking
*/ */

File diff suppressed because it is too large Load Diff

View File

@@ -106,60 +106,3 @@ export interface MyGpsStatus {
lastActive?: string; lastActive?: string;
message?: string; message?: string;
} }
export type TripStatus = 'ACTIVE' | 'COMPLETED' | 'PROCESSING' | 'FAILED';
export interface GpsTrip {
id: string;
deviceId: string;
status: TripStatus;
startTime: string;
endTime: string | null;
startLatitude: number;
startLongitude: number;
endLatitude: number | null;
endLongitude: number | null;
distanceMiles: number | null;
durationSeconds: number | null;
topSpeedMph: number | null;
averageSpeedMph: number | null;
pointCount: number;
startAddress?: string | null;
endAddress?: string | null;
}
export interface GpsTripDetail extends GpsTrip {
matchedRoute: {
coordinates: [number, number][];
distance: number;
duration: number;
confidence: number;
} | null;
rawPoints: Array<{
latitude: number;
longitude: number;
speed: number | null;
course?: number | null;
battery?: number | null;
timestamp: string;
}>;
}
export interface LocationHistoryResponse {
rawPoints: Array<{
latitude: number;
longitude: number;
altitude?: number | null;
speed?: number | null;
course?: number | null;
accuracy?: number | null;
battery?: number | null;
timestamp: string;
}>;
matchedRoute: {
coordinates: [number, number][]; // road-snapped [lat, lng] pairs
distance: number; // road distance in miles
duration: number; // duration in seconds
confidence: number; // 0-1 confidence score
} | null;
}