fix: rewrite GPS stats to calculate from stored history (#22)

- Replace Traccar summary API dependency with local Haversine distance calculation
- Calculate mileage from GpsLocationHistory table (sum consecutive positions)
- Filter out GPS jitter (<0.01mi), gaps (>10min), and unrealistic speeds (>100mph)
- Calculate trips, driving time, average/top speed from position history
- Add detailed stats logging for debugging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 16:48:41 +01:00
parent 4dbb899409
commit d93919910b
2 changed files with 179 additions and 46 deletions

View File

@@ -420,7 +420,7 @@ model GpsLocationHistory {
latitude Float latitude Float
longitude Float longitude Float
altitude Float? altitude Float?
speed Float? // km/h speed Float? // mph (converted from knots during sync)
course Float? // Bearing in degrees course Float? // Bearing in degrees
accuracy Float? // Meters accuracy Float? // Meters
battery Float? // Battery percentage (0-100) battery Float? // Battery percentage (0-100)

View File

@@ -514,8 +514,126 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
})); }));
} }
/**
* Calculate distance between two GPS coordinates using Haversine formula
* Returns distance in miles
*/
private calculateHaversineDistance(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
): number {
const R = 3958.8; // Earth's radius in miles
const dLat = this.toRadians(lat2 - lat1);
const dLon = this.toRadians(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRadians(lat1)) *
Math.cos(this.toRadians(lat2)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
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<number> {
// Get all positions in chronological order
const positions = await this.prisma.gpsLocationHistory.findMany({
where: {
deviceId,
timestamp: {
gte: from,
lte: to,
},
},
orderBy: { timestamp: 'asc' },
select: {
latitude: true,
longitude: true,
timestamp: true,
speed: true,
accuracy: true,
},
});
if (positions.length < 2) {
return 0;
}
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;
}
// Calculate distance between consecutive points
const distance = this.calculateHaversineDistance(
prev.latitude,
prev.longitude,
curr.latitude,
curr.longitude,
);
// 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;
}
// Filter out GPS jitter (movements < 0.01 miles / ~50 feet)
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) * Get driver's own stats (for driver self-view)
* UPDATED: Now calculates distance from GpsLocationHistory table instead of Traccar API
*/ */
async getDriverStats( async getDriverStats(
driverId: string, driverId: string,
@@ -542,58 +660,66 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
const to = toDate || new Date(); const to = toDate || new Date();
const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000); const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000);
// Get summary from Traccar this.logger.log(
let totalMiles = 0; `[Stats] Calculating stats for driver ${device.driver.name} from ${from.toISOString()} to ${to.toISOString()}`,
);
// Calculate total distance from stored position history
const totalMiles = await this.calculateDistanceFromHistory(device.id, from, to);
// Get all positions for speed/time analysis
const allPositions = await this.prisma.gpsLocationHistory.findMany({
where: {
deviceId: device.id,
timestamp: {
gte: from,
lte: to,
},
},
orderBy: { timestamp: 'asc' },
});
// Calculate top speed and driving time from position history
let topSpeedMph = 0; let topSpeedMph = 0;
let topSpeedTimestamp: Date | null = null; let topSpeedTimestamp: Date | null = null;
let totalTrips = 0;
let totalDrivingMinutes = 0; let totalDrivingMinutes = 0;
try { // Identify "trips" (sequences of positions with speed > 5 mph)
const summary = await this.traccarClient.getSummaryReport( let currentTripStart: Date | null = null;
device.traccarDeviceId, let totalTrips = 0;
from,
to,
);
if (summary.length > 0) { for (const pos of allPositions) {
const report = summary[0]; const speedMph = pos.speed || 0; // Speed is already in mph (converted during sync)
// Distance is in meters, convert to miles
totalMiles = (report.distance || 0) / 1609.344; // Track top speed
topSpeedMph = this.traccarClient.knotsToMph(report.maxSpeed || 0); if (speedMph > topSpeedMph) {
// Engine hours in milliseconds, convert to minutes topSpeedMph = speedMph;
totalDrivingMinutes = Math.round((report.engineHours || 0) / 60000); topSpeedTimestamp = pos.timestamp;
} }
// Get trips for additional stats // Trip detection: speed > 5 mph = driving
const trips = await this.traccarClient.getTripReport( if (speedMph > 5) {
device.traccarDeviceId, if (!currentTripStart) {
from, // Start of new trip
to, currentTripStart = pos.timestamp;
); totalTrips++;
totalTrips = trips.length; }
} else if (currentTripStart) {
// Find top speed timestamp from positions // End of trip (stopped)
const positions = await this.traccarClient.getPositionHistory( const tripDurationMs = pos.timestamp.getTime() - currentTripStart.getTime();
device.traccarDeviceId, totalDrivingMinutes += tripDurationMs / 60000;
from, currentTripStart = null;
to,
);
let maxSpeed = 0;
for (const pos of positions) {
const speedMph = this.traccarClient.knotsToMph(pos.speed || 0);
if (speedMph > maxSpeed) {
maxSpeed = speedMph;
topSpeedTimestamp = new Date(pos.deviceTime);
} }
} }
topSpeedMph = maxSpeed;
} catch (error) { // Close last trip if still driving
this.logger.warn(`Failed to fetch stats from Traccar: ${error}`); if (currentTripStart && allPositions.length > 0) {
const lastPos = allPositions[allPositions.length - 1];
const tripDurationMs = lastPos.timestamp.getTime() - currentTripStart.getTime();
totalDrivingMinutes += tripDurationMs / 60000;
} }
// Get recent locations from our database // Get recent locations for display (last 100)
const recentLocations = await this.prisma.gpsLocationHistory.findMany({ const recentLocations = await this.prisma.gpsLocationHistory.findMany({
where: { where: {
deviceId: device.id, deviceId: device.id,
@@ -606,6 +732,15 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
take: 100, take: 100,
}); });
const averageSpeedMph =
totalDrivingMinutes > 0
? totalMiles / (totalDrivingMinutes / 60)
: 0;
this.logger.log(
`[Stats] Results: ${totalMiles.toFixed(1)} miles, ${topSpeedMph.toFixed(0)} mph top speed, ${totalTrips} trips, ${totalDrivingMinutes.toFixed(0)} min driving`,
);
return { return {
driverId, driverId,
driverName: device.driver.name, driverName: device.driver.name,
@@ -617,11 +752,9 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
totalMiles: Math.round(totalMiles * 10) / 10, totalMiles: Math.round(totalMiles * 10) / 10,
topSpeedMph: Math.round(topSpeedMph), topSpeedMph: Math.round(topSpeedMph),
topSpeedTimestamp, topSpeedTimestamp,
averageSpeedMph: totalDrivingMinutes > 0 averageSpeedMph: Math.round(averageSpeedMph * 10) / 10,
? Math.round((totalMiles / (totalDrivingMinutes / 60)) * 10) / 10
: 0,
totalTrips, totalTrips,
totalDrivingMinutes, totalDrivingMinutes: Math.round(totalDrivingMinutes),
}, },
recentLocations: recentLocations.map((loc) => ({ recentLocations: recentLocations.map((loc) => ({
latitude: loc.latitude, latitude: loc.latitude,