fix: improve GPS position sync reliability and add route trails (#21)

Backend:
- Increase sync overlap buffer from 5s to 30s to catch late-arriving positions
- Add position history endpoint GET /gps/locations/:driverId/history
- Add logging for position sync counts (returned vs inserted)

Frontend:
- Add useDriverLocationHistory hook for fetching position trails
- Draw Polyline route trails on GPS map for each tracked driver
- Historical positions shown as semi-transparent paths behind live markers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 16:42:41 +01:00
parent 3bc9cd0bca
commit 4dbb899409
4 changed files with 160 additions and 11 deletions

View File

@@ -130,6 +130,21 @@ export class GpsController {
return location;
}
/**
* Get a driver's location history (for route trail display)
*/
@Get('locations/:driverId/history')
@Roles(Role.ADMINISTRATOR)
async getDriverLocationHistory(
@Param('driverId') driverId: string,
@Query('from') fromStr?: string,
@Query('to') toStr?: string,
) {
const from = fromStr ? new Date(fromStr) : undefined;
const to = toStr ? new Date(toStr) : undefined;
return this.gpsService.getDriverLocationHistory(driverId, from, to);
}
/**
* Get a driver's stats (Admin viewing any driver)
*/

View File

@@ -471,6 +471,49 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
};
}
/**
* Get driver location history (for route trail display)
*/
async getDriverLocationHistory(
driverId: string,
fromDate?: Date,
toDate?: Date,
): Promise<LocationDataDto[]> {
const device = await this.prisma.gpsDevice.findUnique({
where: { driverId },
});
if (!device) {
throw new NotFoundException('Driver is not enrolled for GPS tracking');
}
// Default to last 4 hours if no date range specified
const to = toDate || new Date();
const from = fromDate || new Date(to.getTime() - 4 * 60 * 60 * 1000);
const locations = await this.prisma.gpsLocationHistory.findMany({
where: {
deviceId: device.id,
timestamp: {
gte: from,
lte: to,
},
},
orderBy: { timestamp: 'asc' },
});
return locations.map((loc) => ({
latitude: loc.latitude,
longitude: loc.longitude,
altitude: loc.altitude,
speed: loc.speed,
course: loc.course,
accuracy: loc.accuracy,
battery: loc.battery,
timestamp: loc.timestamp,
}));
}
/**
* Get driver's own stats (for driver self-view)
*/
@@ -605,28 +648,37 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
},
});
if (devices.length === 0) return;
if (devices.length === 0) {
this.logger.debug('[GPS Sync] No active devices to sync');
return;
}
const now = new Date();
this.logger.log(`[GPS Sync] Starting sync for ${devices.length} active devices`);
for (const device of devices) {
try {
// Calculate "since" from device's last active time with 5s overlap buffer
// Calculate "since" from device's last active time with 30s overlap buffer
// (increased from 5s to catch late-arriving positions)
// Falls back to 2 minutes ago if no lastActive
const since = device.lastActive
? new Date(device.lastActive.getTime() - 5000)
? new Date(device.lastActive.getTime() - 30000)
: new Date(now.getTime() - 120000);
this.logger.debug(`[GPS Sync] Fetching positions for device ${device.traccarDeviceId} since ${since.toISOString()}`);
const positions = await this.traccarClient.getPositionHistory(
device.traccarDeviceId,
since,
now,
);
this.logger.log(`[GPS Sync] Device ${device.traccarDeviceId}: Retrieved ${positions.length} positions from Traccar`);
if (positions.length === 0) continue;
// Batch insert with skipDuplicates (unique constraint on deviceId+timestamp)
await this.prisma.gpsLocationHistory.createMany({
const insertResult = await this.prisma.gpsLocationHistory.createMany({
data: positions.map((p) => ({
deviceId: device.id,
latitude: p.latitude,
@@ -641,6 +693,13 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
skipDuplicates: true,
});
const inserted = insertResult.count;
const skipped = positions.length - inserted;
this.logger.log(
`[GPS Sync] Device ${device.traccarDeviceId}: ` +
`Inserted ${inserted} new positions, skipped ${skipped} duplicates`
);
// Update lastActive to the latest position timestamp
const latestPosition = positions.reduce((latest, p) =>
new Date(p.deviceTime) > new Date(latest.deviceTime) ? p : latest
@@ -650,9 +709,11 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
data: { lastActive: new Date(latestPosition.deviceTime) },
});
} catch (error) {
this.logger.error(`Failed to sync positions for device ${device.traccarDeviceId}: ${error}`);
this.logger.error(`[GPS Sync] Failed to sync positions for device ${device.traccarDeviceId}: ${error}`);
}
}
this.logger.log('[GPS Sync] Sync completed');
}
/**

View File

@@ -123,6 +123,24 @@ export function useDriverLocation(driverId: string) {
});
}
/**
* Get driver location history (for route trail display)
*/
export function useDriverLocationHistory(driverId: string | null, from?: string, to?: string) {
return useQuery<Array<{ latitude: number; longitude: number; speed: number; timestamp: Date }>>({
queryKey: ['gps', 'locations', driverId, 'history', from, to],
queryFn: async () => {
const params = new URLSearchParams();
if (from) params.append('from', from);
if (to) params.append('to', to);
const { data } = await api.get(`/gps/locations/${driverId}/history?${params}`);
return data;
},
enabled: !!driverId,
refetchInterval: 30000, // Refresh every 30 seconds
});
}
/**
* Get driver stats
*/

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { QRCodeSVG } from 'qrcode.react';
@@ -40,6 +40,7 @@ import {
useTraccarSetup,
useOpenTraccarAdmin,
useDeviceQr,
useDriverLocationHistory,
} from '@/hooks/useGps';
import { Loading } from '@/components/Loading';
import { ErrorMessage } from '@/components/ErrorMessage';
@@ -115,6 +116,8 @@ export function GpsTracking() {
const [selectedDriverId, setSelectedDriverId] = useState<string>('');
const [enrollmentResult, setEnrollmentResult] = useState<any>(null);
const [showQrDriverId, setShowQrDriverId] = useState<string | null>(null);
const [selectedDriverForTrail, setSelectedDriverForTrail] = useState<string | null>(null);
const [showTrails, setShowTrails] = useState(true);
// Check admin access
if (backendUser?.role !== 'ADMINISTRATOR') {
@@ -138,6 +141,16 @@ export function GpsTracking() {
const { data: driverStats } = useDriverStats(selectedDriverId);
const { data: qrInfo, isLoading: qrLoading } = useDeviceQr(showQrDriverId);
// Fetch location history for trail display
// If a specific driver is selected, show only their trail. Otherwise, fetch for all active drivers.
const activeDriverIds = useMemo(() => {
return locations?.filter(l => l.location).map(l => l.driverId) || [];
}, [locations]);
// For simplicity, fetch history for the selected driver or the first active driver
const driverIdForTrail = selectedDriverForTrail || (showTrails ? activeDriverIds[0] : null);
const { data: locationHistory } = useDriverLocationHistory(driverIdForTrail, undefined, undefined);
// Mutations
const updateSettings = useUpdateGpsSettings();
const traccarSetup = useTraccarSetup();
@@ -368,6 +381,20 @@ export function GpsTracking() {
maxZoom={19}
/>
{locations && <MapFitBounds locations={locations} />}
{/* Route trail polyline */}
{showTrails && locationHistory && locationHistory.length > 1 && (
<Polyline
positions={locationHistory.map(loc => [loc.latitude, loc.longitude])}
pathOptions={{
color: '#3b82f6',
weight: 3,
opacity: 0.6,
}}
/>
)}
{/* Current position markers */}
{locations?.filter(l => l.location).map((driver) => (
<Marker
key={driver.driverId}
@@ -397,6 +424,12 @@ export function GpsTracking() {
<span>{formatDistanceToNow(new Date(driver.location!.timestamp), { addSuffix: true })}</span>
</div>
</div>
<button
onClick={() => setSelectedDriverForTrail(driver.driverId)}
className="mt-2 w-full px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600"
>
Show Route Trail
</button>
</div>
</Popup>
</Marker>
@@ -404,18 +437,40 @@ export function GpsTracking() {
</MapContainer>
)}
{/* Map Legend */}
{/* Map Legend & Controls */}
<div className="absolute bottom-4 left-4 bg-card/90 backdrop-blur-sm border border-border rounded-lg p-3 z-[1000]">
<h4 className="text-sm font-medium text-foreground mb-2">Legend</h4>
<div className="space-y-1 text-sm">
<h4 className="text-sm font-medium text-foreground mb-2">Map Controls</h4>
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500"></div>
<span className="text-muted-foreground">Active</span>
<span className="text-xs text-muted-foreground">Active</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-gray-400"></div>
<span className="text-muted-foreground">Inactive</span>
<span className="text-xs text-muted-foreground">Inactive</span>
</div>
<div className="flex items-center gap-2">
<div className="w-12 h-0.5 bg-blue-500 opacity-60"></div>
<span className="text-xs text-muted-foreground">Route Trail</span>
</div>
<hr className="border-border" />
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showTrails}
onChange={(e) => setShowTrails(e.target.checked)}
className="rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-xs text-foreground">Show Trails</span>
</label>
{selectedDriverForTrail && (
<button
onClick={() => setSelectedDriverForTrail(null)}
className="text-xs text-primary hover:text-primary/80 underline w-full text-left"
>
Clear selected trail
</button>
)}
</div>
</div>
</div>