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:
2026-02-08 18:57:11 +01:00
parent cc3375ef85
commit b80ffd3ca1

View File

@@ -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<void> {
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`);