From cc3375ef8502b9adae8971ecb46adb28a4188143 Mon Sep 17 00:00:00 2001 From: kyle Date: Sun, 8 Feb 2026 18:08:48 +0100 Subject: [PATCH] feat: add GPS trip detection, history panel, and playback (#23) Auto-detect trips from GPS data (5-min idle threshold), pre-compute OSRM routes on trip completion, add trip history side panel with toggleable trips, and animated trip playback with speed controls. - Add GpsTrip model with TripStatus enum and migration - Trip detection in syncPositions cron (start on movement, end on idle) - Trip finalization with OSRM route matching and stats computation - API endpoints: list/detail/active/merge/backfill trips - Stats tab overhaul: trip list panel + map with colored polylines - Trip playback: animated marker, progressive trail, 1x-16x speed - Live map shows active trip trail instead of full day history - Historical backfill from existing GPS location data Co-Authored-By: Claude Opus 4.6 --- .../migration.sql | 34 + backend/prisma/schema.prisma | 40 + backend/src/gps/gps.controller.ts | 66 ++ backend/src/gps/gps.service.ts | 528 ++++++++- frontend/src/hooks/useGps.ts | 96 ++ frontend/src/pages/GpsTracking.tsx | 1039 +++++++++++++++-- frontend/src/types/gps.ts | 36 + 7 files changed, 1746 insertions(+), 93 deletions(-) create mode 100644 backend/prisma/migrations/20260208165115_add_gps_trips/migration.sql diff --git a/backend/prisma/migrations/20260208165115_add_gps_trips/migration.sql b/backend/prisma/migrations/20260208165115_add_gps_trips/migration.sql new file mode 100644 index 0000000..0e0ee36 --- /dev/null +++ b/backend/prisma/migrations/20260208165115_add_gps_trips/migration.sql @@ -0,0 +1,34 @@ +-- CreateEnum +CREATE TYPE "TripStatus" AS ENUM ('ACTIVE', 'COMPLETED', 'PROCESSING', 'FAILED'); + +-- CreateTable +CREATE TABLE "gps_trips" ( + "id" TEXT NOT NULL, + "deviceId" TEXT NOT NULL, + "status" "TripStatus" NOT NULL DEFAULT 'ACTIVE', + "startTime" TIMESTAMP(3) NOT NULL, + "endTime" TIMESTAMP(3), + "startLatitude" DOUBLE PRECISION NOT NULL, + "startLongitude" DOUBLE PRECISION NOT NULL, + "endLatitude" DOUBLE PRECISION, + "endLongitude" DOUBLE PRECISION, + "distanceMiles" DOUBLE PRECISION, + "durationSeconds" INTEGER, + "topSpeedMph" DOUBLE PRECISION, + "averageSpeedMph" DOUBLE PRECISION, + "pointCount" INTEGER NOT NULL DEFAULT 0, + "matchedRoute" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "gps_trips_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "gps_trips_deviceId_startTime_idx" ON "gps_trips"("deviceId", "startTime"); + +-- CreateIndex +CREATE INDEX "gps_trips_status_idx" ON "gps_trips"("status"); + +-- AddForeignKey +ALTER TABLE "gps_trips" ADD CONSTRAINT "gps_trips_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "gps_devices"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 792f656..bfe337c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -405,6 +405,7 @@ model GpsDevice { // Location history locationHistory GpsLocationHistory[] + trips GpsTrip[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -434,6 +435,45 @@ model GpsLocationHistory { @@index([timestamp]) // For cleanup job } +enum TripStatus { + ACTIVE // Currently in progress + COMPLETED // Finished, OSRM route computed + PROCESSING // OSRM computation in progress + FAILED // OSRM computation failed +} + +model GpsTrip { + id String @id @default(uuid()) + deviceId String + device GpsDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade) + + status TripStatus @default(ACTIVE) + + startTime DateTime + endTime DateTime? + startLatitude Float + startLongitude Float + endLatitude Float? + endLongitude Float? + + // Pre-computed stats (filled on completion) + distanceMiles Float? + durationSeconds Int? + topSpeedMph Float? + averageSpeedMph Float? + pointCount Int @default(0) + + // Pre-computed OSRM route (stored as JSON for instant display) + matchedRoute Json? // { coordinates: [lat,lng][], distance, duration, confidence } + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("gps_trips") + @@index([deviceId, startTime]) + @@index([status]) +} + model GpsSettings { id String @id @default(uuid()) diff --git a/backend/src/gps/gps.controller.ts b/backend/src/gps/gps.controller.ts index 571fdd6..89e6f1b 100644 --- a/backend/src/gps/gps.controller.ts +++ b/backend/src/gps/gps.controller.ts @@ -169,6 +169,72 @@ export class GpsController { return this.gpsService.getDriverStats(driverId, from, to); } + // ============================================ + // Trip Management + // ============================================ + + /** + * Get trips for a driver + */ + @Get('trips/:driverId') + @Roles(Role.ADMINISTRATOR) + async getDriverTrips( + @Param('driverId') driverId: string, + @Query('from') fromStr?: string, + @Query('to') toStr?: string, + @Query('status') status?: string, + ) { + const from = fromStr ? new Date(fromStr) : undefined; + const to = toStr ? new Date(toStr) : undefined; + return this.gpsService.getDriverTrips(driverId, from, to, status as any); + } + + /** + * Get active trip for a driver + */ + @Get('trips/:driverId/active') + @Roles(Role.ADMINISTRATOR) + async getActiveTrip(@Param('driverId') driverId: string) { + return this.gpsService.getActiveTrip(driverId); + } + + /** + * Get a single trip with full detail (matchedRoute + rawPoints) + */ + @Get('trips/:driverId/:tripId') + @Roles(Role.ADMINISTRATOR) + async getTripDetail( + @Param('driverId') driverId: string, + @Param('tripId') tripId: string, + ) { + return this.gpsService.getTripDetail(driverId, tripId); + } + + /** + * Merge two trips together + */ + @Post('trips/merge') + @Roles(Role.ADMINISTRATOR) + async mergeTrips( + @Body() body: { tripIdA: string; tripIdB: string }, + ) { + return this.gpsService.mergeTrips(body.tripIdA, body.tripIdB); + } + + /** + * Backfill trips from historical GPS data + */ + @Post('trips/backfill/:driverId') + @Roles(Role.ADMINISTRATOR) + async backfillTrips( + @Param('driverId') driverId: string, + @Body() body: { from?: string; to?: string }, + ) { + const from = body.from ? new Date(body.from) : undefined; + const to = body.to ? new Date(body.to) : undefined; + return this.gpsService.backfillTrips(driverId, from, to); + } + // ============================================ // Traccar Admin Access // ============================================ diff --git a/backend/src/gps/gps.service.ts b/backend/src/gps/gps.service.ts index 60e065a..0c2efb3 100644 --- a/backend/src/gps/gps.service.ts +++ b/backend/src/gps/gps.service.ts @@ -18,7 +18,7 @@ import { LocationDataDto, } from './dto/location-response.dto'; import { UpdateGpsSettingsDto } from './dto/update-gps-settings.dto'; -import { GpsSettings, User } from '@prisma/client'; +import { GpsSettings, Prisma, TripStatus, User } from '@prisma/client'; import * as crypto from 'crypto'; @Injectable() @@ -958,6 +958,9 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} where: { id: device.id }, data: { lastActive: new Date(latestPosition.deviceTime) }, }); + + // Trip detection for this device + await this.detectTripsForDevice(device.id); } catch (error) { this.logger.error(`[GPS Sync] Failed to sync positions for device ${device.traccarDeviceId}: ${error}`); } @@ -966,6 +969,529 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour} this.logger.log('[GPS Sync] Sync completed'); } + // ============================================ + // Trip Detection & Management + // ============================================ + + private static readonly IDLE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes + private static readonly MOVING_SPEED_MPH = 5; // Speed to start a trip + private static readonly IDLE_SPEED_MPH = 3; // Speed below which counts as idle + + /** + * Detect trips for a device after position sync. + * Creates new trips when movement starts, completes them after 5 min idle. + */ + private async detectTripsForDevice(deviceId: string): Promise { + try { + // Find any ACTIVE trip for this device + const activeTrip = await this.prisma.gpsTrip.findFirst({ + where: { deviceId, status: TripStatus.ACTIVE }, + }); + + const now = new Date(); + // Look at positions from the last 7 minutes to detect idle + const recentWindow = new Date(now.getTime() - 7 * 60 * 1000); + + const recentPositions = await this.prisma.gpsLocationHistory.findMany({ + where: { + deviceId, + timestamp: { gte: recentWindow }, + }, + orderBy: { timestamp: 'desc' }, + take: 50, + }); + + if (recentPositions.length === 0) return; + + if (!activeTrip) { + // No active trip - check if driver started moving + const movingPoint = recentPositions.find( + (p) => (p.speed || 0) > GpsService.MOVING_SPEED_MPH, + ); + + if (movingPoint) { + // Find the earliest moving point in this batch + const earliestMoving = [...recentPositions] + .reverse() + .find((p) => (p.speed || 0) > GpsService.MOVING_SPEED_MPH); + + const startPoint = earliestMoving || movingPoint; + + await this.prisma.gpsTrip.create({ + data: { + deviceId, + status: TripStatus.ACTIVE, + startTime: startPoint.timestamp, + startLatitude: startPoint.latitude, + startLongitude: startPoint.longitude, + pointCount: recentPositions.length, + }, + }); + + this.logger.log( + `[Trip] New trip started for device ${deviceId} at ${startPoint.timestamp.toISOString()}`, + ); + } + } else { + // Active trip exists - check if driver has been idle for 5+ minutes + const lastMovingPosition = recentPositions.find( + (p) => (p.speed || 0) > GpsService.IDLE_SPEED_MPH, + ); + + const latestPosition = recentPositions[0]; // Most recent + + if (!lastMovingPosition) { + // No movement in recent window - check if idle long enough + const idleSince = activeTrip.startTime; // Fallback + + // Check oldest position in window - if all are idle and window > 5 min + const oldestInWindow = recentPositions[recentPositions.length - 1]; + const windowDuration = now.getTime() - oldestInWindow.timestamp.getTime(); + + if (windowDuration >= GpsService.IDLE_THRESHOLD_MS) { + // Complete the trip - end time is the oldest idle position + await this.completeTrip(activeTrip.id, oldestInWindow); + } + } else { + // There's recent movement - check time since last movement + const timeSinceMovement = + now.getTime() - lastMovingPosition.timestamp.getTime(); + + if (timeSinceMovement >= GpsService.IDLE_THRESHOLD_MS) { + // Been idle for 5+ minutes after last movement + await this.completeTrip(activeTrip.id, lastMovingPosition); + } else { + // Still driving - update point count + const totalPoints = await this.prisma.gpsLocationHistory.count({ + where: { + deviceId, + timestamp: { gte: activeTrip.startTime }, + }, + }); + + await this.prisma.gpsTrip.update({ + where: { id: activeTrip.id }, + data: { pointCount: totalPoints }, + }); + } + } + } + } catch (error) { + this.logger.error( + `[Trip] Detection failed for device ${deviceId}: ${error.message}`, + ); + } + } + + /** + * Complete a trip and queue OSRM finalization + */ + private async completeTrip( + tripId: string, + lastMovingPosition: { latitude: number; longitude: number; timestamp: Date }, + ): Promise { + await this.prisma.gpsTrip.update({ + where: { id: tripId }, + data: { + status: TripStatus.PROCESSING, + endTime: lastMovingPosition.timestamp, + endLatitude: lastMovingPosition.latitude, + endLongitude: lastMovingPosition.longitude, + }, + }); + + this.logger.log( + `[Trip] Trip ${tripId} completed, queuing finalization`, + ); + + // Fire and forget - don't block the sync cron + this.finalizeTrip(tripId).catch((err) => + this.logger.error(`[Trip] Finalization failed for ${tripId}: ${err.message}`), + ); + } + + /** + * Finalize a trip: compute OSRM route and stats, store results + */ + async finalizeTrip(tripId: string): Promise { + const trip = await this.prisma.gpsTrip.findUnique({ + where: { id: tripId }, + }); + + if (!trip || !trip.endTime) { + this.logger.warn(`[Trip] Cannot finalize trip ${tripId}: not found or no endTime`); + return; + } + + // Fetch all points for this trip + const points = await this.prisma.gpsLocationHistory.findMany({ + where: { + deviceId: trip.deviceId, + timestamp: { + gte: trip.startTime, + lte: trip.endTime, + }, + }, + orderBy: { timestamp: 'asc' }, + }); + + if (points.length < 2) { + await this.prisma.gpsTrip.update({ + where: { id: tripId }, + data: { status: TripStatus.FAILED, pointCount: points.length }, + }); + return; + } + + // Compute stats from raw points + let topSpeedMph = 0; + const speeds = points.filter((p) => (p.speed || 0) > 0).map((p) => p.speed!); + if (speeds.length > 0) { + topSpeedMph = Math.max(...speeds); + } + const averageSpeedMph = + speeds.length > 0 + ? speeds.reduce((sum, s) => sum + s, 0) / speeds.length + : 0; + + const durationSeconds = Math.round( + (trip.endTime.getTime() - trip.startTime.getTime()) / 1000, + ); + + // Try OSRM route matching + let matchedRouteData: any = null; + let distanceMiles: number | null = null; + + try { + const matchResult = await this.osrmService.matchRoute( + points.map((p) => ({ + latitude: p.latitude, + longitude: p.longitude, + timestamp: p.timestamp, + speed: p.speed ?? undefined, + })), + ); + + if (matchResult) { + distanceMiles = matchResult.distance / 1609.34; + matchedRouteData = { + coordinates: matchResult.coordinates, + distance: distanceMiles, + duration: matchResult.duration, + confidence: matchResult.confidence, + }; + + this.logger.log( + `[Trip] Finalized trip ${tripId}: ${distanceMiles.toFixed(1)} mi, ` + + `${points.length} points, confidence ${(matchResult.confidence * 100).toFixed(0)}%`, + ); + } + } catch (error) { + this.logger.warn(`[Trip] OSRM failed for trip ${tripId}: ${error.message}`); + } + + // Fallback distance: haversine sum + if (distanceMiles === null) { + distanceMiles = 0; + for (let i = 1; i < points.length; i++) { + distanceMiles += this.calculateHaversineDistance( + points[i - 1].latitude, + points[i - 1].longitude, + points[i].latitude, + points[i].longitude, + ); + } + } + + await this.prisma.gpsTrip.update({ + where: { id: tripId }, + data: { + status: matchedRouteData ? TripStatus.COMPLETED : TripStatus.FAILED, + distanceMiles: Math.round(distanceMiles * 10) / 10, + durationSeconds, + topSpeedMph: Math.round(topSpeedMph), + averageSpeedMph: Math.round(averageSpeedMph * 10) / 10, + pointCount: points.length, + matchedRoute: matchedRouteData, + }, + }); + } + + /** + * Get trips for a driver + */ + async getDriverTrips( + driverId: string, + fromDate?: Date, + toDate?: Date, + status?: TripStatus, + ) { + const device = await this.prisma.gpsDevice.findUnique({ + where: { driverId }, + }); + + if (!device) { + throw new NotFoundException('Driver is not enrolled for GPS tracking'); + } + + const where: any = { deviceId: device.id }; + + if (fromDate || toDate) { + where.startTime = {}; + if (fromDate) where.startTime.gte = fromDate; + if (toDate) where.startTime.lte = toDate; + } + + if (status) { + where.status = status; + } + + return this.prisma.gpsTrip.findMany({ + where, + orderBy: { startTime: 'desc' }, + select: { + id: true, + deviceId: true, + status: true, + startTime: true, + endTime: true, + startLatitude: true, + startLongitude: true, + endLatitude: true, + endLongitude: true, + distanceMiles: true, + durationSeconds: true, + topSpeedMph: true, + averageSpeedMph: true, + pointCount: true, + // Exclude matchedRoute (heavy JSON) from list view + }, + }); + } + + /** + * Get a single trip with full detail (including matchedRoute and rawPoints) + */ + async getTripDetail(driverId: string, tripId: string) { + const device = await this.prisma.gpsDevice.findUnique({ + where: { driverId }, + }); + + if (!device) { + throw new NotFoundException('Driver is not enrolled for GPS tracking'); + } + + const trip = await this.prisma.gpsTrip.findFirst({ + where: { id: tripId, deviceId: device.id }, + }); + + if (!trip) { + throw new NotFoundException('Trip not found'); + } + + // Fetch raw points for this trip + const rawPoints = trip.endTime + ? await this.prisma.gpsLocationHistory.findMany({ + where: { + deviceId: device.id, + timestamp: { + gte: trip.startTime, + lte: trip.endTime, + }, + }, + orderBy: { timestamp: 'asc' }, + select: { + latitude: true, + longitude: true, + speed: true, + course: true, + battery: true, + timestamp: true, + }, + }) + : []; + + return { + ...trip, + rawPoints, + }; + } + + /** + * Get active trip for a driver (if any) + */ + async getActiveTrip(driverId: string) { + const device = await this.prisma.gpsDevice.findUnique({ + where: { driverId }, + }); + + if (!device) { + return null; + } + + return this.prisma.gpsTrip.findFirst({ + where: { + deviceId: device.id, + status: TripStatus.ACTIVE, + }, + }); + } + + /** + * Merge two trips together (A is earlier, B is later) + */ + async mergeTrips(tripIdA: string, tripIdB: string) { + const tripA = await this.prisma.gpsTrip.findUnique({ where: { id: tripIdA } }); + const tripB = await this.prisma.gpsTrip.findUnique({ where: { id: tripIdB } }); + + if (!tripA || !tripB) { + throw new NotFoundException('One or both trips not found'); + } + + if (tripA.deviceId !== tripB.deviceId) { + throw new BadRequestException('Cannot merge trips from different devices'); + } + + // Determine which is earlier + const [earlier, later] = + tripA.startTime < tripB.startTime ? [tripA, tripB] : [tripB, tripA]; + + // Update the later trip to span both + await this.prisma.gpsTrip.update({ + where: { id: later.id }, + data: { + status: TripStatus.PROCESSING, + startTime: earlier.startTime, + startLatitude: earlier.startLatitude, + startLongitude: earlier.startLongitude, + matchedRoute: Prisma.DbNull, // Will be recomputed + }, + }); + + // Delete the earlier trip + await this.prisma.gpsTrip.delete({ where: { id: earlier.id } }); + + // Re-finalize the merged trip + await this.finalizeTrip(later.id); + + return this.prisma.gpsTrip.findUnique({ where: { id: later.id } }); + } + + /** + * Backfill trips from historical GPS data + */ + async backfillTrips( + driverId: string, + fromDate?: Date, + toDate?: Date, + ): Promise<{ tripsCreated: number }> { + const device = await this.prisma.gpsDevice.findUnique({ + where: { driverId }, + }); + + if (!device) { + throw new NotFoundException('Driver is not enrolled for GPS tracking'); + } + + const to = toDate || new Date(); + const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000); + + this.logger.log( + `[Trip Backfill] Processing ${driverId} from ${from.toISOString()} to ${to.toISOString()}`, + ); + + // Delete existing trips in this range (avoid duplicates) + await this.prisma.gpsTrip.deleteMany({ + where: { + deviceId: device.id, + startTime: { gte: from, lte: to }, + }, + }); + + // Fetch all points in range + const points = await this.prisma.gpsLocationHistory.findMany({ + where: { + deviceId: device.id, + timestamp: { gte: from, lte: to }, + }, + orderBy: { timestamp: 'asc' }, + }); + + if (points.length < 2) { + return { tripsCreated: 0 }; + } + + // Detect trips from historical data + const trips: Array<{ + startIdx: number; + endIdx: number; + }> = []; + + let tripStartIdx: number | null = null; + let lastMovingIdx: number | null = null; + + for (let i = 0; i < points.length; i++) { + const speed = points[i].speed || 0; + + if (speed > GpsService.MOVING_SPEED_MPH) { + if (tripStartIdx === null) { + tripStartIdx = i; + } + lastMovingIdx = i; + } else if (tripStartIdx !== null && lastMovingIdx !== null) { + // Check if we've been idle long enough + const timeSinceMovement = + points[i].timestamp.getTime() - points[lastMovingIdx].timestamp.getTime(); + + if (timeSinceMovement >= GpsService.IDLE_THRESHOLD_MS) { + // Trip ended at lastMovingIdx + trips.push({ startIdx: tripStartIdx, endIdx: lastMovingIdx }); + tripStartIdx = null; + lastMovingIdx = null; + } + } + } + + // Close any open trip + if (tripStartIdx !== null && lastMovingIdx !== null) { + trips.push({ startIdx: tripStartIdx, endIdx: lastMovingIdx }); + } + + this.logger.log(`[Trip Backfill] Detected ${trips.length} trips in ${points.length} points`); + + // Create trip records and finalize each + let tripsCreated = 0; + + for (const t of trips) { + const startPoint = points[t.startIdx]; + const endPoint = points[t.endIdx]; + const tripPoints = points.slice(t.startIdx, t.endIdx + 1); + + if (tripPoints.length < 2) continue; + + const trip = await this.prisma.gpsTrip.create({ + data: { + deviceId: device.id, + status: TripStatus.PROCESSING, + startTime: startPoint.timestamp, + endTime: endPoint.timestamp, + startLatitude: startPoint.latitude, + startLongitude: startPoint.longitude, + endLatitude: endPoint.latitude, + endLongitude: endPoint.longitude, + pointCount: tripPoints.length, + }, + }); + + // Finalize (compute OSRM + stats) - await to respect OSRM rate limits + await this.finalizeTrip(trip.id); + tripsCreated++; + } + + this.logger.log(`[Trip Backfill] Created and finalized ${tripsCreated} trips`); + return { tripsCreated }; + } + /** * Clean up old location history (runs daily at 2 AM) */ diff --git a/frontend/src/hooks/useGps.ts b/frontend/src/hooks/useGps.ts index 3e87303..3b7ffa7 100644 --- a/frontend/src/hooks/useGps.ts +++ b/frontend/src/hooks/useGps.ts @@ -10,6 +10,9 @@ import type { MyGpsStatus, DeviceQrInfo, LocationHistoryResponse, + GpsTrip, + GpsTripDetail, + TripStatus, } from '@/types/gps'; import toast from 'react-hot-toast'; import { queryKeys } from '@/lib/query-keys'; @@ -162,6 +165,99 @@ export function useDriverStats(driverId: string, from?: string, to?: string) { }); } +// ============================================ +// Trip Hooks +// ============================================ + +/** + * Get trips for a driver + */ +export function useDriverTrips(driverId: string | null, from?: string, to?: string, status?: TripStatus) { + return useQuery({ + queryKey: ['gps', 'trips', driverId, from, to, status], + queryFn: async () => { + const params = new URLSearchParams(); + if (from) params.append('from', from); + if (to) params.append('to', to); + if (status) params.append('status', status); + const { data } = await api.get(`/gps/trips/${driverId}?${params}`); + return data; + }, + enabled: !!driverId, + }); +} + +/** + * Get a single trip with full detail (matchedRoute + rawPoints) + */ +export function useDriverTripDetail(driverId: string | null, tripId: string | null) { + return useQuery({ + queryKey: ['gps', 'trips', driverId, tripId], + queryFn: async () => { + const { data } = await api.get(`/gps/trips/${driverId}/${tripId}`); + return data; + }, + enabled: !!driverId && !!tripId, + }); +} + +/** + * Get active trip for a driver + */ +export function useActiveTrip(driverId: string | null) { + return useQuery({ + queryKey: ['gps', 'trips', driverId, 'active'], + queryFn: async () => { + const { data } = await api.get(`/gps/trips/${driverId}/active`); + return data; + }, + enabled: !!driverId, + refetchInterval: 15000, + }); +} + +/** + * Merge two trips + */ +export function useMergeTrips() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ tripIdA, tripIdB }: { tripIdA: string; tripIdB: string }) => { + const { data } = await api.post('/gps/trips/merge', { tripIdA, tripIdB }); + return data; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['gps', 'trips'] }); + toast.success('Trips merged successfully'); + }, + onError: (error: any) => { + toast.error(error.response?.data?.message || 'Failed to merge trips'); + }, + }); +} + +/** + * Backfill trips from historical data + */ +export function useBackfillTrips() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ driverId, from, to }: { driverId: string; from?: string; to?: string }) => { + const { data } = await api.post(`/gps/trips/backfill/${driverId}`, { from, to }); + return data; + }, + onSuccess: (data: { tripsCreated: number }) => { + queryClient.invalidateQueries({ queryKey: ['gps', 'trips'] }); + toast.success(`Backfill complete: ${data.tripsCreated} trips created`); + }, + onError: (error: any) => { + toast.error(error.response?.data?.message || 'Failed to backfill trips'); + }, + }); +} + /** * Enroll a driver for GPS tracking */ diff --git a/frontend/src/pages/GpsTracking.tsx b/frontend/src/pages/GpsTracking.tsx index 839cf14..09f1a08 100644 --- a/frontend/src/pages/GpsTracking.tsx +++ b/frontend/src/pages/GpsTracking.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; @@ -26,6 +26,14 @@ import { Car, Copy, QrCode, + Play, + Calendar, + Merge, + ToggleLeft, + ToggleRight, + Timer, + ChevronDown, + ChevronRight, } from 'lucide-react'; import { useGpsStatus, @@ -41,16 +49,20 @@ import { useOpenTraccarAdmin, useDeviceQr, useDriverLocationHistory, + useDriverTrips, + useActiveTrip, + useMergeTrips, + useBackfillTrips, } from '@/hooks/useGps'; import { Loading } from '@/components/Loading'; import { ErrorMessage } from '@/components/ErrorMessage'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueries } from '@tanstack/react-query'; import { api } from '@/lib/api'; import { useAuth } from '@/contexts/AuthContext'; import type { Driver } from '@/types'; -import type { DriverLocation, LocationHistoryResponse } from '@/types/gps'; +import type { DriverLocation, LocationHistoryResponse, GpsTrip, GpsTripDetail, TripStatus } from '@/types/gps'; import toast from 'react-hot-toast'; -import { formatDistanceToNow } from 'date-fns'; +import { formatDistanceToNow, format, subDays } from 'date-fns'; // Fix Leaflet default marker icons delete (L.Icon.Default.prototype as any)._getIconUrl; @@ -108,6 +120,828 @@ function MapFitBounds({ locations }: { locations: DriverLocation[] }) { return null; } +// Stats Tab Component +// Animated marker component that moves along a route +function AnimatedMarker({ position }: { position: [number, number] }) { + const map = useMap(); + const markerRef = useRef(null); + + useEffect(() => { + if (!markerRef.current) { + const icon = L.divIcon({ + className: 'animated-car-marker', + html: ` +
+ `, + iconSize: [20, 20], + iconAnchor: [10, 10], + }); + markerRef.current = L.marker(position, { icon, zIndexOffset: 1000 }).addTo(map); + } else { + markerRef.current.setLatLng(position); + } + + return () => { + if (markerRef.current) { + map.removeLayer(markerRef.current); + markerRef.current = null; + } + }; + }, [map]); // Only create/destroy on mount + + // Update position smoothly + useEffect(() => { + if (markerRef.current) { + markerRef.current.setLatLng(position); + } + }, [position]); + + return null; +} + +// Calculate cumulative distances along a route +function buildCumulativeDistances(coords: [number, number][]): number[] { + const distances = [0]; + for (let i = 1; i < coords.length; i++) { + const [lat1, lng1] = coords[i - 1]; + const [lat2, lng2] = coords[i]; + const R = 3958.8; // miles + const dLat = ((lat2 - lat1) * Math.PI) / 180; + const dLng = ((lng2 - lng1) * Math.PI) / 180; + const a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos((lat1 * Math.PI) / 180) * + Math.cos((lat2 * Math.PI) / 180) * + Math.sin(dLng / 2) * + Math.sin(dLng / 2); + const d = R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + distances.push(distances[i - 1] + d); + } + return distances; +} + +// Interpolate position along route at given fraction (0-1) +function interpolatePosition( + coords: [number, number][], + cumDist: number[], + fraction: number, +): [number, number] { + const totalDist = cumDist[cumDist.length - 1]; + const targetDist = fraction * totalDist; + + // Find the segment + let i = 1; + while (i < cumDist.length && cumDist[i] < targetDist) i++; + if (i >= cumDist.length) return coords[coords.length - 1]; + + const segStart = cumDist[i - 1]; + const segEnd = cumDist[i]; + const segFraction = segEnd > segStart ? (targetDist - segStart) / (segEnd - segStart) : 0; + + const [lat1, lng1] = coords[i - 1]; + const [lat2, lng2] = coords[i]; + return [ + lat1 + (lat2 - lat1) * segFraction, + lng1 + (lng2 - lng1) * segFraction, + ]; +} + +interface StatsTabProps { + devices: any[]; + selectedDriverId: string; + setSelectedDriverId: (id: string) => void; + dateFrom: string; + dateTo: string; + setDateFrom: (date: string) => void; + setDateTo: (date: string) => void; + toggledTripIds: Set; + setToggledTripIds: (ids: Set) => void; + selectedTripIds: Set; + setSelectedTripIds: (ids: Set) => void; + expandedDates: Set; + setExpandedDates: (dates: Set) => void; +} + +function StatsTab({ + devices, + selectedDriverId, + setSelectedDriverId, + dateFrom, + dateTo, + setDateFrom, + setDateTo, + toggledTripIds, + setToggledTripIds, + selectedTripIds, + setSelectedTripIds, + expandedDates, + setExpandedDates, +}: StatsTabProps) { + const backfillTrips = useBackfillTrips(); + const mergeTrips = useMergeTrips(); + + // Fetch trips for selected driver + const { data: trips, isLoading: tripsLoading } = useDriverTrips( + selectedDriverId || null, + dateFrom, + dateTo + ); + + // Fetch trip details for toggled trips using useQueries (avoids hooks-in-loop violation) + const toggledTripsArray = Array.from(toggledTripIds); + const tripDetailQueries = useQueries({ + queries: toggledTripsArray.map(tripId => ({ + queryKey: ['gps', 'trips', selectedDriverId, tripId], + queryFn: async () => { + const { data } = await api.get(`/gps/trips/${selectedDriverId}/${tripId}`); + return data as GpsTripDetail; + }, + enabled: !!selectedDriverId && !!tripId, + })), + }); + + // Calculate summary stats + const summaryStats = useMemo(() => { + if (!trips) return { totalTrips: 0, totalMiles: 0, totalDrivingTime: 0 }; + + const completedTrips = trips.filter(t => t.status === 'COMPLETED'); + return { + totalTrips: completedTrips.length, + totalMiles: completedTrips.reduce((sum, t) => sum + (t.distanceMiles || 0), 0), + totalDrivingTime: completedTrips.reduce((sum, t) => sum + (t.durationSeconds || 0), 0) / 60, // minutes + }; + }, [trips]); + + // Group trips by date + const tripsByDate = useMemo(() => { + if (!trips) return new Map(); + + const grouped = new Map(); + trips.forEach(trip => { + const dateKey = format(new Date(trip.startTime), 'yyyy-MM-dd'); + if (!grouped.has(dateKey)) { + grouped.set(dateKey, []); + } + grouped.get(dateKey)!.push(trip); + }); + + // Sort dates descending + return new Map([...grouped.entries()].sort((a, b) => b[0].localeCompare(a[0]))); + }, [trips]); + + // Auto-toggle most recent completed trip + React.useEffect(() => { + if (trips && trips.length > 0 && toggledTripIds.size === 0) { + const mostRecentCompleted = trips.find(t => t.status === 'COMPLETED'); + if (mostRecentCompleted) { + setToggledTripIds(new Set([mostRecentCompleted.id])); + } + } + }, [trips, toggledTripIds, setToggledTripIds]); + + const handleToggleTrip = (tripId: string) => { + const newSet = new Set(toggledTripIds); + if (newSet.has(tripId)) { + newSet.delete(tripId); + } else { + newSet.add(tripId); + } + setToggledTripIds(newSet); + }; + + const handleSelectTrip = (tripId: string) => { + const newSet = new Set(selectedTripIds); + if (newSet.has(tripId)) { + newSet.delete(tripId); + } else { + newSet.add(tripId); + } + setSelectedTripIds(newSet); + }; + + const handleToggleDate = (dateKey: string) => { + const newSet = new Set(expandedDates); + if (newSet.has(dateKey)) { + newSet.delete(dateKey); + } else { + newSet.add(dateKey); + } + setExpandedDates(newSet); + }; + + const handleMergeSelected = () => { + const selected = Array.from(selectedTripIds); + if (selected.length !== 2) { + toast.error('Please select exactly 2 adjacent trips to merge'); + return; + } + + mergeTrips.mutate( + { tripIdA: selected[0], tripIdB: selected[1] }, + { + onSuccess: () => { + setSelectedTripIds(new Set()); + }, + } + ); + }; + + // Playback state + const [playingTripId, setPlayingTripId] = useState(null); + const [isPlaying, setIsPlaying] = useState(false); + const [playbackSpeed, setPlaybackSpeed] = useState(4); // 4x default + const [playbackProgress, setPlaybackProgress] = useState(0); // 0-1 + const animationRef = useRef(null); + const lastFrameTimeRef = useRef(0); + + // Get the playing trip's detail + const playingTripQuery = useQuery({ + queryKey: ['gps', 'trips', selectedDriverId, playingTripId, 'playback'], + queryFn: async () => { + const { data } = await api.get(`/gps/trips/${selectedDriverId}/${playingTripId}`); + return data; + }, + enabled: !!selectedDriverId && !!playingTripId, + }); + + const playingTripData = playingTripQuery.data; + + // Pre-compute cumulative distances for playback + const playbackRouteData = useMemo(() => { + if (!playingTripData?.matchedRoute?.coordinates?.length) return null; + const coords = playingTripData.matchedRoute.coordinates; + const cumDist = buildCumulativeDistances(coords); + return { coords, cumDist, totalDist: cumDist[cumDist.length - 1] }; + }, [playingTripData]); + + // Current animated position + const animatedPosition = useMemo(() => { + if (!playbackRouteData) return null; + return interpolatePosition( + playbackRouteData.coords, + playbackRouteData.cumDist, + playbackProgress, + ); + }, [playbackRouteData, playbackProgress]); + + // Trail drawn up to current playback position + const playbackTrail = useMemo(() => { + if (!playbackRouteData || playbackProgress <= 0) return []; + const { coords, cumDist } = playbackRouteData; + const targetDist = playbackProgress * playbackRouteData.totalDist; + + const trail: [number, number][] = [coords[0]]; + for (let i = 1; i < coords.length; i++) { + if (cumDist[i] <= targetDist) { + trail.push(coords[i]); + } else { + // Interpolate the last point + const segFraction = + cumDist[i] > cumDist[i - 1] + ? (targetDist - cumDist[i - 1]) / (cumDist[i] - cumDist[i - 1]) + : 0; + const [lat1, lng1] = coords[i - 1]; + const [lat2, lng2] = coords[i]; + trail.push([ + lat1 + (lat2 - lat1) * segFraction, + lng1 + (lng2 - lng1) * segFraction, + ]); + break; + } + } + return trail; + }, [playbackRouteData, playbackProgress]); + + // Animation loop + useEffect(() => { + if (!isPlaying || !playingTripData?.durationSeconds) { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + animationRef.current = null; + } + return; + } + + const tripDurationMs = playingTripData.durationSeconds * 1000; + + const animate = (time: number) => { + if (!lastFrameTimeRef.current) { + lastFrameTimeRef.current = time; + } + + const deltaMs = time - lastFrameTimeRef.current; + lastFrameTimeRef.current = time; + + // Advance progress based on speed multiplier + const progressDelta = (deltaMs * playbackSpeed) / tripDurationMs; + + setPlaybackProgress((prev) => { + const next = prev + progressDelta; + if (next >= 1) { + setIsPlaying(false); + return 1; + } + return next; + }); + + animationRef.current = requestAnimationFrame(animate); + }; + + lastFrameTimeRef.current = 0; + animationRef.current = requestAnimationFrame(animate); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + }, [isPlaying, playbackSpeed, playingTripData?.durationSeconds]); + + const handlePlayTrip = (tripId: string) => { + if (playingTripId === tripId) { + // Toggle play/pause + setIsPlaying(!isPlaying); + } else { + // Start playing new trip + setPlayingTripId(tripId); + setPlaybackProgress(0); + setIsPlaying(true); + // Also toggle the trip on so we see the full route faintly + if (!toggledTripIds.has(tripId)) { + handleToggleTrip(tripId); + } + } + }; + + const handleStopPlayback = () => { + setIsPlaying(false); + setPlayingTripId(null); + setPlaybackProgress(0); + }; + + const handleBackfill = () => { + if (!selectedDriverId) { + toast.error('Please select a driver first'); + return; + } + + backfillTrips.mutate({ + driverId: selectedDriverId, + from: dateFrom, + to: dateTo, + }); + }; + + const getStatusColor = (status: TripStatus) => { + switch (status) { + case 'ACTIVE': + return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'; + case 'COMPLETED': + return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'; + case 'PROCESSING': + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400'; + case 'FAILED': + return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'; + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400'; + } + }; + + const formatDuration = (seconds: number | null) => { + if (!seconds) return 'N/A'; + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; + }; + + // Polyline colors for different trips + const tripColors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6']; + + return ( +
+
+ {/* Left Panel: Trip List */} +
+ {/* Driver Selector */} +
+ + +
+ + {/* Date Range Picker */} +
+
+ + setDateFrom(e.target.value)} + className="w-full px-2 py-1 text-sm border border-input rounded-md bg-background text-foreground" + /> +
+
+ + setDateTo(e.target.value)} + className="w-full px-2 py-1 text-sm border border-input rounded-md bg-background text-foreground" + /> +
+ +
+ + {/* Summary Stats Bar */} + {selectedDriverId && trips && ( +
+
+
+

{summaryStats.totalTrips}

+

Trips

+
+
+

{summaryStats.totalMiles.toFixed(1)}

+

Miles

+
+
+

{formatDuration(summaryStats.totalDrivingTime * 60)}

+

Driving

+
+
+
+ )} + + {/* Merge Button */} + {selectedTripIds.size > 0 && ( + + )} + + {/* Trip List */} +
+ {!selectedDriverId ? ( +

Select a driver to view trips

+ ) : tripsLoading ? ( + + ) : trips && trips.length > 0 ? ( + Array.from(tripsByDate.entries()).map(([dateKey, dateTrips]) => ( +
+ {/* Date Heading */} + + + {/* Trip Cards */} + {expandedDates.has(dateKey) && ( +
+ {dateTrips.map((trip) => ( +
+
+ {/* Checkbox for selection */} + handleSelectTrip(trip.id)} + className="mt-1 rounded border-gray-300 text-primary focus:ring-primary" + /> + +
+ {/* Time Range */} +
+ + {format(new Date(trip.startTime), 'h:mm a')} + {trip.endTime && ` - ${format(new Date(trip.endTime), 'h:mm a')}`} + + + {trip.status} + +
+ + {/* Stats */} +
+
+ + {formatDuration(trip.durationSeconds)} +
+
+ + {trip.distanceMiles?.toFixed(1) || 'N/A'} mi +
+
+ + {trip.topSpeedMph?.toFixed(0) || 'N/A'} mph +
+
+ + {/* Action Buttons */} +
+ + {trip.status === 'COMPLETED' && ( + + )} +
+
+
+
+ ))} +
+ )} +
+ )) + ) : ( +

No trips found for this date range

+ )} +
+
+ + {/* Right Panel: Map */} +
+ + + + {/* Render polylines for each toggled trip */} + {toggledTripsArray.map((tripId, index) => { + const detailQuery = tripDetailQueries[index]; + if (!detailQuery?.data) return null; + + const detail = detailQuery.data; + const color = tripColors[index % tripColors.length]; + + // Use matched route if available, otherwise raw points + const positions = + detail.matchedRoute?.coordinates || + detail.rawPoints.map(p => [p.latitude, p.longitude] as [number, number]); + + if (positions.length < 2) return null; + + // Start marker (green) + const startIcon = L.divIcon({ + className: 'custom-trip-marker', + html: ` +
+ `, + iconSize: [16, 16], + iconAnchor: [8, 8], + }); + + // End marker (red) + const endIcon = L.divIcon({ + className: 'custom-trip-marker', + html: ` +
+ `, + iconSize: [16, 16], + iconAnchor: [8, 8], + }); + + return ( + + + +
+

+ {format(new Date(detail.startTime), 'h:mm a')} + {detail.endTime && ` - ${format(new Date(detail.endTime), 'h:mm a')}`} +

+

Distance: {detail.distanceMiles?.toFixed(1) || 'N/A'} mi

+

Duration: {formatDuration(detail.durationSeconds)}

+

Top Speed: {detail.topSpeedMph?.toFixed(0) || 'N/A'} mph

+
+
+
+ + {/* Start Marker */} + + +
+

Trip Start

+

{format(new Date(detail.startTime), 'h:mm a')}

+
+
+
+ + {/* End Marker */} + {detail.endLatitude && detail.endLongitude && ( + + +
+

Trip End

+

{detail.endTime && format(new Date(detail.endTime), 'h:mm a')}

+
+
+
+ )} +
+ ); + })} + + {/* Playback trail (drawn progressively) */} + {playingTripId && playbackTrail.length > 1 && ( + + )} + + {/* Animated marker */} + {playingTripId && animatedPosition && ( + + )} +
+ + {toggledTripIds.size === 0 && !playingTripId && ( +
+

Toggle trips to view on map

+
+ )} +
+
+ + {/* Playback Controls Bar */} + {playingTripId && ( +
+
+ {/* Play/Pause */} + + + {/* Stop */} + + + {/* Timeline Scrubber */} +
+ { + const val = parseInt(e.target.value) / 1000; + setPlaybackProgress(val); + }} + className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary" + /> +
+ + {/* Progress Info */} +
+ {playbackRouteData ? ( + `${(playbackProgress * playbackRouteData.totalDist).toFixed(1)} / ${playbackRouteData.totalDist.toFixed(1)} mi` + ) : ( + `${(playbackProgress * 100).toFixed(0)}%` + )} +
+ + {/* Speed Control */} +
+ {[1, 2, 4, 8, 16].map((speed) => ( + + ))} +
+
+
+ )} +
+ ); +} + export function GpsTracking() { const { backendUser } = useAuth(); const [activeTab, setActiveTab] = useState<'map' | 'devices' | 'settings' | 'stats'>('map'); @@ -118,6 +952,14 @@ export function GpsTracking() { const [showQrDriverId, setShowQrDriverId] = useState(null); const [selectedDriverForTrail, setSelectedDriverForTrail] = useState(null); const [showTrails, setShowTrails] = useState(true); + const [showFullHistory, setShowFullHistory] = useState(false); + + // Stats tab state + const [dateFrom, setDateFrom] = useState(format(subDays(new Date(), 7), 'yyyy-MM-dd')); + const [dateTo, setDateTo] = useState(format(new Date(), 'yyyy-MM-dd')); + const [toggledTripIds, setToggledTripIds] = useState>(new Set()); + const [selectedTripIds, setSelectedTripIds] = useState>(new Set()); + const [expandedDates, setExpandedDates] = useState>(new Set()); // Check admin access if (backendUser?.role !== 'ADMINISTRATOR') { @@ -149,38 +991,85 @@ export function GpsTracking() { // 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); + + // Get active trip first, fall back to full history if "Show Full History" is toggled + const { data: activeTrip } = useActiveTrip(driverIdForTrail); + const activeTripId = activeTrip?.id || null; + const { data: activeTripDetail } = useQuery({ + queryKey: ['gps', 'trips', driverIdForTrail, activeTripId], + queryFn: async () => { + const { data } = await api.get(`/gps/trips/${driverIdForTrail}/${activeTripId}`); + return data; + }, + enabled: !!driverIdForTrail && !!activeTripId, + refetchInterval: 15000, + }); + const { data: locationHistory } = useDriverLocationHistory( + showFullHistory ? driverIdForTrail : null, + undefined, + undefined + ); // Extract route polyline coordinates and metadata const routePolyline = useMemo(() => { - if (!locationHistory) return null; + // First try active trip detail if available and not showing full history + if (!showFullHistory && activeTripDetail) { + // If we have OSRM matched route, use road-snapped coordinates + if (activeTripDetail.matchedRoute && activeTripDetail.matchedRoute.coordinates && activeTripDetail.matchedRoute.coordinates.length > 1) { + return { + positions: activeTripDetail.matchedRoute.coordinates, + isMatched: true, + distance: activeTripDetail.matchedRoute.distance, + duration: activeTripDetail.matchedRoute.duration, + confidence: activeTripDetail.matchedRoute.confidence, + isActiveTrip: true, + }; + } - const history = locationHistory as LocationHistoryResponse; - - // If we have OSRM matched route, use road-snapped coordinates - if (history.matchedRoute && history.matchedRoute.coordinates && history.matchedRoute.coordinates.length > 1) { - return { - positions: history.matchedRoute.coordinates, - isMatched: true, - distance: history.matchedRoute.distance, // already in miles from backend - duration: history.matchedRoute.duration, - confidence: history.matchedRoute.confidence, - }; + // Fall back to raw GPS points from active trip + if (activeTripDetail.rawPoints && activeTripDetail.rawPoints.length > 1) { + return { + positions: activeTripDetail.rawPoints.map(loc => [loc.latitude, loc.longitude] as [number, number]), + isMatched: false, + distance: activeTripDetail.distanceMiles || undefined, + duration: undefined, + confidence: undefined, + isActiveTrip: true, + }; + } } - // Fall back to raw GPS points - if (history.rawPoints && history.rawPoints.length > 1) { - return { - positions: history.rawPoints.map(loc => [loc.latitude, loc.longitude] as [number, number]), - isMatched: false, - distance: undefined, - duration: undefined, - confidence: undefined, - }; + // Fall back to full location history if enabled + if (showFullHistory && locationHistory) { + const history = locationHistory as LocationHistoryResponse; + + // If we have OSRM matched route, use road-snapped coordinates + if (history.matchedRoute && history.matchedRoute.coordinates && history.matchedRoute.coordinates.length > 1) { + return { + positions: history.matchedRoute.coordinates, + isMatched: true, + distance: history.matchedRoute.distance, + duration: history.matchedRoute.duration, + confidence: history.matchedRoute.confidence, + isActiveTrip: false, + }; + } + + // Fall back to raw GPS points + if (history.rawPoints && history.rawPoints.length > 1) { + return { + positions: history.rawPoints.map(loc => [loc.latitude, loc.longitude] as [number, number]), + isMatched: false, + distance: undefined, + duration: undefined, + confidence: undefined, + isActiveTrip: false, + }; + } } return null; - }, [locationHistory]); + }, [activeTripDetail, locationHistory, showFullHistory]); // Mutations const updateSettings = useUpdateGpsSettings(); @@ -470,7 +1359,7 @@ export function GpsTracking() { )} {/* Map Legend & Controls */} -
+

Map Controls

@@ -489,6 +1378,12 @@ export function GpsTracking() {
GPS Trail
+ {routePolyline && routePolyline.isActiveTrip && ( +
+ + Live Trip +
+ )} {routePolyline && routePolyline.distance != null && (
@@ -511,6 +1406,15 @@ export function GpsTracking() { /> Show Trails + {selectedDriverForTrail && (