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:
@@ -130,6 +130,21 @@ export class GpsController {
|
|||||||
return location;
|
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)
|
* Get a driver's stats (Admin viewing any driver)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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)
|
* 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();
|
const now = new Date();
|
||||||
|
this.logger.log(`[GPS Sync] Starting sync for ${devices.length} active devices`);
|
||||||
|
|
||||||
for (const device of devices) {
|
for (const device of devices) {
|
||||||
try {
|
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
|
// Falls back to 2 minutes ago if no lastActive
|
||||||
const since = device.lastActive
|
const since = device.lastActive
|
||||||
? new Date(device.lastActive.getTime() - 5000)
|
? new Date(device.lastActive.getTime() - 30000)
|
||||||
: new Date(now.getTime() - 120000);
|
: 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(
|
const positions = await this.traccarClient.getPositionHistory(
|
||||||
device.traccarDeviceId,
|
device.traccarDeviceId,
|
||||||
since,
|
since,
|
||||||
now,
|
now,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.logger.log(`[GPS Sync] Device ${device.traccarDeviceId}: Retrieved ${positions.length} positions from Traccar`);
|
||||||
|
|
||||||
if (positions.length === 0) continue;
|
if (positions.length === 0) continue;
|
||||||
|
|
||||||
// Batch insert with skipDuplicates (unique constraint on deviceId+timestamp)
|
// 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) => ({
|
data: positions.map((p) => ({
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
latitude: p.latitude,
|
latitude: p.latitude,
|
||||||
@@ -641,6 +693,13 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
skipDuplicates: true,
|
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
|
// Update lastActive to the latest position timestamp
|
||||||
const latestPosition = positions.reduce((latest, p) =>
|
const latestPosition = positions.reduce((latest, p) =>
|
||||||
new Date(p.deviceTime) > new Date(latest.deviceTime) ? p : latest
|
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) },
|
data: { lastActive: new Date(latestPosition.deviceTime) },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
* Get driver stats
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
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 L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { QRCodeSVG } from 'qrcode.react';
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
useTraccarSetup,
|
useTraccarSetup,
|
||||||
useOpenTraccarAdmin,
|
useOpenTraccarAdmin,
|
||||||
useDeviceQr,
|
useDeviceQr,
|
||||||
|
useDriverLocationHistory,
|
||||||
} from '@/hooks/useGps';
|
} from '@/hooks/useGps';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
import { ErrorMessage } from '@/components/ErrorMessage';
|
import { ErrorMessage } from '@/components/ErrorMessage';
|
||||||
@@ -115,6 +116,8 @@ export function GpsTracking() {
|
|||||||
const [selectedDriverId, setSelectedDriverId] = useState<string>('');
|
const [selectedDriverId, setSelectedDriverId] = useState<string>('');
|
||||||
const [enrollmentResult, setEnrollmentResult] = useState<any>(null);
|
const [enrollmentResult, setEnrollmentResult] = useState<any>(null);
|
||||||
const [showQrDriverId, setShowQrDriverId] = useState<string | null>(null);
|
const [showQrDriverId, setShowQrDriverId] = useState<string | null>(null);
|
||||||
|
const [selectedDriverForTrail, setSelectedDriverForTrail] = useState<string | null>(null);
|
||||||
|
const [showTrails, setShowTrails] = useState(true);
|
||||||
|
|
||||||
// Check admin access
|
// Check admin access
|
||||||
if (backendUser?.role !== 'ADMINISTRATOR') {
|
if (backendUser?.role !== 'ADMINISTRATOR') {
|
||||||
@@ -138,6 +141,16 @@ export function GpsTracking() {
|
|||||||
const { data: driverStats } = useDriverStats(selectedDriverId);
|
const { data: driverStats } = useDriverStats(selectedDriverId);
|
||||||
const { data: qrInfo, isLoading: qrLoading } = useDeviceQr(showQrDriverId);
|
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
|
// Mutations
|
||||||
const updateSettings = useUpdateGpsSettings();
|
const updateSettings = useUpdateGpsSettings();
|
||||||
const traccarSetup = useTraccarSetup();
|
const traccarSetup = useTraccarSetup();
|
||||||
@@ -368,6 +381,20 @@ export function GpsTracking() {
|
|||||||
maxZoom={19}
|
maxZoom={19}
|
||||||
/>
|
/>
|
||||||
{locations && <MapFitBounds locations={locations} />}
|
{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) => (
|
{locations?.filter(l => l.location).map((driver) => (
|
||||||
<Marker
|
<Marker
|
||||||
key={driver.driverId}
|
key={driver.driverId}
|
||||||
@@ -397,6 +424,12 @@ export function GpsTracking() {
|
|||||||
<span>{formatDistanceToNow(new Date(driver.location!.timestamp), { addSuffix: true })}</span>
|
<span>{formatDistanceToNow(new Date(driver.location!.timestamp), { addSuffix: true })}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</Marker>
|
||||||
@@ -404,18 +437,40 @@ export function GpsTracking() {
|
|||||||
</MapContainer>
|
</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]">
|
<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>
|
<h4 className="text-sm font-medium text-foreground mb-2">Map Controls</h4>
|
||||||
<div className="space-y-1 text-sm">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded-full bg-green-500"></div>
|
<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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded-full bg-gray-400"></div>
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user