From 139cb4aebe40cefd00c79f29b913d657828cd9fa Mon Sep 17 00:00:00 2001 From: kyle Date: Mon, 9 Feb 2026 19:53:57 +0100 Subject: [PATCH] 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 --- backend/src/gps/gps.controller.ts | 94 +-- backend/src/gps/gps.module.ts | 3 +- backend/src/gps/gps.service.ts | 467 +---------- backend/src/gps/osrm.service.ts | 415 ---------- frontend/src/hooks/useGps.ts | 108 +-- frontend/src/pages/GpsTracking.tsx | 1194 +--------------------------- frontend/src/types/gps.ts | 57 -- 7 files changed, 52 insertions(+), 2286 deletions(-) delete mode 100644 backend/src/gps/osrm.service.ts diff --git a/backend/src/gps/gps.controller.ts b/backend/src/gps/gps.controller.ts index c990b21..945e585 100644 --- a/backend/src/gps/gps.controller.ts +++ b/backend/src/gps/gps.controller.ts @@ -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 // ============================================ diff --git a/backend/src/gps/gps.module.ts b/backend/src/gps/gps.module.ts index c14657f..ff96b46 100644 --- a/backend/src/gps/gps.module.ts +++ b/backend/src/gps/gps.module.ts @@ -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 {} diff --git a/backend/src/gps/gps.service.ts b/backend/src/gps/gps.service.ts index 5d93e6f..c12172d 100644 --- a/backend/src/gps/gps.service.ts +++ b/backend/src/gps/gps.service.ts @@ -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('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 { 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 { 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 { - 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 { - // 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 { 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 { 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 { - // 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, diff --git a/backend/src/gps/osrm.service.ts b/backend/src/gps/osrm.service.ts deleted file mode 100644 index 409773e..0000000 --- a/backend/src/gps/osrm.service.ts +++ /dev/null @@ -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 { - // 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 { - 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 = { - 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 { - 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 { - 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(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 { - return new Promise((resolve) => setTimeout(resolve, ms)); - } -} diff --git a/frontend/src/hooks/useGps.ts b/frontend/src/hooks/useGps.ts index 62ccda9..105492e 100644 --- a/frontend/src/hooks/useGps.ts +++ b/frontend/src/hooks/useGps.ts @@ -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({ @@ -111,109 +108,6 @@ export function useDriverLocations() { }); } -/** - * Get a specific driver's location - */ -export function useDriverLocation(driverId: string) { - return useQuery({ - 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({ - 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({ - 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({ - 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({ - 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 */ diff --git a/frontend/src/pages/GpsTracking.tsx b/frontend/src/pages/GpsTracking.tsx index 6e02778..083397a 100644 --- a/frontend/src/pages/GpsTracking.tsx +++ b/frontend/src/pages/GpsTracking.tsx @@ -1,903 +1,51 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet'; -import L from 'leaflet'; -import 'leaflet/dist/leaflet.css'; +import React, { useState, useMemo } from 'react'; import { QRCodeSVG } from 'qrcode.react'; import { MapPin, Settings, Users, RefreshCw, - Navigation, - Battery, - Clock, ExternalLink, X, Search, AlertCircle, CheckCircle, - XCircle, UserPlus, UserMinus, - Gauge, Activity, Smartphone, - Route, - Car, + Clock, Copy, QrCode, - Play, - Calendar, - ToggleLeft, - ToggleRight, - Timer, - ChevronDown, - ChevronRight, } from 'lucide-react'; import { useGpsStatus, useGpsSettings, useUpdateGpsSettings, - useDriverLocations, useGpsDevices, useEnrollDriver, useUnenrollDriver, - useDriverStats, useTraccarSetupStatus, useTraccarSetup, useOpenTraccarAdmin, useDeviceQr, - useDriverLocationHistory, - useDriverTrips, - useActiveTrip, } from '@/hooks/useGps'; import { Loading } from '@/components/Loading'; import { ErrorMessage } from '@/components/ErrorMessage'; -import { useQuery, useQueries } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { api } from '@/lib/api'; import { useAuth } from '@/contexts/AuthContext'; import type { Driver } from '@/types'; -import type { DriverLocation, LocationHistoryResponse, GpsTrip, GpsTripDetail, TripStatus } from '@/types/gps'; import toast from 'react-hot-toast'; -import { formatDistanceToNow, format, subDays } from 'date-fns'; - -// Fix Leaflet default marker icons -delete (L.Icon.Default.prototype as any)._getIconUrl; -L.Icon.Default.mergeOptions({ - iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png', - iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png', - shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png', -}); - -// Custom driver marker icon -const createDriverIcon = (isActive: boolean) => { - return L.divIcon({ - className: 'custom-driver-marker', - html: ` -
- - - -
- `, - iconSize: [32, 32], - iconAnchor: [16, 32], - popupAnchor: [0, -32], - }); -}; - -// Map auto-fit component — only fits bounds on initial load, not on every refresh -function MapFitBounds({ locations }: { locations: DriverLocation[] }) { - const map = useMap(); - const hasFitted = useRef(false); - - useEffect(() => { - if (hasFitted.current) return; - - const validLocations = locations.filter(d => d.location); - if (validLocations.length > 0) { - const bounds = L.latLngBounds( - validLocations.map(loc => [loc.location!.latitude, loc.location!.longitude]) - ); - map.fitBounds(bounds, { padding: [50, 50], maxZoom: 14 }); - hasFitted.current = true; - } - }, [locations, map]); - - return null; -} - -// Stats Tab Component -// Animated marker component that moves along a route -function AnimatedMarker({ position }: { position: [number, number] }) { - const map = useMap(); - const markerRef = useRef(null); - - useEffect(() => { - if (!markerRef.current) { - const icon = L.divIcon({ - className: 'animated-car-marker', - html: ` -
- `, - iconSize: [20, 20], - iconAnchor: [10, 10], - }); - markerRef.current = L.marker(position, { icon, zIndexOffset: 1000 }).addTo(map); - } else { - markerRef.current.setLatLng(position); - } - - return () => { - if (markerRef.current) { - map.removeLayer(markerRef.current); - markerRef.current = null; - } - }; - }, [map]); // Only create/destroy on mount - - // Update position smoothly - useEffect(() => { - if (markerRef.current) { - markerRef.current.setLatLng(position); - } - }, [position]); - - return null; -} - -// Calculate cumulative distances along a route -function buildCumulativeDistances(coords: [number, number][]): number[] { - const distances = [0]; - for (let i = 1; i < coords.length; i++) { - const [lat1, lng1] = coords[i - 1]; - const [lat2, lng2] = coords[i]; - const R = 3958.8; // miles - const dLat = ((lat2 - lat1) * Math.PI) / 180; - const dLng = ((lng2 - lng1) * 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(dLng / 2) * - Math.sin(dLng / 2); - const d = R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); - distances.push(distances[i - 1] + d); - } - return distances; -} - -// Interpolate position along route at given fraction (0-1) -function interpolatePosition( - coords: [number, number][], - cumDist: number[], - fraction: number, -): [number, number] { - const totalDist = cumDist[cumDist.length - 1]; - const targetDist = fraction * totalDist; - - // Find the segment - let i = 1; - while (i < cumDist.length && cumDist[i] < targetDist) i++; - if (i >= cumDist.length) return coords[coords.length - 1]; - - const segStart = cumDist[i - 1]; - const segEnd = cumDist[i]; - const segFraction = segEnd > segStart ? (targetDist - segStart) / (segEnd - segStart) : 0; - - const [lat1, lng1] = coords[i - 1]; - const [lat2, lng2] = coords[i]; - return [ - lat1 + (lat2 - lat1) * segFraction, - lng1 + (lng2 - lng1) * segFraction, - ]; -} - -interface StatsTabProps { - devices: any[]; - selectedDriverId: string; - setSelectedDriverId: (id: string) => void; - dateFrom: string; - dateTo: string; - setDateFrom: (date: string) => void; - setDateTo: (date: string) => void; - toggledTripIds: Set; - setToggledTripIds: (ids: Set) => void; - expandedDates: Set; - setExpandedDates: (dates: Set) => void; -} - -function StatsTab({ - devices, - selectedDriverId, - setSelectedDriverId, - dateFrom, - dateTo, - setDateFrom, - setDateTo, - toggledTripIds, - setToggledTripIds, - expandedDates, - setExpandedDates, -}: StatsTabProps) { - // Fetch trips for selected driver - const { data: trips, isLoading: tripsLoading } = useDriverTrips( - selectedDriverId || null, - dateFrom, - dateTo - ); - - // Fetch trip details for toggled trips using useQueries (avoids hooks-in-loop violation) - const toggledTripsArray = Array.from(toggledTripIds); - const tripDetailQueries = useQueries({ - queries: toggledTripsArray.map(tripId => ({ - queryKey: ['gps', 'trips', selectedDriverId, tripId], - queryFn: async () => { - const { data } = await api.get(`/gps/trips/${selectedDriverId}/${tripId}`); - return data as GpsTripDetail; - }, - enabled: !!selectedDriverId && !!tripId, - })), - }); - - // Calculate summary stats - const summaryStats = useMemo(() => { - if (!trips) return { totalTrips: 0, totalMiles: 0, totalDrivingTime: 0 }; - - const completedTrips = trips.filter(t => t.status === 'COMPLETED'); - return { - totalTrips: completedTrips.length, - totalMiles: completedTrips.reduce((sum, t) => sum + (t.distanceMiles || 0), 0), - totalDrivingTime: completedTrips.reduce((sum, t) => sum + (t.durationSeconds || 0), 0) / 60, // minutes - }; - }, [trips]); - - // Group trips by date - const tripsByDate = useMemo(() => { - if (!trips) return new Map(); - - const grouped = new Map(); - trips.forEach(trip => { - const dateKey = format(new Date(trip.startTime), 'yyyy-MM-dd'); - if (!grouped.has(dateKey)) { - grouped.set(dateKey, []); - } - grouped.get(dateKey)!.push(trip); - }); - - // Sort dates descending - return new Map([...grouped.entries()].sort((a, b) => b[0].localeCompare(a[0]))); - }, [trips]); - - // Auto-toggle most recent completed trip - React.useEffect(() => { - if (trips && trips.length > 0 && toggledTripIds.size === 0) { - const mostRecentCompleted = trips.find(t => t.status === 'COMPLETED'); - if (mostRecentCompleted) { - setToggledTripIds(new Set([mostRecentCompleted.id])); - } - } - }, [trips, toggledTripIds, setToggledTripIds]); - - const handleToggleTrip = (tripId: string) => { - const newSet = new Set(toggledTripIds); - if (newSet.has(tripId)) { - newSet.delete(tripId); - } else { - newSet.add(tripId); - } - setToggledTripIds(newSet); - }; - - const handleToggleDate = (dateKey: string) => { - const newSet = new Set(expandedDates); - if (newSet.has(dateKey)) { - newSet.delete(dateKey); - } else { - newSet.add(dateKey); - } - setExpandedDates(newSet); - }; - - // Playback state - const [playingTripId, setPlayingTripId] = useState(null); - const [isPlaying, setIsPlaying] = useState(false); - const [playbackSpeed, setPlaybackSpeed] = useState(4); // 4x default - const [playbackProgress, setPlaybackProgress] = useState(0); // 0-1 - const animationRef = useRef(null); - const lastFrameTimeRef = useRef(0); - - // Get the playing trip's detail - const playingTripQuery = useQuery({ - queryKey: ['gps', 'trips', selectedDriverId, playingTripId, 'playback'], - queryFn: async () => { - const { data } = await api.get(`/gps/trips/${selectedDriverId}/${playingTripId}`); - return data; - }, - enabled: !!selectedDriverId && !!playingTripId, - }); - - const playingTripData = playingTripQuery.data; - - // Pre-compute cumulative distances for playback - const playbackRouteData = useMemo(() => { - if (!playingTripData?.matchedRoute?.coordinates?.length) return null; - const coords = playingTripData.matchedRoute.coordinates; - const cumDist = buildCumulativeDistances(coords); - return { coords, cumDist, totalDist: cumDist[cumDist.length - 1] }; - }, [playingTripData]); - - // Current animated position - const animatedPosition = useMemo(() => { - if (!playbackRouteData) return null; - return interpolatePosition( - playbackRouteData.coords, - playbackRouteData.cumDist, - playbackProgress, - ); - }, [playbackRouteData, playbackProgress]); - - // Trail drawn up to current playback position - const playbackTrail = useMemo(() => { - if (!playbackRouteData || playbackProgress <= 0) return []; - const { coords, cumDist } = playbackRouteData; - const targetDist = playbackProgress * playbackRouteData.totalDist; - - const trail: [number, number][] = [coords[0]]; - for (let i = 1; i < coords.length; i++) { - if (cumDist[i] <= targetDist) { - trail.push(coords[i]); - } else { - // Interpolate the last point - const segFraction = - cumDist[i] > cumDist[i - 1] - ? (targetDist - cumDist[i - 1]) / (cumDist[i] - cumDist[i - 1]) - : 0; - const [lat1, lng1] = coords[i - 1]; - const [lat2, lng2] = coords[i]; - trail.push([ - lat1 + (lat2 - lat1) * segFraction, - lng1 + (lng2 - lng1) * segFraction, - ]); - break; - } - } - return trail; - }, [playbackRouteData, playbackProgress]); - - // Animation loop - useEffect(() => { - if (!isPlaying || !playingTripData?.durationSeconds) { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current); - animationRef.current = null; - } - return; - } - - const tripDurationMs = playingTripData.durationSeconds * 1000; - - const animate = (time: number) => { - if (!lastFrameTimeRef.current) { - lastFrameTimeRef.current = time; - } - - const deltaMs = time - lastFrameTimeRef.current; - lastFrameTimeRef.current = time; - - // Advance progress based on speed multiplier - const progressDelta = (deltaMs * playbackSpeed) / tripDurationMs; - - setPlaybackProgress((prev) => { - const next = prev + progressDelta; - if (next >= 1) { - setIsPlaying(false); - return 1; - } - return next; - }); - - animationRef.current = requestAnimationFrame(animate); - }; - - lastFrameTimeRef.current = 0; - animationRef.current = requestAnimationFrame(animate); - - return () => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current); - } - }; - }, [isPlaying, playbackSpeed, playingTripData?.durationSeconds]); - - const handlePlayTrip = (tripId: string) => { - if (playingTripId === tripId) { - // Toggle play/pause - setIsPlaying(!isPlaying); - } else { - // Start playing new trip - setPlayingTripId(tripId); - setPlaybackProgress(0); - setIsPlaying(true); - // Also toggle the trip on so we see the full route faintly - if (!toggledTripIds.has(tripId)) { - handleToggleTrip(tripId); - } - } - }; - - const handleStopPlayback = () => { - setIsPlaying(false); - setPlayingTripId(null); - setPlaybackProgress(0); - }; - - const getStatusColor = (status: TripStatus) => { - switch (status) { - case 'ACTIVE': - return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'; - case 'COMPLETED': - return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'; - case 'PROCESSING': - return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'; - case 'FAILED': - return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'; - default: - return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400'; - } - }; - - const formatDuration = (seconds: number | null) => { - if (!seconds) return 'N/A'; - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - if (hours > 0) { - return `${hours}h ${minutes}m`; - } - return `${minutes}m`; - }; - - // Polyline colors for different trips - const tripColors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6']; - - return ( -
-
- {/* Left Panel: Trip List */} -
- {/* Driver Selector */} -
- - -
- - {/* Date Range Picker */} -
-
- - setDateFrom(e.target.value)} - className="w-full px-2 py-1 text-sm border border-input rounded-md bg-background text-foreground" - /> -
-
- - setDateTo(e.target.value)} - className="w-full px-2 py-1 text-sm border border-input rounded-md bg-background text-foreground" - /> -
-
- - {/* Summary Stats Bar */} - {selectedDriverId && trips && ( -
-
-
-

{summaryStats.totalTrips}

-

Trips

-
-
-

{summaryStats.totalMiles.toFixed(1)}

-

Miles

-
-
-

{formatDuration(summaryStats.totalDrivingTime * 60)}

-

Driving

-
-
-
- )} - - {/* Trip List */} -
- {!selectedDriverId ? ( -

Select a driver to view trips

- ) : tripsLoading ? ( - - ) : trips && trips.length > 0 ? ( - Array.from(tripsByDate.entries()).map(([dateKey, dateTrips]) => ( -
- {/* Date Heading */} - - - {/* Trip Cards */} - {expandedDates.has(dateKey) && ( -
- {dateTrips.map((trip) => ( -
-
-
- {/* Time Range */} -
- - {format(new Date(trip.startTime), 'h:mm a')} - {trip.endTime && ` - ${format(new Date(trip.endTime), 'h:mm a')}`} - - - {trip.status} - -
- - {/* Addresses */} - {(trip.startAddress || trip.endAddress) && ( -
- {trip.startAddress && ( -
- - {trip.startAddress} -
- )} - {trip.endAddress && ( -
- - {trip.endAddress} -
- )} -
- )} - - {/* Stats */} -
-
- - {formatDuration(trip.durationSeconds)} -
-
- - {trip.distanceMiles?.toFixed(1) || 'N/A'} mi -
-
- - {trip.topSpeedMph?.toFixed(0) || 'N/A'} mph -
-
- - {/* Action Buttons */} -
- - {trip.status === 'COMPLETED' && ( - - )} -
-
-
-
- ))} -
- )} -
- )) - ) : ( -

No trips found for this date range

- )} -
-
- - {/* Right Panel: Map */} -
- - - - {/* Render polylines for each toggled trip */} - {toggledTripsArray.map((tripId, index) => { - const detailQuery = tripDetailQueries[index]; - if (!detailQuery?.data) return null; - - const detail = detailQuery.data; - const color = tripColors[index % tripColors.length]; - - // Use matched route if available, otherwise raw points - const positions = - detail.matchedRoute?.coordinates || - detail.rawPoints.map(p => [p.latitude, p.longitude] as [number, number]); - - if (positions.length < 2) return null; - - // Start marker (green) - const startIcon = L.divIcon({ - className: 'custom-trip-marker', - html: ` -
- `, - iconSize: [16, 16], - iconAnchor: [8, 8], - }); - - // End marker (red) - const endIcon = L.divIcon({ - className: 'custom-trip-marker', - html: ` -
- `, - iconSize: [16, 16], - iconAnchor: [8, 8], - }); - - return ( - - - -
-

- {format(new Date(detail.startTime), 'h:mm a')} - {detail.endTime && ` - ${format(new Date(detail.endTime), 'h:mm a')}`} -

-

Distance: {detail.distanceMiles?.toFixed(1) || 'N/A'} mi

-

Duration: {formatDuration(detail.durationSeconds)}

-

Top Speed: {detail.topSpeedMph?.toFixed(0) || 'N/A'} mph

-
-
-
- - {/* Start Marker */} - - -
-

Trip Start

-

{format(new Date(detail.startTime), 'h:mm a')}

-
-
-
- - {/* End Marker */} - {detail.endLatitude && detail.endLongitude && ( - - -
-

Trip End

-

{detail.endTime && format(new Date(detail.endTime), 'h:mm a')}

-
-
-
- )} -
- ); - })} - - {/* Playback trail (drawn progressively) */} - {playingTripId && playbackTrail.length > 1 && ( - - )} - - {/* Animated marker */} - {playingTripId && animatedPosition && ( - - )} -
- - {toggledTripIds.size === 0 && !playingTripId && ( -
-

Toggle trips to view on map

-
- )} -
-
- - {/* Playback Controls Bar */} - {playingTripId && ( -
-
- {/* Play/Pause */} - - - {/* Stop */} - - - {/* Timeline Scrubber */} -
- { - const val = parseInt(e.target.value) / 1000; - setPlaybackProgress(val); - }} - className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary" - /> -
- - {/* Progress Info */} -
- {playbackRouteData ? ( - `${(playbackProgress * playbackRouteData.totalDist).toFixed(1)} / ${playbackRouteData.totalDist.toFixed(1)} mi` - ) : ( - `${(playbackProgress * 100).toFixed(0)}%` - )} -
- - {/* Speed Control */} -
- {[1, 2, 4, 8, 16].map((speed) => ( - - ))} -
-
-
- )} -
- ); -} +import { formatDistanceToNow } from 'date-fns'; export function GpsTracking() { const { backendUser } = useAuth(); - const [activeTab, setActiveTab] = useState<'map' | 'devices' | 'settings' | 'stats'>('map'); + const [activeTab, setActiveTab] = useState<'devices' | 'settings'>('devices'); const [showEnrollModal, setShowEnrollModal] = useState(false); const [searchTerm, setSearchTerm] = useState(''); - const [selectedDriverId, setSelectedDriverId] = useState(''); const [enrollmentResult, setEnrollmentResult] = useState(null); const [showQrDriverId, setShowQrDriverId] = useState(null); - const [selectedDriverForTrail, setSelectedDriverForTrail] = useState(null); - const [showTrails, setShowTrails] = useState(true); - const [showFullHistory, setShowFullHistory] = useState(false); - - // Stats tab state - const [dateFrom, setDateFrom] = useState(format(subDays(new Date(), 7), 'yyyy-MM-dd')); - const [dateTo, setDateTo] = useState(format(new Date(), 'yyyy-MM-dd')); - const [toggledTripIds, setToggledTripIds] = useState>(new Set()); - const [expandedDates, setExpandedDates] = useState>(new Set()); // Check admin access if (backendUser?.role !== 'ADMINISTRATOR') { @@ -915,100 +63,10 @@ export function GpsTracking() { // Data hooks const { data: status, isLoading: statusLoading } = useGpsStatus(); const { data: settings, isLoading: settingsLoading } = useGpsSettings(); - const { data: locations, isLoading: locationsLoading, refetch: refetchLocations } = useDriverLocations(); const { data: devices, isLoading: devicesLoading } = useGpsDevices(); const { data: traccarStatus } = useTraccarSetupStatus(); - const { data: driverStats } = useDriverStats(selectedDriverId); const { data: qrInfo, isLoading: qrLoading } = useDeviceQr(showQrDriverId); - // Fetch location history for trail display - // If a specific driver is selected, show only their trail. Otherwise, fetch for all active drivers. - const activeDriverIds = useMemo(() => { - return locations?.filter(l => l.location).map(l => l.driverId) || []; - }, [locations]); - - // For simplicity, fetch history for the selected driver or the first active driver - const driverIdForTrail = selectedDriverForTrail || (showTrails ? activeDriverIds[0] : null); - - // Get active trip first, fall back to full history if "Show Full History" is toggled - const { data: activeTrip } = useActiveTrip(driverIdForTrail); - const activeTripId = activeTrip?.id || null; - const { data: activeTripDetail } = useQuery({ - queryKey: ['gps', 'trips', driverIdForTrail, activeTripId], - queryFn: async () => { - const { data } = await api.get(`/gps/trips/${driverIdForTrail}/${activeTripId}`); - return data; - }, - enabled: !!driverIdForTrail && !!activeTripId, - refetchInterval: 15000, - }); - const { data: locationHistory } = useDriverLocationHistory( - showFullHistory ? driverIdForTrail : null, - undefined, - undefined - ); - - // Extract route polyline coordinates and metadata - const routePolyline = useMemo(() => { - // First try active trip detail if available and not showing full history - if (!showFullHistory && activeTripDetail) { - // If we have OSRM matched route, use road-snapped coordinates - if (activeTripDetail.matchedRoute && activeTripDetail.matchedRoute.coordinates && activeTripDetail.matchedRoute.coordinates.length > 1) { - return { - positions: activeTripDetail.matchedRoute.coordinates, - isMatched: true, - distance: activeTripDetail.matchedRoute.distance, - duration: activeTripDetail.matchedRoute.duration, - confidence: activeTripDetail.matchedRoute.confidence, - isActiveTrip: true, - }; - } - - // Fall back to raw GPS points from active trip - if (activeTripDetail.rawPoints && activeTripDetail.rawPoints.length > 1) { - return { - positions: activeTripDetail.rawPoints.map(loc => [loc.latitude, loc.longitude] as [number, number]), - isMatched: false, - distance: activeTripDetail.distanceMiles || undefined, - duration: undefined, - confidence: undefined, - isActiveTrip: true, - }; - } - } - - // Fall back to full location history if enabled - if (showFullHistory && locationHistory) { - const history = locationHistory as LocationHistoryResponse; - - // If we have OSRM matched route, use road-snapped coordinates - if (history.matchedRoute && history.matchedRoute.coordinates && history.matchedRoute.coordinates.length > 1) { - return { - positions: history.matchedRoute.coordinates, - isMatched: true, - distance: history.matchedRoute.distance, - duration: history.matchedRoute.duration, - confidence: history.matchedRoute.confidence, - isActiveTrip: false, - }; - } - - // Fall back to raw GPS points - if (history.rawPoints && history.rawPoints.length > 1) { - return { - positions: history.rawPoints.map(loc => [loc.latitude, loc.longitude] as [number, number]), - isMatched: false, - distance: undefined, - duration: undefined, - confidence: undefined, - isActiveTrip: false, - }; - } - } - - return null; - }, [activeTripDetail, locationHistory, showFullHistory]); - // Mutations const updateSettings = useUpdateGpsSettings(); const traccarSetup = useTraccarSetup(); @@ -1032,18 +90,6 @@ export function GpsTracking() { d.name.toLowerCase().includes(searchTerm.toLowerCase()) ); - // Calculate center for map - const mapCenter: [number, number] = useMemo(() => { - const validLocations = locations?.filter(l => l.location) || []; - if (validLocations.length > 0) { - return [ - validLocations.reduce((sum, loc) => sum + loc.location!.latitude, 0) / validLocations.length, - validLocations.reduce((sum, loc) => sum + loc.location!.longitude, 0) / validLocations.length, - ]; - } - return [36.0, -79.0]; // Default to North Carolina area - }, [locations]); - const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); toast.success('Copied to clipboard'); @@ -1103,17 +149,9 @@ export function GpsTracking() {

GPS Tracking

-

Monitor driver locations in real-time

+

Manage driver devices and tracking settings

- {status?.traccarAvailable && ( )}
@@ -1141,6 +179,31 @@ export function GpsTracking() {
)} + {/* Traccar CTA Banner */} + {status?.traccarAvailable && ( +
+
+
+ +
+

Live Tracking, Trips & Reports

+

+ Use the Traccar dashboard for live map, trip history, route playback, and driver reports. +

+
+
+ +
+
+ )} + {/* Status Cards */}
@@ -1196,9 +259,7 @@ export function GpsTracking() {
{[ - { id: 'map', label: 'Live Map', icon: MapPin }, { id: 'devices', label: 'Devices', icon: Smartphone }, - { id: 'stats', label: 'Stats', icon: Gauge }, { id: 'settings', label: 'Settings', icon: Settings }, ].map(tab => ( -
- - - ))} - - )} - - {/* Map Legend & Controls */} -
-

Map Controls

-
-
-
- Active -
-
-
- Inactive -
-
-
- Road Route -
-
-
- GPS Trail -
- {routePolyline && routePolyline.isActiveTrip && ( -
- - Live Trip -
- )} - {routePolyline && routePolyline.distance != null && ( -
-
- Distance: {routePolyline.distance.toFixed(1)} mi -
- {routePolyline.confidence !== undefined && ( -
- Confidence: {(routePolyline.confidence * 100).toFixed(0)}% -
- )} -
- )} -
- - - {selectedDriverForTrail && ( - - )} -
-
-
- - {/* Active Drivers List */} -
-

Active Drivers ({locations?.filter(l => l.location).length || 0})

- {!locations || locations.filter(l => l.location).length === 0 ? ( -

No active drivers reporting location

- ) : ( -
- {locations.filter(l => l.location).map((driver) => ( -
-
-

{driver.driverName}

-

- {driver.location?.speed?.toFixed(0) || 0} mph -

-
- - Online - -
- ))} -
- )} -
-
- )} - {/* Devices Tab */} {activeTab === 'devices' && (
@@ -1476,23 +365,6 @@ export function GpsTracking() {
)} - {/* Stats Tab */} - {activeTab === 'stats' && ( - - )} - {/* Settings Tab */} {activeTab === 'settings' && (
@@ -1696,7 +568,7 @@ export function GpsTracking() { />

- Open Traccar Client app → tap the QR icon → scan this code + Open Traccar Client app {'→'} tap the QR icon {'→'} scan this code

diff --git a/frontend/src/types/gps.ts b/frontend/src/types/gps.ts index c2e534c..8ccd212 100644 --- a/frontend/src/types/gps.ts +++ b/frontend/src/types/gps.ts @@ -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; -}