refactor: use Traccar trip API instead of custom detection (#23)
Replace custom trip detection (overlapping/micro-trip prone) with Traccar's built-in trip report API. Remove merge/backfill UI and endpoints. Add geocoded address display to trip cards. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -174,7 +174,7 @@ export class GpsController {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get trips for a driver
|
* Get trips for a driver (powered by Traccar)
|
||||||
*/
|
*/
|
||||||
@Get('trips/:driverId')
|
@Get('trips/:driverId')
|
||||||
@Roles(Role.ADMINISTRATOR)
|
@Roles(Role.ADMINISTRATOR)
|
||||||
@@ -182,11 +182,10 @@ export class GpsController {
|
|||||||
@Param('driverId') driverId: string,
|
@Param('driverId') driverId: string,
|
||||||
@Query('from') fromStr?: string,
|
@Query('from') fromStr?: string,
|
||||||
@Query('to') toStr?: string,
|
@Query('to') toStr?: string,
|
||||||
@Query('status') status?: string,
|
|
||||||
) {
|
) {
|
||||||
const from = fromStr ? new Date(fromStr) : undefined;
|
const from = fromStr ? new Date(fromStr) : undefined;
|
||||||
const to = toStr ? new Date(toStr) : undefined;
|
const to = toStr ? new Date(toStr) : undefined;
|
||||||
return this.gpsService.getDriverTrips(driverId, from, to, status as any);
|
return this.gpsService.getDriverTrips(driverId, from, to);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -210,31 +209,6 @@ export class GpsController {
|
|||||||
return this.gpsService.getTripDetail(driverId, tripId);
|
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, Prisma, TripStatus, User } from '@prisma/client';
|
import { GpsSettings, User } from '@prisma/client';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -959,8 +959,7 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
data: { lastActive: new Date(latestPosition.deviceTime) },
|
data: { lastActive: new Date(latestPosition.deviceTime) },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trip detection for this device
|
// Trip detection handled by Traccar natively
|
||||||
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}`);
|
||||||
}
|
}
|
||||||
@@ -970,458 +969,49 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Trip Detection & Management
|
// Trip Management (powered by Traccar)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
private static readonly IDLE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes - balances splitting real stops vs GPS gaps
|
|
||||||
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
|
|
||||||
private static readonly MIN_TRIP_DISTANCE_MI = 0.1; // Minimum trip distance to keep
|
|
||||||
private static readonly MIN_TRIP_DURATION_S = 60; // Minimum trip duration to keep (seconds)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect trips for a device after position sync.
|
* Generate a deterministic trip ID from device ID + start time
|
||||||
* Creates new trips when movement starts, completes them after 15 min idle.
|
|
||||||
* Only considers positions AFTER the last completed trip to prevent overlaps.
|
|
||||||
*/
|
*/
|
||||||
private async detectTripsForDevice(deviceId: string): Promise<void> {
|
private generateTripId(deviceId: string, startTime: string): string {
|
||||||
try {
|
return crypto
|
||||||
// Find any ACTIVE trip for this device
|
.createHash('sha256')
|
||||||
const activeTrip = await this.prisma.gpsTrip.findFirst({
|
.update(`${deviceId}:${startTime}`)
|
||||||
where: { deviceId, status: TripStatus.ACTIVE },
|
.digest('hex')
|
||||||
});
|
.substring(0, 24);
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
if (!activeTrip) {
|
|
||||||
// No active trip - find the most recent non-ACTIVE trip to avoid overlap
|
|
||||||
const lastTrip = await this.prisma.gpsTrip.findFirst({
|
|
||||||
where: {
|
|
||||||
deviceId,
|
|
||||||
status: { not: TripStatus.ACTIVE },
|
|
||||||
},
|
|
||||||
orderBy: { endTime: 'desc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only look at positions AFTER the last trip ended (or last 20 min if no trips)
|
|
||||||
const lookAfter = lastTrip?.endTime
|
|
||||||
? new Date(lastTrip.endTime.getTime() + 1000) // 1 second after last trip ended
|
|
||||||
: new Date(now.getTime() - 20 * 60 * 1000);
|
|
||||||
|
|
||||||
const newPositions = await this.prisma.gpsLocationHistory.findMany({
|
|
||||||
where: {
|
|
||||||
deviceId,
|
|
||||||
timestamp: { gte: lookAfter },
|
|
||||||
},
|
|
||||||
orderBy: { timestamp: 'asc' },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (newPositions.length === 0) return;
|
|
||||||
|
|
||||||
// Find the first position with meaningful movement
|
|
||||||
const firstMoving = newPositions.find(
|
|
||||||
(p) => (p.speed || 0) > GpsService.MOVING_SPEED_MPH,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (firstMoving) {
|
|
||||||
// Double-check no trip already exists at this start time
|
|
||||||
const existingAtTime = await this.prisma.gpsTrip.findFirst({
|
|
||||||
where: {
|
|
||||||
deviceId,
|
|
||||||
startTime: firstMoving.timestamp,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existingAtTime) {
|
|
||||||
await this.prisma.gpsTrip.create({
|
|
||||||
data: {
|
|
||||||
deviceId,
|
|
||||||
status: TripStatus.ACTIVE,
|
|
||||||
startTime: firstMoving.timestamp,
|
|
||||||
startLatitude: firstMoving.latitude,
|
|
||||||
startLongitude: firstMoving.longitude,
|
|
||||||
pointCount: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`[Trip] New trip started for device ${deviceId} at ${firstMoving.timestamp.toISOString()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Active trip exists - check if driver has been idle
|
|
||||||
// Get ALL positions since the trip started, most recent first
|
|
||||||
const tripPositions = await this.prisma.gpsLocationHistory.findMany({
|
|
||||||
where: {
|
|
||||||
deviceId,
|
|
||||||
timestamp: { gte: activeTrip.startTime },
|
|
||||||
},
|
|
||||||
orderBy: { timestamp: 'desc' },
|
|
||||||
take: 200,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (tripPositions.length === 0) return;
|
|
||||||
|
|
||||||
const latestPosition = tripPositions[0];
|
|
||||||
|
|
||||||
// Find the most recent position with movement
|
|
||||||
const lastMovingPosition = tripPositions.find(
|
|
||||||
(p) => (p.speed || 0) > GpsService.IDLE_SPEED_MPH,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!lastMovingPosition) {
|
|
||||||
// No movement at all since trip started - shouldn't happen, but clean up
|
|
||||||
const tripDuration = now.getTime() - activeTrip.startTime.getTime();
|
|
||||||
if (tripDuration >= GpsService.IDLE_THRESHOLD_MS) {
|
|
||||||
this.logger.log(`[Trip] Deleting trip ${activeTrip.id} - no movement detected`);
|
|
||||||
await this.prisma.gpsTrip.delete({ where: { id: activeTrip.id } });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check time since last movement (use latest GPS timestamp, not wall clock,
|
|
||||||
// to handle sparse data where the phone stops sending positions)
|
|
||||||
const latestTimestamp = latestPosition.timestamp;
|
|
||||||
const timeSinceMovement = latestTimestamp.getTime() - lastMovingPosition.timestamp.getTime();
|
|
||||||
|
|
||||||
// Also check wall clock gap - if no GPS data for 15+ min, the phone stopped reporting
|
|
||||||
const timeSinceLastData = now.getTime() - latestTimestamp.getTime();
|
|
||||||
const effectiveIdleTime = Math.max(timeSinceMovement, timeSinceLastData);
|
|
||||||
|
|
||||||
if (effectiveIdleTime >= GpsService.IDLE_THRESHOLD_MS) {
|
|
||||||
// Been idle long enough - complete the trip
|
|
||||||
await this.completeTrip(activeTrip.id, lastMovingPosition);
|
|
||||||
} else {
|
|
||||||
// Still driving - update point count
|
|
||||||
await this.prisma.gpsTrip.update({
|
|
||||||
where: { id: activeTrip.id },
|
|
||||||
data: { pointCount: tripPositions.length },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(
|
|
||||||
`[Trip] Detection failed for device ${deviceId}: ${error.message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Complete a trip and queue OSRM finalization
|
* Map a Traccar trip to our frontend GpsTrip format
|
||||||
*/
|
*/
|
||||||
private async completeTrip(
|
private mapTraccarTrip(deviceId: string, trip: any, status = 'COMPLETED') {
|
||||||
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) {
|
|
||||||
this.logger.log(`[Trip] Deleting micro-trip ${tripId}: only ${points.length} points`);
|
|
||||||
await this.prisma.gpsTrip.delete({ where: { id: tripId } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check minimum duration
|
|
||||||
const tripDurationS = Math.round(
|
|
||||||
(trip.endTime.getTime() - trip.startTime.getTime()) / 1000,
|
|
||||||
);
|
|
||||||
if (tripDurationS < GpsService.MIN_TRIP_DURATION_S) {
|
|
||||||
this.logger.log(`[Trip] Deleting micro-trip ${tripId}: only ${tripDurationS}s duration`);
|
|
||||||
await this.prisma.gpsTrip.delete({ where: { id: tripId } });
|
|
||||||
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;
|
|
||||||
|
|
||||||
// tripDurationS already computed above for the minimum check
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete trips that are too short in distance
|
|
||||||
if (distanceMiles < GpsService.MIN_TRIP_DISTANCE_MI) {
|
|
||||||
this.logger.log(`[Trip] Deleting micro-trip ${tripId}: only ${distanceMiles.toFixed(2)} mi`);
|
|
||||||
await this.prisma.gpsTrip.delete({ where: { id: tripId } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.gpsTrip.update({
|
|
||||||
where: { id: tripId },
|
|
||||||
data: {
|
|
||||||
status: matchedRouteData ? TripStatus.COMPLETED : TripStatus.FAILED,
|
|
||||||
distanceMiles: Math.round(distanceMiles * 10) / 10,
|
|
||||||
durationSeconds: tripDurationS,
|
|
||||||
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 {
|
return {
|
||||||
...trip,
|
id: this.generateTripId(deviceId, trip.startTime),
|
||||||
rawPoints,
|
deviceId,
|
||||||
|
status,
|
||||||
|
startTime: trip.startTime,
|
||||||
|
endTime: trip.endTime,
|
||||||
|
startLatitude: trip.startLat,
|
||||||
|
startLongitude: trip.startLon,
|
||||||
|
endLatitude: trip.endLat,
|
||||||
|
endLongitude: trip.endLon,
|
||||||
|
distanceMiles: Math.round((trip.distance / 1609.34) * 10) / 10,
|
||||||
|
durationSeconds: Math.round(trip.duration / 1000),
|
||||||
|
topSpeedMph: Math.round(this.traccarClient.knotsToMph(trip.maxSpeed)),
|
||||||
|
averageSpeedMph:
|
||||||
|
Math.round(this.traccarClient.knotsToMph(trip.averageSpeed) * 10) / 10,
|
||||||
|
pointCount: 0,
|
||||||
|
startAddress: trip.startAddress,
|
||||||
|
endAddress: trip.endAddress,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get active trip for a driver (if any)
|
* Get trips for a driver (from Traccar trip report)
|
||||||
*/
|
*/
|
||||||
async getActiveTrip(driverId: string) {
|
async getDriverTrips(driverId: string, fromDate?: Date, toDate?: Date) {
|
||||||
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({
|
const device = await this.prisma.gpsDevice.findUnique({
|
||||||
where: { driverId },
|
where: { driverId },
|
||||||
});
|
});
|
||||||
@@ -1433,107 +1023,137 @@ Note: GPS tracking is only active during shift hours (${settings.shiftStartHour}
|
|||||||
const to = toDate || new Date();
|
const to = toDate || new Date();
|
||||||
const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000);
|
const from = fromDate || new Date(to.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
this.logger.log(
|
const traccarTrips = await this.traccarClient.getTripReport(
|
||||||
`[Trip Backfill] Processing ${driverId} from ${from.toISOString()} to ${to.toISOString()}`,
|
device.traccarDeviceId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete existing trips in this range (avoid duplicates)
|
return traccarTrips.map((t) => this.mapTraccarTrip(device.id, t));
|
||||||
await this.prisma.gpsTrip.deleteMany({
|
}
|
||||||
where: {
|
|
||||||
deviceId: device.id,
|
/**
|
||||||
startTime: { gte: from, lte: to },
|
* Get a single trip with full detail (matchedRoute + rawPoints)
|
||||||
},
|
*/
|
||||||
|
async getTripDetail(driverId: string, tripId: string) {
|
||||||
|
const device = await this.prisma.gpsDevice.findUnique({
|
||||||
|
where: { driverId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch all points in range
|
if (!device) {
|
||||||
const points = await this.prisma.gpsLocationHistory.findMany({
|
throw new NotFoundException('Driver is not enrolled for GPS tracking');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search last 30 days for the matching trip
|
||||||
|
const to = new Date();
|
||||||
|
const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const traccarTrips = await this.traccarClient.getTripReport(
|
||||||
|
device.traccarDeviceId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
);
|
||||||
|
|
||||||
|
const trip = traccarTrips.find(
|
||||||
|
(t) => this.generateTripId(device.id, t.startTime) === tripId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!trip) {
|
||||||
|
throw new NotFoundException('Trip not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch raw points for this trip from our location history
|
||||||
|
const rawPoints = await this.prisma.gpsLocationHistory.findMany({
|
||||||
where: {
|
where: {
|
||||||
deviceId: device.id,
|
deviceId: device.id,
|
||||||
timestamp: { gte: from, lte: to },
|
timestamp: {
|
||||||
|
gte: new Date(trip.startTime),
|
||||||
|
lte: new Date(trip.endTime),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
orderBy: { timestamp: 'asc' },
|
orderBy: { timestamp: 'asc' },
|
||||||
});
|
select: {
|
||||||
|
latitude: true,
|
||||||
if (points.length < 2) {
|
longitude: true,
|
||||||
return { tripsCreated: 0 };
|
speed: true,
|
||||||
}
|
course: true,
|
||||||
|
battery: true,
|
||||||
// Detect trips from historical data
|
timestamp: true,
|
||||||
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;
|
|
||||||
|
|
||||||
// Check minimum duration before creating
|
|
||||||
const duration = (endPoint.timestamp.getTime() - startPoint.timestamp.getTime()) / 1000;
|
|
||||||
if (duration < GpsService.MIN_TRIP_DURATION_S) 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
|
// Try OSRM map matching for the route visualization
|
||||||
// finalizeTrip will delete the trip if distance is too short
|
let matchedRoute = null;
|
||||||
await this.finalizeTrip(trip.id);
|
if (rawPoints.length >= 2) {
|
||||||
// Check if trip still exists (finalizeTrip may have deleted micro-trips)
|
try {
|
||||||
const stillExists = await this.prisma.gpsTrip.findUnique({ where: { id: trip.id } });
|
const matchResult = await this.osrmService.matchRoute(
|
||||||
if (stillExists) tripsCreated++;
|
rawPoints.map((p) => ({
|
||||||
|
latitude: p.latitude,
|
||||||
|
longitude: p.longitude,
|
||||||
|
timestamp: p.timestamp,
|
||||||
|
speed: p.speed ?? undefined,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchResult) {
|
||||||
|
matchedRoute = {
|
||||||
|
coordinates: matchResult.coordinates,
|
||||||
|
distance: matchResult.distance / 1609.34,
|
||||||
|
duration: matchResult.duration,
|
||||||
|
confidence: matchResult.confidence,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(`OSRM failed for trip ${tripId}: ${error.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`[Trip Backfill] Created and finalized ${tripsCreated} trips`);
|
return {
|
||||||
return { tripsCreated };
|
...this.mapTraccarTrip(device.id, trip),
|
||||||
|
pointCount: rawPoints.length,
|
||||||
|
matchedRoute,
|
||||||
|
rawPoints,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active trip for a driver (if any)
|
||||||
|
* Checks Traccar for a trip that ended very recently (still in progress)
|
||||||
|
*/
|
||||||
|
async getActiveTrip(driverId: string) {
|
||||||
|
const device = await this.prisma.gpsDevice.findUnique({
|
||||||
|
where: { driverId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const to = new Date();
|
||||||
|
const from = new Date(to.getTime() - 60 * 60 * 1000); // Last hour
|
||||||
|
|
||||||
|
const traccarTrips = await this.traccarClient.getTripReport(
|
||||||
|
device.traccarDeviceId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
);
|
||||||
|
|
||||||
|
// A trip is "active" if it ended less than 5 minutes ago
|
||||||
|
const activeTrip = traccarTrips.find((t) => {
|
||||||
|
const endTime = new Date(t.endTime);
|
||||||
|
return to.getTime() - endTime.getTime() < 5 * 60 * 1000;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!activeTrip) return null;
|
||||||
|
|
||||||
|
return this.mapTraccarTrip(device.id, activeTrip, 'ACTIVE');
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Failed to check active trip for ${driverId}: ${error.message}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ export class TraccarClientService implements OnModuleInit {
|
|||||||
deviceId: number,
|
deviceId: number,
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date,
|
to: Date,
|
||||||
): Promise<any[]> {
|
): Promise<TraccarTrip[]> {
|
||||||
const fromStr = from.toISOString();
|
const fromStr = from.toISOString();
|
||||||
const toStr = to.toISOString();
|
const toStr = to.toISOString();
|
||||||
return this.request('get', `/api/reports/trips?deviceId=${deviceId}&from=${fromStr}&to=${toStr}`);
|
return this.request('get', `/api/reports/trips?deviceId=${deviceId}&from=${fromStr}&to=${toStr}`);
|
||||||
@@ -567,3 +567,27 @@ export interface TraccarUser {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
attributes: Record<string, any>;
|
attributes: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TraccarTrip {
|
||||||
|
deviceId: number;
|
||||||
|
deviceName: string;
|
||||||
|
distance: number; // meters
|
||||||
|
averageSpeed: number; // knots
|
||||||
|
maxSpeed: number; // knots
|
||||||
|
spentFuel: number;
|
||||||
|
startOdometer: number;
|
||||||
|
endOdometer: number;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
startPositionId: number;
|
||||||
|
endPositionId: number;
|
||||||
|
startLat: number;
|
||||||
|
startLon: number;
|
||||||
|
endLat: number;
|
||||||
|
endLon: number;
|
||||||
|
startAddress: string | null;
|
||||||
|
endAddress: string | null;
|
||||||
|
duration: number; // milliseconds
|
||||||
|
driverUniqueId: string | null;
|
||||||
|
driverName: string | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type {
|
|||||||
LocationHistoryResponse,
|
LocationHistoryResponse,
|
||||||
GpsTrip,
|
GpsTrip,
|
||||||
GpsTripDetail,
|
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';
|
||||||
@@ -170,16 +169,15 @@ export function useDriverStats(driverId: string, from?: string, to?: string) {
|
|||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get trips for a driver
|
* Get trips for a driver (powered by Traccar)
|
||||||
*/
|
*/
|
||||||
export function useDriverTrips(driverId: string | null, from?: string, to?: string, status?: TripStatus) {
|
export function useDriverTrips(driverId: string | null, from?: string, to?: string) {
|
||||||
return useQuery<GpsTrip[]>({
|
return useQuery<GpsTrip[]>({
|
||||||
queryKey: ['gps', 'trips', driverId, from, to, status],
|
queryKey: ['gps', 'trips', driverId, from, to],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (from) params.append('from', from);
|
if (from) params.append('from', from);
|
||||||
if (to) params.append('to', to);
|
if (to) params.append('to', to);
|
||||||
if (status) params.append('status', status);
|
|
||||||
const { data } = await api.get(`/gps/trips/${driverId}?${params}`);
|
const { data } = await api.get(`/gps/trips/${driverId}?${params}`);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
@@ -216,48 +214,6 @@ export function useActiveTrip(driverId: string | null) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
QrCode,
|
QrCode,
|
||||||
Play,
|
Play,
|
||||||
Calendar,
|
Calendar,
|
||||||
Merge,
|
|
||||||
ToggleLeft,
|
ToggleLeft,
|
||||||
ToggleRight,
|
ToggleRight,
|
||||||
Timer,
|
Timer,
|
||||||
@@ -51,8 +50,6 @@ import {
|
|||||||
useDriverLocationHistory,
|
useDriverLocationHistory,
|
||||||
useDriverTrips,
|
useDriverTrips,
|
||||||
useActiveTrip,
|
useActiveTrip,
|
||||||
useMergeTrips,
|
|
||||||
useBackfillTrips,
|
|
||||||
} from '@/hooks/useGps';
|
} from '@/hooks/useGps';
|
||||||
import { Loading } from '@/components/Loading';
|
import { Loading } from '@/components/Loading';
|
||||||
import { ErrorMessage } from '@/components/ErrorMessage';
|
import { ErrorMessage } from '@/components/ErrorMessage';
|
||||||
@@ -223,8 +220,6 @@ interface StatsTabProps {
|
|||||||
setDateTo: (date: string) => void;
|
setDateTo: (date: string) => void;
|
||||||
toggledTripIds: Set<string>;
|
toggledTripIds: Set<string>;
|
||||||
setToggledTripIds: (ids: Set<string>) => void;
|
setToggledTripIds: (ids: Set<string>) => void;
|
||||||
selectedTripIds: Set<string>;
|
|
||||||
setSelectedTripIds: (ids: Set<string>) => void;
|
|
||||||
expandedDates: Set<string>;
|
expandedDates: Set<string>;
|
||||||
setExpandedDates: (dates: Set<string>) => void;
|
setExpandedDates: (dates: Set<string>) => void;
|
||||||
}
|
}
|
||||||
@@ -239,14 +234,9 @@ function StatsTab({
|
|||||||
setDateTo,
|
setDateTo,
|
||||||
toggledTripIds,
|
toggledTripIds,
|
||||||
setToggledTripIds,
|
setToggledTripIds,
|
||||||
selectedTripIds,
|
|
||||||
setSelectedTripIds,
|
|
||||||
expandedDates,
|
expandedDates,
|
||||||
setExpandedDates,
|
setExpandedDates,
|
||||||
}: StatsTabProps) {
|
}: StatsTabProps) {
|
||||||
const backfillTrips = useBackfillTrips();
|
|
||||||
const mergeTrips = useMergeTrips();
|
|
||||||
|
|
||||||
// Fetch trips for selected driver
|
// Fetch trips for selected driver
|
||||||
const { data: trips, isLoading: tripsLoading } = useDriverTrips(
|
const { data: trips, isLoading: tripsLoading } = useDriverTrips(
|
||||||
selectedDriverId || null,
|
selectedDriverId || null,
|
||||||
@@ -316,16 +306,6 @@ function StatsTab({
|
|||||||
setToggledTripIds(newSet);
|
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 handleToggleDate = (dateKey: string) => {
|
||||||
const newSet = new Set(expandedDates);
|
const newSet = new Set(expandedDates);
|
||||||
if (newSet.has(dateKey)) {
|
if (newSet.has(dateKey)) {
|
||||||
@@ -336,23 +316,6 @@ function StatsTab({
|
|||||||
setExpandedDates(newSet);
|
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
|
// Playback state
|
||||||
const [playingTripId, setPlayingTripId] = useState<string | null>(null);
|
const [playingTripId, setPlayingTripId] = useState<string | null>(null);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
@@ -486,19 +449,6 @@ function StatsTab({
|
|||||||
setPlaybackProgress(0);
|
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) => {
|
const getStatusColor = (status: TripStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'ACTIVE':
|
case 'ACTIVE':
|
||||||
@@ -540,7 +490,6 @@ function StatsTab({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setSelectedDriverId(e.target.value);
|
setSelectedDriverId(e.target.value);
|
||||||
setToggledTripIds(new Set());
|
setToggledTripIds(new Set());
|
||||||
setSelectedTripIds(new Set());
|
|
||||||
}}
|
}}
|
||||||
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary"
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:ring-primary focus:border-primary"
|
||||||
>
|
>
|
||||||
@@ -573,14 +522,6 @@ function StatsTab({
|
|||||||
className="w-full px-2 py-1 text-sm border border-input rounded-md bg-background text-foreground"
|
className="w-full px-2 py-1 text-sm border border-input rounded-md bg-background text-foreground"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleBackfill}
|
|
||||||
disabled={!selectedDriverId || backfillTrips.isPending}
|
|
||||||
className="px-3 py-1 text-sm bg-primary text-white rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
|
||||||
title="Backfill trips from historical data"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`h-4 w-4 ${backfillTrips.isPending ? 'animate-spin' : ''}`} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Stats Bar */}
|
{/* Summary Stats Bar */}
|
||||||
@@ -603,18 +544,6 @@ function StatsTab({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Merge Button */}
|
|
||||||
{selectedTripIds.size > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={handleMergeSelected}
|
|
||||||
disabled={selectedTripIds.size !== 2 || mergeTrips.isPending}
|
|
||||||
className="mb-4 w-full px-3 py-2 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<Merge className="h-4 w-4" />
|
|
||||||
Merge Selected ({selectedTripIds.size})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Trip List */}
|
{/* Trip List */}
|
||||||
<div className="flex-1 overflow-y-auto space-y-3">
|
<div className="flex-1 overflow-y-auto space-y-3">
|
||||||
{!selectedDriverId ? (
|
{!selectedDriverId ? (
|
||||||
@@ -649,15 +578,7 @@ function StatsTab({
|
|||||||
: 'border-border bg-card hover:bg-accent'
|
: 'border-border bg-card hover:bg-accent'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div>
|
||||||
{/* Checkbox for selection */}
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedTripIds.has(trip.id)}
|
|
||||||
onChange={() => handleSelectTrip(trip.id)}
|
|
||||||
className="mt-1 rounded border-gray-300 text-primary focus:ring-primary"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* Time Range */}
|
{/* Time Range */}
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
@@ -670,6 +591,24 @@ function StatsTab({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Addresses */}
|
||||||
|
{(trip.startAddress || trip.endAddress) && (
|
||||||
|
<div className="text-xs text-muted-foreground mb-2 space-y-0.5">
|
||||||
|
{trip.startAddress && (
|
||||||
|
<div className="truncate" title={trip.startAddress}>
|
||||||
|
<MapPin className="h-3 w-3 inline mr-1 text-green-500" />
|
||||||
|
{trip.startAddress}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{trip.endAddress && (
|
||||||
|
<div className="truncate" title={trip.endAddress}>
|
||||||
|
<MapPin className="h-3 w-3 inline mr-1 text-red-500" />
|
||||||
|
{trip.endAddress}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground mb-2">
|
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground mb-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -958,7 +897,6 @@ export function GpsTracking() {
|
|||||||
const [dateFrom, setDateFrom] = useState(format(subDays(new Date(), 7), 'yyyy-MM-dd'));
|
const [dateFrom, setDateFrom] = useState(format(subDays(new Date(), 7), 'yyyy-MM-dd'));
|
||||||
const [dateTo, setDateTo] = useState(format(new Date(), 'yyyy-MM-dd'));
|
const [dateTo, setDateTo] = useState(format(new Date(), 'yyyy-MM-dd'));
|
||||||
const [toggledTripIds, setToggledTripIds] = useState<Set<string>>(new Set());
|
const [toggledTripIds, setToggledTripIds] = useState<Set<string>>(new Set());
|
||||||
const [selectedTripIds, setSelectedTripIds] = useState<Set<string>>(new Set());
|
|
||||||
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
|
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// Check admin access
|
// Check admin access
|
||||||
@@ -1550,8 +1488,6 @@ export function GpsTracking() {
|
|||||||
setDateTo={setDateTo}
|
setDateTo={setDateTo}
|
||||||
toggledTripIds={toggledTripIds}
|
toggledTripIds={toggledTripIds}
|
||||||
setToggledTripIds={setToggledTripIds}
|
setToggledTripIds={setToggledTripIds}
|
||||||
selectedTripIds={selectedTripIds}
|
|
||||||
setSelectedTripIds={setSelectedTripIds}
|
|
||||||
expandedDates={expandedDates}
|
expandedDates={expandedDates}
|
||||||
setExpandedDates={setExpandedDates}
|
setExpandedDates={setExpandedDates}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ export interface GpsTrip {
|
|||||||
topSpeedMph: number | null;
|
topSpeedMph: number | null;
|
||||||
averageSpeedMph: number | null;
|
averageSpeedMph: number | null;
|
||||||
pointCount: number;
|
pointCount: number;
|
||||||
|
startAddress?: string | null;
|
||||||
|
endAddress?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GpsTripDetail extends GpsTrip {
|
export interface GpsTripDetail extends GpsTrip {
|
||||||
|
|||||||
Reference in New Issue
Block a user