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 <noreply@anthropic.com>
This commit is contained in:
2026-02-08 18:08:48 +01:00
parent cb4a070ad9
commit cc3375ef85
7 changed files with 1746 additions and 93 deletions

View File

@@ -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;

View File

@@ -405,6 +405,7 @@ model GpsDevice {
// Location history // Location history
locationHistory GpsLocationHistory[] locationHistory GpsLocationHistory[]
trips GpsTrip[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -434,6 +435,45 @@ model GpsLocationHistory {
@@index([timestamp]) // For cleanup job @@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 { model GpsSettings {
id String @id @default(uuid()) id String @id @default(uuid())

View File

@@ -169,6 +169,72 @@ export class GpsController {
return this.gpsService.getDriverStats(driverId, from, to); 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 // Traccar Admin Access
// ============================================ // ============================================

View File

@@ -18,7 +18,7 @@ import {
LocationDataDto, LocationDataDto,
} from './dto/location-response.dto'; } from './dto/location-response.dto';
import { UpdateGpsSettingsDto } from './dto/update-gps-settings.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'; import * as crypto from 'crypto';
@Injectable() @Injectable()
@@ -958,6 +958,9 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
where: { id: device.id }, where: { id: device.id },
data: { lastActive: new Date(latestPosition.deviceTime) }, data: { lastActive: new Date(latestPosition.deviceTime) },
}); });
// Trip detection for this device
await this.detectTripsForDevice(device.id);
} catch (error) { } catch (error) {
this.logger.error(`[GPS Sync] Failed to sync positions for device ${device.traccarDeviceId}: ${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'); 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<void> {
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<void> {
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<void> {
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) * Clean up old location history (runs daily at 2 AM)
*/ */

View File

@@ -10,6 +10,9 @@ import type {
MyGpsStatus, MyGpsStatus,
DeviceQrInfo, DeviceQrInfo,
LocationHistoryResponse, LocationHistoryResponse,
GpsTrip,
GpsTripDetail,
TripStatus,
} from '@/types/gps'; } from '@/types/gps';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { queryKeys } from '@/lib/query-keys'; 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<GpsTrip[]>({
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<GpsTripDetail>({
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<GpsTrip | null>({
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 * Enroll a driver for GPS tracking
*/ */

File diff suppressed because it is too large Load Diff

View File

@@ -107,6 +107,42 @@ export interface MyGpsStatus {
message?: string; message?: string;
} }
export type TripStatus = 'ACTIVE' | 'COMPLETED' | 'PROCESSING' | 'FAILED';
export interface GpsTrip {
id: string;
deviceId: string;
status: TripStatus;
startTime: string;
endTime: string | null;
startLatitude: number;
startLongitude: number;
endLatitude: number | null;
endLongitude: number | null;
distanceMiles: number | null;
durationSeconds: number | null;
topSpeedMph: number | null;
averageSpeedMph: number | null;
pointCount: number;
}
export interface GpsTripDetail extends GpsTrip {
matchedRoute: {
coordinates: [number, number][];
distance: number;
duration: number;
confidence: number;
} | null;
rawPoints: Array<{
latitude: number;
longitude: number;
speed: number | null;
course?: number | null;
battery?: number | null;
timestamp: string;
}>;
}
export interface LocationHistoryResponse { export interface LocationHistoryResponse {
rawPoints: Array<{ rawPoints: Array<{
latitude: number; latitude: number;