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
|
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)
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
} 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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get recent locations from our database
|
// 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 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user