diff --git a/backend/src/gps/gps.controller.ts b/backend/src/gps/gps.controller.ts index 89e6f1b..c990b21 100644 --- a/backend/src/gps/gps.controller.ts +++ b/backend/src/gps/gps.controller.ts @@ -174,7 +174,7 @@ export class GpsController { // ============================================ /** - * Get trips for a driver + * Get trips for a driver (powered by Traccar) */ @Get('trips/:driverId') @Roles(Role.ADMINISTRATOR) @@ -182,11 +182,10 @@ export class GpsController { @Param('driverId') driverId: string, @Query('from') fromStr?: string, @Query('to') toStr?: string, - @Query('status') status?: string, ) { const from = fromStr ? new Date(fromStr) : undefined; const to = toStr ? new Date(toStr) : undefined; - return this.gpsService.getDriverTrips(driverId, from, to, status as any); + return this.gpsService.getDriverTrips(driverId, from, to); } /** @@ -210,31 +209,6 @@ export class GpsController { return this.gpsService.getTripDetail(driverId, tripId); } - /** - * Merge two trips together - */ - @Post('trips/merge') - @Roles(Role.ADMINISTRATOR) - async mergeTrips( - @Body() body: { tripIdA: string; tripIdB: string }, - ) { - return this.gpsService.mergeTrips(body.tripIdA, body.tripIdB); - } - - /** - * Backfill trips from historical GPS data - */ - @Post('trips/backfill/:driverId') - @Roles(Role.ADMINISTRATOR) - async backfillTrips( - @Param('driverId') driverId: string, - @Body() body: { from?: string; to?: string }, - ) { - const from = body.from ? new Date(body.from) : undefined; - const to = body.to ? new Date(body.to) : undefined; - return this.gpsService.backfillTrips(driverId, from, to); - } - // ============================================ // Traccar Admin Access // ============================================ diff --git a/backend/src/gps/gps.service.ts b/backend/src/gps/gps.service.ts index 7f0975c..fefd416 100644 --- a/backend/src/gps/gps.service.ts +++ b/backend/src/gps/gps.service.ts @@ -18,7 +18,7 @@ import { LocationDataDto, } from './dto/location-response.dto'; import { UpdateGpsSettingsDto } from './dto/update-gps-settings.dto'; -import { GpsSettings, Prisma, TripStatus, User } from '@prisma/client'; +import { GpsSettings, User } from '@prisma/client'; import * as crypto from 'crypto'; @Injectable() @@ -959,8 +959,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} data: { lastActive: new Date(latestPosition.deviceTime) }, }); - // Trip detection for this device - await this.detectTripsForDevice(device.id); + // Trip detection handled by Traccar natively } catch (error) { this.logger.error(`[GPS Sync] Failed to sync positions for device ${device.traccarDeviceId}: ${error}`); } @@ -970,458 +969,49 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} } // ============================================ - // Trip Detection & Management + // Trip Management (powered by Traccar) // ============================================ - private static readonly IDLE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes - balances splitting real stops vs GPS gaps - private static readonly MOVING_SPEED_MPH = 5; // Speed to start a trip - private static readonly IDLE_SPEED_MPH = 3; // Speed below which counts as idle - private static readonly MIN_TRIP_DISTANCE_MI = 0.1; // Minimum trip distance to keep - private static readonly MIN_TRIP_DURATION_S = 60; // Minimum trip duration to keep (seconds) - /** - * Detect trips for a device after position sync. - * Creates new trips when movement starts, completes them after 15 min idle. - * Only considers positions AFTER the last completed trip to prevent overlaps. + * Generate a deterministic trip ID from device ID + start time */ - private async detectTripsForDevice(deviceId: string): Promise { - try { - // Find any ACTIVE trip for this device - const activeTrip = await this.prisma.gpsTrip.findFirst({ - where: { deviceId, status: TripStatus.ACTIVE }, - }); - - const now = new Date(); - - if (!activeTrip) { - // No active trip - find the most recent non-ACTIVE trip to avoid overlap - const lastTrip = await this.prisma.gpsTrip.findFirst({ - where: { - deviceId, - status: { not: TripStatus.ACTIVE }, - }, - orderBy: { endTime: 'desc' }, - }); - - // Only look at positions AFTER the last trip ended (or last 20 min if no trips) - const lookAfter = lastTrip?.endTime - ? new Date(lastTrip.endTime.getTime() + 1000) // 1 second after last trip ended - : new Date(now.getTime() - 20 * 60 * 1000); - - const newPositions = await this.prisma.gpsLocationHistory.findMany({ - where: { - deviceId, - timestamp: { gte: lookAfter }, - }, - orderBy: { timestamp: 'asc' }, - }); - - if (newPositions.length === 0) return; - - // Find the first position with meaningful movement - const firstMoving = newPositions.find( - (p) => (p.speed || 0) > GpsService.MOVING_SPEED_MPH, - ); - - if (firstMoving) { - // Double-check no trip already exists at this start time - const existingAtTime = await this.prisma.gpsTrip.findFirst({ - where: { - deviceId, - startTime: firstMoving.timestamp, - }, - }); - - if (!existingAtTime) { - await this.prisma.gpsTrip.create({ - data: { - deviceId, - status: TripStatus.ACTIVE, - startTime: firstMoving.timestamp, - startLatitude: firstMoving.latitude, - startLongitude: firstMoving.longitude, - pointCount: 1, - }, - }); - - this.logger.log( - `[Trip] New trip started for device ${deviceId} at ${firstMoving.timestamp.toISOString()}`, - ); - } - } - } else { - // Active trip exists - check if driver has been idle - // Get ALL positions since the trip started, most recent first - const tripPositions = await this.prisma.gpsLocationHistory.findMany({ - where: { - deviceId, - timestamp: { gte: activeTrip.startTime }, - }, - orderBy: { timestamp: 'desc' }, - take: 200, - }); - - if (tripPositions.length === 0) return; - - const latestPosition = tripPositions[0]; - - // Find the most recent position with movement - const lastMovingPosition = tripPositions.find( - (p) => (p.speed || 0) > GpsService.IDLE_SPEED_MPH, - ); - - if (!lastMovingPosition) { - // No movement at all since trip started - shouldn't happen, but clean up - const tripDuration = now.getTime() - activeTrip.startTime.getTime(); - if (tripDuration >= GpsService.IDLE_THRESHOLD_MS) { - this.logger.log(`[Trip] Deleting trip ${activeTrip.id} - no movement detected`); - await this.prisma.gpsTrip.delete({ where: { id: activeTrip.id } }); - } - return; - } - - // Check time since last movement (use latest GPS timestamp, not wall clock, - // to handle sparse data where the phone stops sending positions) - const latestTimestamp = latestPosition.timestamp; - const timeSinceMovement = latestTimestamp.getTime() - lastMovingPosition.timestamp.getTime(); - - // Also check wall clock gap - if no GPS data for 15+ min, the phone stopped reporting - const timeSinceLastData = now.getTime() - latestTimestamp.getTime(); - const effectiveIdleTime = Math.max(timeSinceMovement, timeSinceLastData); - - if (effectiveIdleTime >= GpsService.IDLE_THRESHOLD_MS) { - // Been idle long enough - complete the trip - await this.completeTrip(activeTrip.id, lastMovingPosition); - } else { - // Still driving - update point count - await this.prisma.gpsTrip.update({ - where: { id: activeTrip.id }, - data: { pointCount: tripPositions.length }, - }); - } - } - } catch (error) { - this.logger.error( - `[Trip] Detection failed for device ${deviceId}: ${error.message}`, - ); - } + private generateTripId(deviceId: string, startTime: string): string { + return crypto + .createHash('sha256') + .update(`${deviceId}:${startTime}`) + .digest('hex') + .substring(0, 24); } /** - * Complete a trip and queue OSRM finalization + * Map a Traccar trip to our frontend GpsTrip format */ - private async completeTrip( - tripId: string, - lastMovingPosition: { latitude: number; longitude: number; timestamp: Date }, - ): Promise { - await this.prisma.gpsTrip.update({ - where: { id: tripId }, - data: { - status: TripStatus.PROCESSING, - endTime: lastMovingPosition.timestamp, - endLatitude: lastMovingPosition.latitude, - endLongitude: lastMovingPosition.longitude, - }, - }); - - this.logger.log( - `[Trip] Trip ${tripId} completed, queuing finalization`, - ); - - // Fire and forget - don't block the sync cron - this.finalizeTrip(tripId).catch((err) => - this.logger.error(`[Trip] Finalization failed for ${tripId}: ${err.message}`), - ); - } - - /** - * Finalize a trip: compute OSRM route and stats, store results - */ - async finalizeTrip(tripId: string): Promise { - const trip = await this.prisma.gpsTrip.findUnique({ - where: { id: tripId }, - }); - - if (!trip || !trip.endTime) { - this.logger.warn(`[Trip] Cannot finalize trip ${tripId}: not found or no endTime`); - return; - } - - // Fetch all points for this trip - const points = await this.prisma.gpsLocationHistory.findMany({ - where: { - deviceId: trip.deviceId, - timestamp: { - gte: trip.startTime, - lte: trip.endTime, - }, - }, - orderBy: { timestamp: 'asc' }, - }); - - if (points.length < 2) { - this.logger.log(`[Trip] Deleting micro-trip ${tripId}: only ${points.length} points`); - await this.prisma.gpsTrip.delete({ where: { id: tripId } }); - return; - } - - // Check minimum duration - const tripDurationS = Math.round( - (trip.endTime.getTime() - trip.startTime.getTime()) / 1000, - ); - if (tripDurationS < GpsService.MIN_TRIP_DURATION_S) { - this.logger.log(`[Trip] Deleting micro-trip ${tripId}: only ${tripDurationS}s duration`); - await this.prisma.gpsTrip.delete({ where: { id: tripId } }); - return; - } - - // Compute stats from raw points - let topSpeedMph = 0; - const speeds = points.filter((p) => (p.speed || 0) > 0).map((p) => p.speed!); - if (speeds.length > 0) { - topSpeedMph = Math.max(...speeds); - } - const averageSpeedMph = - speeds.length > 0 - ? speeds.reduce((sum, s) => sum + s, 0) / speeds.length - : 0; - - // tripDurationS already computed above for the minimum check - - // Try OSRM route matching - let matchedRouteData: any = null; - let distanceMiles: number | null = null; - - try { - const matchResult = await this.osrmService.matchRoute( - points.map((p) => ({ - latitude: p.latitude, - longitude: p.longitude, - timestamp: p.timestamp, - speed: p.speed ?? undefined, - })), - ); - - if (matchResult) { - distanceMiles = matchResult.distance / 1609.34; - matchedRouteData = { - coordinates: matchResult.coordinates, - distance: distanceMiles, - duration: matchResult.duration, - confidence: matchResult.confidence, - }; - - this.logger.log( - `[Trip] Finalized trip ${tripId}: ${distanceMiles.toFixed(1)} mi, ` + - `${points.length} points, confidence ${(matchResult.confidence * 100).toFixed(0)}%`, - ); - } - } catch (error) { - this.logger.warn(`[Trip] OSRM failed for trip ${tripId}: ${error.message}`); - } - - // Fallback distance: haversine sum - if (distanceMiles === null) { - distanceMiles = 0; - for (let i = 1; i < points.length; i++) { - distanceMiles += this.calculateHaversineDistance( - points[i - 1].latitude, - points[i - 1].longitude, - points[i].latitude, - points[i].longitude, - ); - } - } - - // Delete trips that are too short in distance - if (distanceMiles < GpsService.MIN_TRIP_DISTANCE_MI) { - this.logger.log(`[Trip] Deleting micro-trip ${tripId}: only ${distanceMiles.toFixed(2)} mi`); - await this.prisma.gpsTrip.delete({ where: { id: tripId } }); - return; - } - - await this.prisma.gpsTrip.update({ - where: { id: tripId }, - data: { - status: matchedRouteData ? TripStatus.COMPLETED : TripStatus.FAILED, - distanceMiles: Math.round(distanceMiles * 10) / 10, - durationSeconds: tripDurationS, - topSpeedMph: Math.round(topSpeedMph), - averageSpeedMph: Math.round(averageSpeedMph * 10) / 10, - pointCount: points.length, - matchedRoute: matchedRouteData, - }, - }); - } - - /** - * Get trips for a driver - */ - async getDriverTrips( - driverId: string, - fromDate?: Date, - toDate?: Date, - status?: TripStatus, - ) { - const device = await this.prisma.gpsDevice.findUnique({ - where: { driverId }, - }); - - if (!device) { - throw new NotFoundException('Driver is not enrolled for GPS tracking'); - } - - const where: any = { deviceId: device.id }; - - if (fromDate || toDate) { - where.startTime = {}; - if (fromDate) where.startTime.gte = fromDate; - if (toDate) where.startTime.lte = toDate; - } - - if (status) { - where.status = status; - } - - return this.prisma.gpsTrip.findMany({ - where, - orderBy: { startTime: 'desc' }, - select: { - id: true, - deviceId: true, - status: true, - startTime: true, - endTime: true, - startLatitude: true, - startLongitude: true, - endLatitude: true, - endLongitude: true, - distanceMiles: true, - durationSeconds: true, - topSpeedMph: true, - averageSpeedMph: true, - pointCount: true, - // Exclude matchedRoute (heavy JSON) from list view - }, - }); - } - - /** - * Get a single trip with full detail (including matchedRoute and 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'); - } - - const trip = await this.prisma.gpsTrip.findFirst({ - where: { id: tripId, deviceId: device.id }, - }); - - if (!trip) { - throw new NotFoundException('Trip not found'); - } - - // Fetch raw points for this trip - const rawPoints = trip.endTime - ? await this.prisma.gpsLocationHistory.findMany({ - where: { - deviceId: device.id, - timestamp: { - gte: trip.startTime, - lte: trip.endTime, - }, - }, - orderBy: { timestamp: 'asc' }, - select: { - latitude: true, - longitude: true, - speed: true, - course: true, - battery: true, - timestamp: true, - }, - }) - : []; - + private mapTraccarTrip(deviceId: string, trip: any, status = 'COMPLETED') { return { - ...trip, - rawPoints, + 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 active trip for a driver (if any) + * Get trips for a driver (from Traccar trip report) */ - async getActiveTrip(driverId: string) { - const device = await this.prisma.gpsDevice.findUnique({ - where: { driverId }, - }); - - if (!device) { - return null; - } - - return this.prisma.gpsTrip.findFirst({ - where: { - deviceId: device.id, - status: TripStatus.ACTIVE, - }, - }); - } - - /** - * Merge two trips together (A is earlier, B is later) - */ - async mergeTrips(tripIdA: string, tripIdB: string) { - const tripA = await this.prisma.gpsTrip.findUnique({ where: { id: tripIdA } }); - const tripB = await this.prisma.gpsTrip.findUnique({ where: { id: tripIdB } }); - - if (!tripA || !tripB) { - throw new NotFoundException('One or both trips not found'); - } - - if (tripA.deviceId !== tripB.deviceId) { - throw new BadRequestException('Cannot merge trips from different devices'); - } - - // Determine which is earlier - const [earlier, later] = - tripA.startTime < tripB.startTime ? [tripA, tripB] : [tripB, tripA]; - - // Update the later trip to span both - await this.prisma.gpsTrip.update({ - where: { id: later.id }, - data: { - status: TripStatus.PROCESSING, - startTime: earlier.startTime, - startLatitude: earlier.startLatitude, - startLongitude: earlier.startLongitude, - matchedRoute: Prisma.DbNull, // Will be recomputed - }, - }); - - // Delete the earlier trip - await this.prisma.gpsTrip.delete({ where: { id: earlier.id } }); - - // Re-finalize the merged trip - await this.finalizeTrip(later.id); - - return this.prisma.gpsTrip.findUnique({ where: { id: later.id } }); - } - - /** - * Backfill trips from historical GPS data - */ - async backfillTrips( - driverId: string, - fromDate?: Date, - toDate?: Date, - ): Promise<{ tripsCreated: number }> { + async getDriverTrips(driverId: string, fromDate?: Date, toDate?: Date) { const device = await this.prisma.gpsDevice.findUnique({ where: { driverId }, }); @@ -1433,107 +1023,137 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} const to = toDate || new Date(); const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000); - this.logger.log( - `[Trip Backfill] Processing ${driverId} from ${from.toISOString()} to ${to.toISOString()}`, + const traccarTrips = await this.traccarClient.getTripReport( + device.traccarDeviceId, + from, + to, ); - // Delete existing trips in this range (avoid duplicates) - await this.prisma.gpsTrip.deleteMany({ - where: { - deviceId: device.id, - startTime: { gte: from, lte: 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 }, }); - // Fetch all points in range - const points = await this.prisma.gpsLocationHistory.findMany({ - where: { - deviceId: device.id, - timestamp: { gte: from, lte: to }, - }, - orderBy: { timestamp: 'asc' }, - }); - - if (points.length < 2) { - return { tripsCreated: 0 }; + if (!device) { + throw new NotFoundException('Driver is not enrolled for GPS tracking'); } - // Detect trips from historical data - const trips: Array<{ - startIdx: number; - endIdx: number; - }> = []; + // Search last 30 days for the matching trip + const to = new Date(); + const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000); - let tripStartIdx: number | null = null; - let lastMovingIdx: number | null = null; + const traccarTrips = await this.traccarClient.getTripReport( + device.traccarDeviceId, + from, + to, + ); - for (let i = 0; i < points.length; i++) { - const speed = points[i].speed || 0; + const trip = traccarTrips.find( + (t) => this.generateTripId(device.id, t.startTime) === tripId, + ); - if (speed > GpsService.MOVING_SPEED_MPH) { - if (tripStartIdx === null) { - tripStartIdx = i; - } - lastMovingIdx = i; - } else if (tripStartIdx !== null && lastMovingIdx !== null) { - // Check if we've been idle long enough - const timeSinceMovement = - points[i].timestamp.getTime() - points[lastMovingIdx].timestamp.getTime(); - - if (timeSinceMovement >= GpsService.IDLE_THRESHOLD_MS) { - // Trip ended at lastMovingIdx - trips.push({ startIdx: tripStartIdx, endIdx: lastMovingIdx }); - tripStartIdx = null; - lastMovingIdx = null; + 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}`); } } - // Close any open trip - if (tripStartIdx !== null && lastMovingIdx !== null) { - trips.push({ startIdx: tripStartIdx, endIdx: lastMovingIdx }); + 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; } - this.logger.log(`[Trip Backfill] Detected ${trips.length} trips in ${points.length} points`); + try { + const to = new Date(); + const from = new Date(to.getTime() - 60 * 60 * 1000); // Last hour - // Create trip records and finalize each - let tripsCreated = 0; + const traccarTrips = await this.traccarClient.getTripReport( + device.traccarDeviceId, + from, + to, + ); - for (const t of trips) { - const startPoint = points[t.startIdx]; - const endPoint = points[t.endIdx]; - const tripPoints = points.slice(t.startIdx, t.endIdx + 1); - - if (tripPoints.length < 2) continue; - - // Check minimum duration before creating - const duration = (endPoint.timestamp.getTime() - startPoint.timestamp.getTime()) / 1000; - if (duration < GpsService.MIN_TRIP_DURATION_S) continue; - - const trip = await this.prisma.gpsTrip.create({ - data: { - deviceId: device.id, - status: TripStatus.PROCESSING, - startTime: startPoint.timestamp, - endTime: endPoint.timestamp, - startLatitude: startPoint.latitude, - startLongitude: startPoint.longitude, - endLatitude: endPoint.latitude, - endLongitude: endPoint.longitude, - pointCount: tripPoints.length, - }, + // 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; }); - // Finalize (compute OSRM + stats) - await to respect OSRM rate limits - // finalizeTrip will delete the trip if distance is too short - await this.finalizeTrip(trip.id); - // Check if trip still exists (finalizeTrip may have deleted micro-trips) - const stillExists = await this.prisma.gpsTrip.findUnique({ where: { id: trip.id } }); - if (stillExists) tripsCreated++; - } + if (!activeTrip) return null; - this.logger.log(`[Trip Backfill] Created and finalized ${tripsCreated} trips`); - return { tripsCreated }; + return this.mapTraccarTrip(device.id, activeTrip, 'ACTIVE'); + } catch (error) { + this.logger.warn( + `Failed to check active trip for ${driverId}: ${error.message}`, + ); + return null; + } } /** diff --git a/backend/src/gps/traccar-client.service.ts b/backend/src/gps/traccar-client.service.ts index 86d0d34..c715d9a 100644 --- a/backend/src/gps/traccar-client.service.ts +++ b/backend/src/gps/traccar-client.service.ts @@ -321,7 +321,7 @@ export class TraccarClientService implements OnModuleInit { deviceId: number, from: Date, to: Date, - ): Promise { + ): Promise { const fromStr = from.toISOString(); const toStr = to.toISOString(); return this.request('get', `/api/reports/trips?deviceId=${deviceId}&from=${fromStr}&to=${toStr}`); @@ -567,3 +567,27 @@ export interface TraccarUser { token: string | null; attributes: Record; } + +export interface TraccarTrip { + deviceId: number; + deviceName: string; + distance: number; // meters + averageSpeed: number; // knots + maxSpeed: number; // knots + spentFuel: number; + startOdometer: number; + endOdometer: number; + startTime: string; + endTime: string; + startPositionId: number; + endPositionId: number; + startLat: number; + startLon: number; + endLat: number; + endLon: number; + startAddress: string | null; + endAddress: string | null; + duration: number; // milliseconds + driverUniqueId: string | null; + driverName: string | null; +} diff --git a/frontend/src/hooks/useGps.ts b/frontend/src/hooks/useGps.ts index 3b7ffa7..62ccda9 100644 --- a/frontend/src/hooks/useGps.ts +++ b/frontend/src/hooks/useGps.ts @@ -12,7 +12,6 @@ import type { LocationHistoryResponse, GpsTrip, GpsTripDetail, - TripStatus, } from '@/types/gps'; import toast from 'react-hot-toast'; import { queryKeys } from '@/lib/query-keys'; @@ -170,16 +169,15 @@ export function useDriverStats(driverId: string, from?: string, to?: string) { // ============================================ /** - * Get trips for a driver + * Get trips for a driver (powered by Traccar) */ -export function useDriverTrips(driverId: string | null, from?: string, to?: string, status?: TripStatus) { +export function useDriverTrips(driverId: string | null, from?: string, to?: string) { return useQuery({ - queryKey: ['gps', 'trips', driverId, from, to, status], + queryKey: ['gps', 'trips', driverId, from, to], queryFn: async () => { const params = new URLSearchParams(); if (from) params.append('from', from); if (to) params.append('to', to); - if (status) params.append('status', status); const { data } = await api.get(`/gps/trips/${driverId}?${params}`); return data; }, @@ -216,48 +214,6 @@ export function useActiveTrip(driverId: string | null) { }); } -/** - * Merge two trips - */ -export function useMergeTrips() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ tripIdA, tripIdB }: { tripIdA: string; tripIdB: string }) => { - const { data } = await api.post('/gps/trips/merge', { tripIdA, tripIdB }); - return data; - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['gps', 'trips'] }); - toast.success('Trips merged successfully'); - }, - onError: (error: any) => { - toast.error(error.response?.data?.message || 'Failed to merge trips'); - }, - }); -} - -/** - * Backfill trips from historical data - */ -export function useBackfillTrips() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ driverId, from, to }: { driverId: string; from?: string; to?: string }) => { - const { data } = await api.post(`/gps/trips/backfill/${driverId}`, { from, to }); - return data; - }, - onSuccess: (data: { tripsCreated: number }) => { - queryClient.invalidateQueries({ queryKey: ['gps', 'trips'] }); - toast.success(`Backfill complete: ${data.tripsCreated} trips created`); - }, - onError: (error: any) => { - toast.error(error.response?.data?.message || 'Failed to backfill trips'); - }, - }); -} - /** * Enroll a driver for GPS tracking */ diff --git a/frontend/src/pages/GpsTracking.tsx b/frontend/src/pages/GpsTracking.tsx index 09f1a08..6e02778 100644 --- a/frontend/src/pages/GpsTracking.tsx +++ b/frontend/src/pages/GpsTracking.tsx @@ -28,7 +28,6 @@ import { QrCode, Play, Calendar, - Merge, ToggleLeft, ToggleRight, Timer, @@ -51,8 +50,6 @@ import { useDriverLocationHistory, useDriverTrips, useActiveTrip, - useMergeTrips, - useBackfillTrips, } from '@/hooks/useGps'; import { Loading } from '@/components/Loading'; import { ErrorMessage } from '@/components/ErrorMessage'; @@ -223,8 +220,6 @@ interface StatsTabProps { setDateTo: (date: string) => void; toggledTripIds: Set; setToggledTripIds: (ids: Set) => void; - selectedTripIds: Set; - setSelectedTripIds: (ids: Set) => void; expandedDates: Set; setExpandedDates: (dates: Set) => void; } @@ -239,14 +234,9 @@ function StatsTab({ setDateTo, toggledTripIds, setToggledTripIds, - selectedTripIds, - setSelectedTripIds, expandedDates, setExpandedDates, }: StatsTabProps) { - const backfillTrips = useBackfillTrips(); - const mergeTrips = useMergeTrips(); - // Fetch trips for selected driver const { data: trips, isLoading: tripsLoading } = useDriverTrips( selectedDriverId || null, @@ -316,16 +306,6 @@ function StatsTab({ setToggledTripIds(newSet); }; - const handleSelectTrip = (tripId: string) => { - const newSet = new Set(selectedTripIds); - if (newSet.has(tripId)) { - newSet.delete(tripId); - } else { - newSet.add(tripId); - } - setSelectedTripIds(newSet); - }; - const handleToggleDate = (dateKey: string) => { const newSet = new Set(expandedDates); if (newSet.has(dateKey)) { @@ -336,23 +316,6 @@ function StatsTab({ setExpandedDates(newSet); }; - const handleMergeSelected = () => { - const selected = Array.from(selectedTripIds); - if (selected.length !== 2) { - toast.error('Please select exactly 2 adjacent trips to merge'); - return; - } - - mergeTrips.mutate( - { tripIdA: selected[0], tripIdB: selected[1] }, - { - onSuccess: () => { - setSelectedTripIds(new Set()); - }, - } - ); - }; - // Playback state const [playingTripId, setPlayingTripId] = useState(null); const [isPlaying, setIsPlaying] = useState(false); @@ -486,19 +449,6 @@ function StatsTab({ setPlaybackProgress(0); }; - const handleBackfill = () => { - if (!selectedDriverId) { - toast.error('Please select a driver first'); - return; - } - - backfillTrips.mutate({ - driverId: selectedDriverId, - from: dateFrom, - to: dateTo, - }); - }; - const getStatusColor = (status: TripStatus) => { switch (status) { case 'ACTIVE': @@ -540,7 +490,6 @@ function StatsTab({ onChange={(e) => { setSelectedDriverId(e.target.value); setToggledTripIds(new Set()); - setSelectedTripIds(new Set()); }} className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary" > @@ -573,14 +522,6 @@ function StatsTab({ className="w-full px-2 py-1 text-sm border border-input rounded-md bg-background text-foreground" /> - {/* Summary Stats Bar */} @@ -603,18 +544,6 @@ function StatsTab({ )} - {/* Merge Button */} - {selectedTripIds.size > 0 && ( - - )} - {/* Trip List */}
{!selectedDriverId ? ( @@ -649,15 +578,7 @@ function StatsTab({ : 'border-border bg-card hover:bg-accent' }`} > -
- {/* Checkbox for selection */} - handleSelectTrip(trip.id)} - className="mt-1 rounded border-gray-300 text-primary focus:ring-primary" - /> - +
{/* Time Range */}
@@ -670,6 +591,24 @@ function StatsTab({
+ {/* Addresses */} + {(trip.startAddress || trip.endAddress) && ( +
+ {trip.startAddress && ( +
+ + {trip.startAddress} +
+ )} + {trip.endAddress && ( +
+ + {trip.endAddress} +
+ )} +
+ )} + {/* Stats */}
@@ -958,7 +897,6 @@ export function GpsTracking() { 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 [selectedTripIds, setSelectedTripIds] = useState>(new Set()); const [expandedDates, setExpandedDates] = useState>(new Set()); // Check admin access @@ -1550,8 +1488,6 @@ export function GpsTracking() { setDateTo={setDateTo} toggledTripIds={toggledTripIds} setToggledTripIds={setToggledTripIds} - selectedTripIds={selectedTripIds} - setSelectedTripIds={setSelectedTripIds} expandedDates={expandedDates} setExpandedDates={setExpandedDates} /> diff --git a/frontend/src/types/gps.ts b/frontend/src/types/gps.ts index f784a48..c2e534c 100644 --- a/frontend/src/types/gps.ts +++ b/frontend/src/types/gps.ts @@ -124,6 +124,8 @@ export interface GpsTrip { topSpeedMph: number | null; averageSpeedMph: number | null; pointCount: number; + startAddress?: string | null; + endAddress?: string | null; } export interface GpsTripDetail extends GpsTrip {