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:
@@ -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;
|
||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -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)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user