diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 223fbe0..792f656 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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) diff --git a/backend/src/gps/gps.service.ts b/backend/src/gps/gps.service.ts index da509dd..f07c66c 100644 --- a/backend/src/gps/gps.service.ts +++ b/backend/src/gps/gps.service.ts @@ -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 { + // 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}`); } - // 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({ 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,