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:
@@ -10,6 +10,9 @@ import type {
|
||||
MyGpsStatus,
|
||||
DeviceQrInfo,
|
||||
LocationHistoryResponse,
|
||||
GpsTrip,
|
||||
GpsTripDetail,
|
||||
TripStatus,
|
||||
} from '@/types/gps';
|
||||
import toast from 'react-hot-toast';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
@@ -162,6 +165,99 @@ export function useDriverStats(driverId: string, from?: string, to?: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Trip Hooks
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get trips for a driver
|
||||
*/
|
||||
export function useDriverTrips(driverId: string | null, from?: string, to?: string, status?: TripStatus) {
|
||||
return useQuery<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
|
||||
*/
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -107,6 +107,42 @@ export interface MyGpsStatus {
|
||||
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 {
|
||||
rawPoints: Array<{
|
||||
latitude: number;
|
||||
|
||||
Reference in New Issue
Block a user