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')
|
@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
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
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
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user