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:
@@ -420,7 +420,7 @@ model GpsLocationHistory {
|
||||
latitude Float
|
||||
longitude Float
|
||||
altitude Float?
|
||||
speed Float? // km/h
|
||||
speed Float? // mph (converted from knots during sync)
|
||||
course Float? // Bearing in degrees
|
||||
accuracy Float? // Meters
|
||||
battery Float? // Battery percentage (0-100)
|
||||
|
||||
@@ -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)
|
||||
* UPDATED: Now calculates distance from GpsLocationHistory table instead of Traccar API
|
||||
*/
|
||||
async getDriverStats(
|
||||
driverId: string,
|
||||
@@ -542,58 +660,66 @@ 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);
|
||||
|
||||
// Get summary from Traccar
|
||||
let totalMiles = 0;
|
||||
this.logger.log(
|
||||
`[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 topSpeedTimestamp: Date | null = null;
|
||||
let totalTrips = 0;
|
||||
let totalDrivingMinutes = 0;
|
||||
|
||||
try {
|
||||
const summary = await this.traccarClient.getSummaryReport(
|
||||
device.traccarDeviceId,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
// Identify "trips" (sequences of positions with speed > 5 mph)
|
||||
let currentTripStart: Date | null = null;
|
||||
let totalTrips = 0;
|
||||
|
||||
if (summary.length > 0) {
|
||||
const report = summary[0];
|
||||
// Distance is in meters, convert to miles
|
||||
totalMiles = (report.distance || 0) / 1609.344;
|
||||
topSpeedMph = this.traccarClient.knotsToMph(report.maxSpeed || 0);
|
||||
// Engine hours in milliseconds, convert to minutes
|
||||
totalDrivingMinutes = Math.round((report.engineHours || 0) / 60000);
|
||||
for (const pos of allPositions) {
|
||||
const speedMph = pos.speed || 0; // Speed is already in mph (converted during sync)
|
||||
|
||||
// Track top speed
|
||||
if (speedMph > topSpeedMph) {
|
||||
topSpeedMph = speedMph;
|
||||
topSpeedTimestamp = pos.timestamp;
|
||||
}
|
||||
|
||||
// Get trips for additional stats
|
||||
const trips = await this.traccarClient.getTripReport(
|
||||
device.traccarDeviceId,
|
||||
from,
|
||||
to,
|
||||
);
|
||||
totalTrips = trips.length;
|
||||
|
||||
// Find top speed timestamp from positions
|
||||
const positions = await this.traccarClient.getPositionHistory(
|
||||
device.traccarDeviceId,
|
||||
from,
|
||||
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);
|
||||
// Trip detection: speed > 5 mph = driving
|
||||
if (speedMph > 5) {
|
||||
if (!currentTripStart) {
|
||||
// Start of new trip
|
||||
currentTripStart = pos.timestamp;
|
||||
totalTrips++;
|
||||
}
|
||||
} else if (currentTripStart) {
|
||||
// End of trip (stopped)
|
||||
const tripDurationMs = pos.timestamp.getTime() - currentTripStart.getTime();
|
||||
totalDrivingMinutes += tripDurationMs / 60000;
|
||||
currentTripStart = null;
|
||||
}
|
||||
}
|
||||
topSpeedMph = maxSpeed;
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to fetch stats from Traccar: ${error}`);
|
||||
|
||||
// Close last trip if still driving
|
||||
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({
|
||||
where: {
|
||||
deviceId: device.id,
|
||||
@@ -606,6 +732,15 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
||||
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 {
|
||||
driverId,
|
||||
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,
|
||||
topSpeedMph: Math.round(topSpeedMph),
|
||||
topSpeedTimestamp,
|
||||
averageSpeedMph: totalDrivingMinutes > 0
|
||||
? Math.round((totalMiles / (totalDrivingMinutes / 60)) * 10) / 10
|
||||
: 0,
|
||||
averageSpeedMph: Math.round(averageSpeedMph * 10) / 10,
|
||||
totalTrips,
|
||||
totalDrivingMinutes,
|
||||
totalDrivingMinutes: Math.round(totalDrivingMinutes),
|
||||
},
|
||||
recentLocations: recentLocations.map((loc) => ({
|
||||
latitude: loc.latitude,
|
||||
|
||||
Reference in New Issue
Block a user