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 <noreply@anthropic.com>
This commit is contained in:
@@ -973,13 +973,16 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
// Trip Detection & Management
|
// 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 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 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.
|
* 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<void> {
|
private async detectTripsForDevice(deviceId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -989,93 +992,114 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
});
|
});
|
||||||
|
|
||||||
const now = new Date();
|
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) {
|
if (!activeTrip) {
|
||||||
// No active trip - check if driver started moving
|
// No active trip - find the most recent non-ACTIVE trip to avoid overlap
|
||||||
const movingPoint = recentPositions.find(
|
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,
|
(p) => (p.speed || 0) > GpsService.MOVING_SPEED_MPH,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (movingPoint) {
|
if (firstMoving) {
|
||||||
// Find the earliest moving point in this batch
|
// Double-check no trip already exists at this start time
|
||||||
const earliestMoving = [...recentPositions]
|
const existingAtTime = await this.prisma.gpsTrip.findFirst({
|
||||||
.reverse()
|
where: {
|
||||||
.find((p) => (p.speed || 0) > GpsService.MOVING_SPEED_MPH);
|
deviceId,
|
||||||
|
startTime: firstMoving.timestamp,
|
||||||
const startPoint = earliestMoving || movingPoint;
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingAtTime) {
|
||||||
await this.prisma.gpsTrip.create({
|
await this.prisma.gpsTrip.create({
|
||||||
data: {
|
data: {
|
||||||
deviceId,
|
deviceId,
|
||||||
status: TripStatus.ACTIVE,
|
status: TripStatus.ACTIVE,
|
||||||
startTime: startPoint.timestamp,
|
startTime: firstMoving.timestamp,
|
||||||
startLatitude: startPoint.latitude,
|
startLatitude: firstMoving.latitude,
|
||||||
startLongitude: startPoint.longitude,
|
startLongitude: firstMoving.longitude,
|
||||||
pointCount: recentPositions.length,
|
pointCount: 1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[Trip] New trip started for device ${deviceId} at ${startPoint.timestamp.toISOString()}`,
|
`[Trip] New trip started for device ${deviceId} at ${firstMoving.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 {
|
} else {
|
||||||
// There's recent movement - check time since last movement
|
// Active trip exists - check if driver has been idle
|
||||||
const timeSinceMovement =
|
// Get ALL positions since the trip started, most recent first
|
||||||
now.getTime() - lastMovingPosition.timestamp.getTime();
|
const tripPositions = await this.prisma.gpsLocationHistory.findMany({
|
||||||
|
|
||||||
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: {
|
where: {
|
||||||
deviceId,
|
deviceId,
|
||||||
timestamp: { gte: activeTrip.startTime },
|
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({
|
await this.prisma.gpsTrip.update({
|
||||||
where: { id: activeTrip.id },
|
where: { id: activeTrip.id },
|
||||||
data: { pointCount: totalPoints },
|
data: { pointCount: tripPositions.length },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`[Trip] Detection failed for device ${deviceId}: ${error.message}`,
|
`[Trip] Detection failed for device ${deviceId}: ${error.message}`,
|
||||||
@@ -1136,10 +1160,18 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (points.length < 2) {
|
if (points.length < 2) {
|
||||||
await this.prisma.gpsTrip.update({
|
this.logger.log(`[Trip] Deleting micro-trip ${tripId}: only ${points.length} points`);
|
||||||
where: { id: tripId },
|
await this.prisma.gpsTrip.delete({ where: { id: tripId } });
|
||||||
data: { status: TripStatus.FAILED, pointCount: points.length },
|
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;
|
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
|
? speeds.reduce((sum, s) => sum + s, 0) / speeds.length
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const durationSeconds = Math.round(
|
// tripDurationS already computed above for the minimum check
|
||||||
(trip.endTime.getTime() - trip.startTime.getTime()) / 1000,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Try OSRM route matching
|
// Try OSRM route matching
|
||||||
let matchedRouteData: any = null;
|
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({
|
await this.prisma.gpsTrip.update({
|
||||||
where: { id: tripId },
|
where: { id: tripId },
|
||||||
data: {
|
data: {
|
||||||
status: matchedRouteData ? TripStatus.COMPLETED : TripStatus.FAILED,
|
status: matchedRouteData ? TripStatus.COMPLETED : TripStatus.FAILED,
|
||||||
distanceMiles: Math.round(distanceMiles * 10) / 10,
|
distanceMiles: Math.round(distanceMiles * 10) / 10,
|
||||||
durationSeconds,
|
durationSeconds: tripDurationS,
|
||||||
topSpeedMph: Math.round(topSpeedMph),
|
topSpeedMph: Math.round(topSpeedMph),
|
||||||
averageSpeedMph: Math.round(averageSpeedMph * 10) / 10,
|
averageSpeedMph: Math.round(averageSpeedMph * 10) / 10,
|
||||||
pointCount: points.length,
|
pointCount: points.length,
|
||||||
@@ -1469,6 +1506,10 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
|
|
||||||
if (tripPoints.length < 2) continue;
|
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({
|
const trip = await this.prisma.gpsTrip.create({
|
||||||
data: {
|
data: {
|
||||||
deviceId: device.id,
|
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
|
// 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);
|
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`);
|
this.logger.log(`[Trip Backfill] Created and finalized ${tripsCreated} trips`);
|
||||||
|
|||||||
Reference in New Issue
Block a user