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

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

View File

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