From b80ffd3ca1cb00f16ca0693a0d4902395e677593 Mon Sep 17 00:00:00 2001 From: kyle Date: Sun, 8 Feb 2026 18:57:11 +0100 Subject: [PATCH] fix: trip detection creating overlapping/micro trips (#23) - Increase idle threshold from 5 to 10 minutes for sparse GPS data - Only start new trips from positions AFTER the previous trip ended - Prevent duplicate trips at same timestamp with existence check - Auto-delete micro-trips (< 0.1 mi or < 60 seconds) - Use GPS timestamps for idle detection instead of wall clock Co-Authored-By: Claude Opus 4.6 --- backend/src/gps/gps.service.ts | 208 ++++++++++++++++++++------------- 1 file changed, 126 insertions(+), 82 deletions(-) diff --git a/backend/src/gps/gps.service.ts b/backend/src/gps/gps.service.ts index 0c2efb3..7f0975c 100644 --- a/backend/src/gps/gps.service.ts +++ b/backend/src/gps/gps.service.ts @@ -973,13 +973,16 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} // Trip Detection & Management // ============================================ - private static readonly IDLE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes + 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 5 min idle. + * Creates new trips when movement starts, completes them after 15 min idle. + * Only considers positions AFTER the last completed trip to prevent overlaps. */ private async detectTripsForDevice(deviceId: string): Promise { try { @@ -989,92 +992,113 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} }); const now = new Date(); - // Look at positions from the last 7 minutes to detect idle - const recentWindow = new Date(now.getTime() - 7 * 60 * 1000); - - const recentPositions = await this.prisma.gpsLocationHistory.findMany({ - where: { - deviceId, - timestamp: { gte: recentWindow }, - }, - orderBy: { timestamp: 'desc' }, - take: 50, - }); - - if (recentPositions.length === 0) return; if (!activeTrip) { - // No active trip - check if driver started moving - const movingPoint = recentPositions.find( + // 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 (movingPoint) { - // Find the earliest moving point in this batch - const earliestMoving = [...recentPositions] - .reverse() - .find((p) => (p.speed || 0) > GpsService.MOVING_SPEED_MPH); - - const startPoint = earliestMoving || movingPoint; - - await this.prisma.gpsTrip.create({ - data: { + if (firstMoving) { + // Double-check no trip already exists at this start time + const existingAtTime = await this.prisma.gpsTrip.findFirst({ + where: { deviceId, - status: TripStatus.ACTIVE, - startTime: startPoint.timestamp, - startLatitude: startPoint.latitude, - startLongitude: startPoint.longitude, - pointCount: recentPositions.length, + startTime: firstMoving.timestamp, }, }); - this.logger.log( - `[Trip] New trip started for device ${deviceId} at ${startPoint.timestamp.toISOString()}`, - ); - } - } else { - // Active trip exists - check if driver has been idle for 5+ minutes - const lastMovingPosition = recentPositions.find( - (p) => (p.speed || 0) > GpsService.IDLE_SPEED_MPH, - ); - - const latestPosition = recentPositions[0]; // Most recent - - if (!lastMovingPosition) { - // No movement in recent window - check if idle long enough - const idleSince = activeTrip.startTime; // Fallback - - // Check oldest position in window - if all are idle and window > 5 min - const oldestInWindow = recentPositions[recentPositions.length - 1]; - const windowDuration = now.getTime() - oldestInWindow.timestamp.getTime(); - - if (windowDuration >= GpsService.IDLE_THRESHOLD_MS) { - // Complete the trip - end time is the oldest idle position - await this.completeTrip(activeTrip.id, oldestInWindow); - } - } else { - // There's recent movement - check time since last movement - const timeSinceMovement = - now.getTime() - lastMovingPosition.timestamp.getTime(); - - if (timeSinceMovement >= GpsService.IDLE_THRESHOLD_MS) { - // Been idle for 5+ minutes after last movement - await this.completeTrip(activeTrip.id, lastMovingPosition); - } else { - // Still driving - update point count - const totalPoints = await this.prisma.gpsLocationHistory.count({ - where: { + if (!existingAtTime) { + await this.prisma.gpsTrip.create({ + data: { deviceId, - timestamp: { gte: activeTrip.startTime }, + status: TripStatus.ACTIVE, + startTime: firstMoving.timestamp, + startLatitude: firstMoving.latitude, + startLongitude: firstMoving.longitude, + pointCount: 1, }, }); - await this.prisma.gpsTrip.update({ - where: { id: activeTrip.id }, - data: { pointCount: totalPoints }, - }); + 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( @@ -1136,10 +1160,18 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} }); if (points.length < 2) { - await this.prisma.gpsTrip.update({ - where: { id: tripId }, - data: { status: TripStatus.FAILED, pointCount: points.length }, - }); + 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; } @@ -1154,9 +1186,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} ? speeds.reduce((sum, s) => sum + s, 0) / speeds.length : 0; - const durationSeconds = Math.round( - (trip.endTime.getTime() - trip.startTime.getTime()) / 1000, - ); + // tripDurationS already computed above for the minimum check // Try OSRM route matching let matchedRouteData: any = null; @@ -1203,12 +1233,19 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} } } + // 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, + durationSeconds: tripDurationS, topSpeedMph: Math.round(topSpeedMph), averageSpeedMph: Math.round(averageSpeedMph * 10) / 10, pointCount: points.length, @@ -1469,6 +1506,10 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} 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, @@ -1484,8 +1525,11 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} }); // 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); - tripsCreated++; + // 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++; } this.logger.log(`[Trip Backfill] Created and finalized ${tripsCreated} trips`);