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:
@@ -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')
|
||||
@Roles(Role.ADMINISTRATOR)
|
||||
@@ -117,98 +117,6 @@ export class GpsController {
|
||||
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
|
||||
// ============================================
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { GpsController } from './gps.controller';
|
||||
import { GpsService } from './gps.service';
|
||||
import { TraccarClientService } from './traccar-client.service';
|
||||
import { OsrmService } from './osrm.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { SignalModule } from '../signal/signal.module';
|
||||
|
||||
@@ -14,7 +13,7 @@ import { SignalModule } from '../signal/signal.module';
|
||||
ScheduleModule.forRoot(),
|
||||
],
|
||||
controllers: [GpsController],
|
||||
providers: [GpsService, TraccarClientService, OsrmService],
|
||||
providers: [GpsService, TraccarClientService],
|
||||
exports: [GpsService, TraccarClientService],
|
||||
})
|
||||
export class GpsModule {}
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SignalService } from '../signal/signal.service';
|
||||
import { TraccarClientService } from './traccar-client.service';
|
||||
import { OsrmService } from './osrm.service';
|
||||
import {
|
||||
DriverLocationDto,
|
||||
DriverStatsDto,
|
||||
@@ -30,7 +29,6 @@ export class GpsService implements OnModuleInit {
|
||||
private traccarClient: TraccarClientService,
|
||||
private signalService: SignalService,
|
||||
private configService: ConfigService,
|
||||
private osrmService: OsrmService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
@@ -198,20 +196,18 @@ export class GpsService implements OnModuleInit {
|
||||
const settings = await this.getSettings();
|
||||
|
||||
// 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 traccarPublicUrl = this.traccarClient.getTraccarUrl();
|
||||
const qrUrl = new URL(traccarPublicUrl);
|
||||
qrUrl.port = String(devicePort);
|
||||
qrUrl.searchParams.set('id', actualDeviceId);
|
||||
qrUrl.searchParams.set('interval', String(settings.updateIntervalSeconds));
|
||||
qrUrl.searchParams.set('accuracy', 'highest'); // iOS: kCLDesiredAccuracyBestForNavigation
|
||||
qrUrl.searchParams.set('distance', '0'); // Disable distance filter — rely on interval
|
||||
qrUrl.searchParams.set('angle', '30'); // Send update on 30° heading change (turns)
|
||||
qrUrl.searchParams.set('heartbeat', '300'); // 5 min heartbeat when stationary
|
||||
qrUrl.searchParams.set('stop_detection', 'false'); // CRITICAL: prevent iOS from pausing GPS
|
||||
qrUrl.searchParams.set('buffer', 'true'); // Buffer points when offline
|
||||
qrUrl.searchParams.set('accuracy', 'highest');
|
||||
qrUrl.searchParams.set('distance', '0');
|
||||
qrUrl.searchParams.set('angle', '30');
|
||||
qrUrl.searchParams.set('heartbeat', '300');
|
||||
qrUrl.searchParams.set('stop_detection', 'false');
|
||||
qrUrl.searchParams.set('buffer', 'true');
|
||||
const qrCodeUrl = qrUrl.toString();
|
||||
|
||||
this.logger.log(`QR code URL for driver: ${qrCodeUrl}`);
|
||||
@@ -262,7 +258,7 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deviceIdentifier: actualDeviceId, // Return what Traccar actually stored
|
||||
deviceIdentifier: actualDeviceId,
|
||||
serverUrl,
|
||||
qrCodeUrl,
|
||||
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[]> {
|
||||
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> {
|
||||
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
|
||||
* Returns distance in miles
|
||||
@@ -651,23 +512,18 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert degrees to radians
|
||||
*/
|
||||
private toRadians(degrees: number): number {
|
||||
return degrees * (Math.PI / 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total distance from position history
|
||||
* Uses stored GpsLocationHistory records (not Traccar API)
|
||||
*/
|
||||
private async calculateDistanceFromHistory(
|
||||
deviceId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<number> {
|
||||
// Get all positions in chronological order
|
||||
const positions = await this.prisma.gpsLocationHistory.findMany({
|
||||
where: {
|
||||
deviceId,
|
||||
@@ -692,25 +548,16 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
|
||||
let totalMiles = 0;
|
||||
|
||||
// Sum distances between consecutive positions
|
||||
for (let i = 1; i < positions.length; i++) {
|
||||
const prev = positions[i - 1];
|
||||
const curr = positions[i];
|
||||
|
||||
// Calculate time gap between positions
|
||||
const timeDiffMs = curr.timestamp.getTime() - prev.timestamp.getTime();
|
||||
const timeDiffMinutes = timeDiffMs / 60000;
|
||||
|
||||
// Skip if gap is too large (more than 10 minutes)
|
||||
// This prevents straight-line distance when device was off
|
||||
if (timeDiffMinutes > 10) {
|
||||
this.logger.debug(
|
||||
`[Stats] Skipping gap: ${timeDiffMinutes.toFixed(1)} min between positions`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (timeDiffMinutes > 10) continue;
|
||||
|
||||
// Calculate distance between consecutive points
|
||||
const distance = this.calculateHaversineDistance(
|
||||
prev.latitude,
|
||||
prev.longitude,
|
||||
@@ -719,33 +566,20 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
);
|
||||
|
||||
// Sanity check: skip unrealistic distances (> 100 mph equivalent)
|
||||
// Max distance = time_hours * 100 mph
|
||||
const maxPossibleDistance = (timeDiffMinutes / 60) * 100;
|
||||
if (distance > maxPossibleDistance) {
|
||||
this.logger.warn(
|
||||
`[Stats] Skipping unrealistic distance: ${distance.toFixed(2)} miles in ${timeDiffMinutes.toFixed(1)} min (max: ${maxPossibleDistance.toFixed(2)})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (distance > maxPossibleDistance) continue;
|
||||
|
||||
// Filter out GPS jitter (movements < 0.01 miles / ~50 feet)
|
||||
if (distance < 0.01) {
|
||||
continue;
|
||||
}
|
||||
if (distance < 0.01) continue;
|
||||
|
||||
totalMiles += distance;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[Stats] Calculated ${totalMiles.toFixed(2)} miles from ${positions.length} positions`,
|
||||
);
|
||||
|
||||
return totalMiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get driver's own stats (for driver self-view)
|
||||
* UPDATED: Uses OSRM road-matched distance when available, falls back to Haversine
|
||||
* Get driver stats (used by driver self-service via me/stats)
|
||||
*/
|
||||
async getDriverStats(
|
||||
driverId: string,
|
||||
@@ -772,34 +606,7 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
const to = toDate || new Date();
|
||||
const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
this.logger.log(
|
||||
`[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`,
|
||||
);
|
||||
}
|
||||
const totalMiles = await this.calculateDistanceFromHistory(device.id, from, to);
|
||||
|
||||
// Get all positions for speed/time analysis
|
||||
const allPositions = await this.prisma.gpsLocationHistory.findMany({
|
||||
@@ -813,33 +620,26 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
orderBy: { timestamp: 'asc' },
|
||||
});
|
||||
|
||||
// Calculate top speed and driving time from position history
|
||||
let topSpeedMph = 0;
|
||||
let topSpeedTimestamp: Date | null = null;
|
||||
let totalDrivingMinutes = 0;
|
||||
|
||||
// Identify "trips" (sequences of positions with speed > 5 mph)
|
||||
let currentTripStart: Date | null = null;
|
||||
let totalTrips = 0;
|
||||
|
||||
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) {
|
||||
topSpeedMph = speedMph;
|
||||
topSpeedTimestamp = pos.timestamp;
|
||||
}
|
||||
|
||||
// Trip detection: speed > 5 mph = driving
|
||||
if (speedMph > 5) {
|
||||
if (!currentTripStart) {
|
||||
// Start of new trip
|
||||
currentTripStart = pos.timestamp;
|
||||
totalTrips++;
|
||||
}
|
||||
} else if (currentTripStart) {
|
||||
// End of trip (stopped)
|
||||
const tripDurationMs = pos.timestamp.getTime() - currentTripStart.getTime();
|
||||
totalDrivingMinutes += tripDurationMs / 60000;
|
||||
currentTripStart = null;
|
||||
@@ -871,10 +671,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
? totalMiles / (totalDrivingMinutes / 60)
|
||||
: 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 {
|
||||
driverId,
|
||||
driverName: device.driver.name,
|
||||
@@ -889,7 +685,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
averageSpeedMph: Math.round(averageSpeedMph * 10) / 10,
|
||||
totalTrips,
|
||||
totalDrivingMinutes: Math.round(totalDrivingMinutes),
|
||||
distanceMethod, // 'osrm' or 'haversine'
|
||||
},
|
||||
recentLocations: recentLocations.map((loc) => ({
|
||||
latitude: loc.latitude,
|
||||
@@ -926,15 +721,10 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
|
||||
for (const device of devices) {
|
||||
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
|
||||
? new Date(device.lastActive.getTime() - 30000)
|
||||
: 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(
|
||||
device.traccarDeviceId,
|
||||
since,
|
||||
@@ -945,7 +735,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
|
||||
if (positions.length === 0) continue;
|
||||
|
||||
// Batch insert with skipDuplicates (unique constraint on deviceId+timestamp)
|
||||
const insertResult = await this.prisma.gpsLocationHistory.createMany({
|
||||
data: positions.map((p) => ({
|
||||
deviceId: device.id,
|
||||
@@ -968,7 +757,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
`Inserted ${inserted} new positions, skipped ${skipped} duplicates`
|
||||
);
|
||||
|
||||
// Update lastActive to the latest position timestamp
|
||||
const latestPosition = positions.reduce((latest, p) =>
|
||||
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 },
|
||||
data: { lastActive: new Date(latestPosition.deviceTime) },
|
||||
});
|
||||
|
||||
// Trip detection handled by Traccar natively
|
||||
} catch (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');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 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)
|
||||
*/
|
||||
@@ -1198,11 +796,7 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
// Traccar User Sync (VIP Admin -> Traccar Admin)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Generate a secure password for Traccar user
|
||||
*/
|
||||
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';
|
||||
return crypto
|
||||
.createHmac('sha256', secret)
|
||||
@@ -1211,11 +805,7 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
.substring(0, 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a secure token for Traccar auto-login
|
||||
*/
|
||||
private generateTraccarToken(userId: string): string {
|
||||
// Generate deterministic token for auto-login
|
||||
const secret = process.env.JWT_SECRET || 'vip-coordinator-traccar-token';
|
||||
return crypto
|
||||
.createHmac('sha256', secret + '-token')
|
||||
@@ -1224,9 +814,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
.substring(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a VIP user to Traccar
|
||||
*/
|
||||
async syncUserToTraccar(user: User): Promise<boolean> {
|
||||
if (!user.email) return false;
|
||||
|
||||
@@ -1240,7 +827,7 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
user.name || user.email,
|
||||
password,
|
||||
isAdmin,
|
||||
token, // Include token for auto-login
|
||||
token,
|
||||
);
|
||||
|
||||
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 }> {
|
||||
const admins = await this.prisma.user.findMany({
|
||||
where: {
|
||||
@@ -1275,9 +859,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
return { synced, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get auto-login URL for Traccar (for admin users)
|
||||
*/
|
||||
async getTraccarAutoLoginUrl(user: User): Promise<{
|
||||
url: string;
|
||||
directAccess: boolean;
|
||||
@@ -1286,30 +867,22 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
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);
|
||||
|
||||
// Get the token for auto-login
|
||||
const token = this.generateTraccarToken(user.id);
|
||||
const baseUrl = this.traccarClient.getTraccarUrl();
|
||||
|
||||
// Return URL with token parameter for auto-login
|
||||
// Traccar supports ?token=xxx for direct authentication
|
||||
return {
|
||||
url: `${baseUrl}?token=${token}`,
|
||||
directAccess: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Traccar session cookie for a user (for proxy/iframe auth)
|
||||
*/
|
||||
async getTraccarSessionForUser(user: User): Promise<string | null> {
|
||||
if (user.role !== 'ADMINISTRATOR') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure user is synced
|
||||
await this.syncUserToTraccar(user);
|
||||
|
||||
const password = this.generateTraccarPassword(user.id);
|
||||
@@ -1318,9 +891,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
return session?.cookie || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Traccar needs initial setup
|
||||
*/
|
||||
async checkTraccarSetup(): Promise<{
|
||||
needsSetup: boolean;
|
||||
isAvailable: boolean;
|
||||
@@ -1334,11 +904,7 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
return { needsSetup, isAvailable };
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform initial Traccar setup
|
||||
*/
|
||||
async performTraccarSetup(adminEmail: string): Promise<boolean> {
|
||||
// Generate a secure password for the service account
|
||||
const servicePassword = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
const success = await this.traccarClient.performInitialSetup(
|
||||
@@ -1347,7 +913,6 @@ GPS Tracking Setup Instructions for ${driver.name}:
|
||||
);
|
||||
|
||||
if (success) {
|
||||
// Save the service account credentials to settings
|
||||
await this.updateSettings({
|
||||
traccarAdminUser: adminEmail,
|
||||
traccarAdminPassword: servicePassword,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,6 @@ import type {
|
||||
EnrollmentResponse,
|
||||
MyGpsStatus,
|
||||
DeviceQrInfo,
|
||||
LocationHistoryResponse,
|
||||
GpsTrip,
|
||||
GpsTripDetail,
|
||||
} from '@/types/gps';
|
||||
import toast from 'react-hot-toast';
|
||||
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() {
|
||||
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
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -106,60 +106,3 @@ export interface MyGpsStatus {
|
||||
lastActive?: 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user