diff --git a/__tests__/components/shipments/TrackingMapEta.test.tsx b/__tests__/components/shipments/TrackingMapEta.test.tsx new file mode 100644 index 00000000..fe611bd0 --- /dev/null +++ b/__tests__/components/shipments/TrackingMapEta.test.tsx @@ -0,0 +1,380 @@ +/** + * [Test] Component: View Estimated Delivery Date Map Logic & UI (Issue #487) + * + * Automated component tests verifying that the real TrackingMap component: + * 1. Renders physical route updates (origin, destination, checkpoints, live + * in-transit driver markers, polylines, and map bounds updates). + * 2. Calculates and displays ETA and Estimated Delivery Date accurately + * across initial routes, live driver telemetry updates, and fallback + * distance calculations. + * 3. Handles edge cases, empty states, and error states reliably with all + * external dependencies (Leaflet, APIs, WebSockets, Web3 wallets) mocked. + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { + TrackingMap, + calculateRemainingEtaMinutes, + calculateEstimatedDeliveryDate, +} from '@/components/shipments/TrackingMap'; +import { useTrackingRoute } from '@/hooks/useTrackingRoute'; +import type { RouteData } from '@/types/tracking'; + +const mockFitBounds = jest.fn(); + +jest.mock('@/hooks/useTrackingRoute'); +const mockUseTrackingRoute = useTrackingRoute as jest.MockedFunction< + typeof useTrackingRoute +>; + +jest.mock('leaflet', () => ({ + __esModule: true, + default: { + icon: jest.fn((opts) => opts), + latLngBounds: jest.fn((sw, ne) => ({ sw, ne })), + }, +})); + +jest.mock('react-leaflet', () => { + const ReactLib = require('react'); + return { + __esModule: true, + MapContainer: ({ children, center, zoom, style }: Record) => + ReactLib.createElement( + 'div', + { + 'data-testid': 'map-container', + 'data-center': Array.isArray(center) ? center.join(',') : '', + 'data-zoom': String(zoom), + style, + }, + children as React.ReactNode, + ), + TileLayer: ({ url }: { url: string }) => + ReactLib.createElement('div', { + 'data-testid': 'tile-layer', + 'data-url': url, + }), + Marker: ({ + children, + position, + }: { + children: React.ReactNode; + position: [number, number]; + }) => + ReactLib.createElement( + 'div', + { + 'data-testid': 'map-marker', + 'data-position': position.join(','), + }, + children, + ), + Popup: ({ children }: { children: React.ReactNode }) => + ReactLib.createElement('div', { 'data-testid': 'map-popup' }, children), + Polyline: ({ positions }: { positions: [number, number][] }) => + ReactLib.createElement('div', { + 'data-testid': 'polyline', + 'data-positions': positions.map((p) => p.join(',')).join(';'), + }), + useMap: () => ({ + fitBounds: mockFitBounds, + }), + }; +}); + +describe('TrackingMap Component: Physical Route Updates & ETA Calculation (Issue #487)', () => { + const deliveryId = 'deliv-eta-487'; + + const baseRouteData: RouteData = { + deliveryId, + origin: { latitude: 6.5244, longitude: 3.3792 }, + destination: { latitude: 6.6244, longitude: 3.3792 }, + waypoints: [ + { + coordinate: { latitude: 6.5244, longitude: 3.3792 }, + label: 'Apapa Dispatch', + type: 'origin', + }, + { + coordinate: { latitude: 6.5744, longitude: 3.3792 }, + label: 'Midway Hub', + type: 'checkpoint', + address: '14 Ikorodu Road', + }, + { + coordinate: { latitude: 6.6244, longitude: 3.3792 }, + label: 'Ikeja Destination', + type: 'destination', + }, + ], + distance: 20.0, + estimatedTime: 30, + currentLocation: { latitude: 6.5244, longitude: 3.3792 }, + status: 'in_transit', + timestamp: '2026-10-19T08:00:00.000Z', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Happy Path: Physical Route Rendering & Live Updates', () => { + it('renders the delivery route map, polyline coordinates, checkpoints, and fits bounds', () => { + mockUseTrackingRoute.mockReturnValue({ + routeData: baseRouteData, + currentLocation: baseRouteData.currentLocation!, + isLoading: false, + isError: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByTestId('map-container')).toBeInTheDocument(); + expect(screen.getByTestId('tile-layer')).toBeInTheDocument(); + expect(screen.getByTestId('polyline')).toHaveAttribute( + 'data-positions', + '6.5244,3.3792;6.5744,3.3792;6.6244,3.3792', + ); + expect(screen.getByText('Origin')).toBeInTheDocument(); + expect(screen.getByText('Destination')).toBeInTheDocument(); + expect(screen.getByText('Midway Hub')).toBeInTheDocument(); + expect(screen.getByText('Current Location')).toBeInTheDocument(); + expect(mockFitBounds).toHaveBeenCalledTimes(1); + }); + + it('updates physical route marker and remaining ETA when live driver location advances halfway to destination', () => { + mockUseTrackingRoute.mockReturnValue({ + routeData: baseRouteData, + currentLocation: { latitude: 6.5244, longitude: 3.3792 }, + isLoading: false, + isError: false, + error: null, + refetch: jest.fn(), + }); + + const { rerender } = render(); + + expect( + screen.getByTestId('current-location-coordinates'), + ).toHaveTextContent('6.5244, 3.3792'); + expect(screen.getByTestId('dynamic-remaining-eta')).toHaveTextContent( + '30 min remaining', + ); + + // Driver advances halfway from 6.5244 to 6.6244 -> 6.5744 + mockUseTrackingRoute.mockReturnValue({ + routeData: baseRouteData, + currentLocation: { latitude: 6.5744, longitude: 3.3792 }, + isLoading: false, + isError: false, + error: null, + refetch: jest.fn(), + }); + + rerender(); + + expect( + screen.getByTestId('current-location-coordinates'), + ).toHaveTextContent('6.5744, 3.3792'); + expect(screen.getByTestId('dynamic-remaining-eta')).toHaveTextContent( + '15 min remaining', + ); + }); + + it('re-renders the polyline and checkpoints when physical route waypoints are updated', () => { + mockUseTrackingRoute.mockReturnValue({ + routeData: baseRouteData, + currentLocation: baseRouteData.currentLocation!, + isLoading: false, + isError: false, + error: null, + refetch: jest.fn(), + }); + + const { rerender } = render(); + expect(screen.getByTestId('polyline')).toHaveAttribute( + 'data-positions', + '6.5244,3.3792;6.5744,3.3792;6.6244,3.3792', + ); + + const reroutedData: RouteData = { + ...baseRouteData, + waypoints: [ + ...baseRouteData.waypoints.slice(0, 2), + { + coordinate: { latitude: 6.6, longitude: 3.39 }, + label: 'Alternate Bypass', + type: 'checkpoint', + }, + baseRouteData.waypoints[2], + ], + distance: 24.0, + estimatedTime: 36, + }; + + mockUseTrackingRoute.mockReturnValue({ + routeData: reroutedData, + currentLocation: reroutedData.currentLocation!, + isLoading: false, + isError: false, + error: null, + refetch: jest.fn(), + }); + + rerender(); + + expect(screen.getByText('Alternate Bypass')).toBeInTheDocument(); + expect(screen.getByTestId('polyline')).toHaveAttribute( + 'data-positions', + '6.5244,3.3792;6.5744,3.3792;6.6,3.39;6.6244,3.3792', + ); + expect(screen.getByTestId('distance-value')).toHaveTextContent('24.0 km'); + expect(screen.getByTestId('estimated-time-value')).toHaveTextContent( + '36 min', + ); + }); + }); + + describe('ETA & Estimated Delivery Date Calculation Logic', () => { + it('calculates exact ISO estimated delivery date from route timestamp and ETA', () => { + mockUseTrackingRoute.mockReturnValue({ + routeData: baseRouteData, + currentLocation: baseRouteData.currentLocation!, + isLoading: false, + isError: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByTestId('estimated-time-value')).toHaveTextContent( + '30 min', + ); + expect(screen.getByTestId('estimated-delivery-date')).toHaveTextContent( + '2026-10-19T08:30:00.000Z', + ); + }); + + it('derives fallback ETA from route distance when estimatedTime is omitted', () => { + const routeWithoutExplicitEta: RouteData = { + ...baseRouteData, + estimatedTime: undefined, + distance: 20, // 20 km at 40 km/h = 30 min + }; + + mockUseTrackingRoute.mockReturnValue({ + routeData: routeWithoutExplicitEta, + currentLocation: null, + isLoading: false, + isError: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByTestId('estimated-time-value')).toHaveTextContent( + '30 min', + ); + expect(screen.getByTestId('estimated-delivery-date')).toHaveTextContent( + '2026-10-19T08:30:00.000Z', + ); + }); + + it('returns 0 remaining ETA when delivery status is delivered', () => { + const deliveredRoute: RouteData = { + ...baseRouteData, + status: 'delivered', + }; + + expect(calculateRemainingEtaMinutes(deliveredRoute)).toBe(0); + }); + + it('returns null for calculateEstimatedDeliveryDate when timestamp is invalid', () => { + expect(calculateEstimatedDeliveryDate('invalid-date', 25)).toBeNull(); + expect(calculateEstimatedDeliveryDate(undefined, 25)).toBeNull(); + }); + }); + + describe('Edge Cases, Empty States, and Error Handling', () => { + it('renders loading state with spinner while fetching route data', () => { + mockUseTrackingRoute.mockReturnValue({ + routeData: null, + currentLocation: null, + isLoading: true, + isError: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + expect(screen.getByText('Loading map...')).toBeInTheDocument(); + }); + + it('renders empty state when deliveryId is null or routeData is unavailable', () => { + mockUseTrackingRoute.mockReturnValue({ + routeData: null, + currentLocation: null, + isLoading: false, + isError: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByTestId('package-icon')).toBeInTheDocument(); + expect( + screen.getByText('No delivery data available'), + ).toBeInTheDocument(); + }); + + it('renders error alert and invokes onMapError callback when route fetch fails', () => { + const onMapError = jest.fn(); + mockUseTrackingRoute.mockReturnValue({ + routeData: null, + currentLocation: null, + isLoading: false, + isError: true, + error: 'Request timeout - unable to fetch route', + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByTestId('alert-icon')).toBeInTheDocument(); + expect( + screen.getByText('Request timeout - unable to fetch route'), + ).toBeInTheDocument(); + expect(onMapError).toHaveBeenCalledWith( + 'Request timeout - unable to fetch route', + ); + }); + + it('handles empty waypoints array gracefully without rendering a broken polyline', () => { + mockUseTrackingRoute.mockReturnValue({ + routeData: { + ...baseRouteData, + waypoints: [], + }, + currentLocation: null, + isLoading: false, + isError: false, + error: null, + refetch: jest.fn(), + }); + + render(); + + expect(screen.getByTestId('map-container')).toBeInTheDocument(); + expect(screen.queryByTestId('polyline')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/components/fleet/FleetMapClient.tsx b/components/fleet/FleetMapClient.tsx index 2e3f1fdf..45594a1f 100644 --- a/components/fleet/FleetMapClient.tsx +++ b/components/fleet/FleetMapClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { MapContainer, TileLayer, @@ -11,22 +11,48 @@ import { import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { cellSizeForZoom, clusterDrivers } from './clustering'; +import { useRouteOptimization } from '@/hooks/useRouteOptimization'; import type { Driver } from '@/types/fleet'; +import type { + OptimizerWaypoint, + RouteOptimizationResult, +} from '@/types/routeOptimizer'; -interface FleetMapClientProps { +export interface FleetMapClientProps { drivers: Driver[]; + waypoints?: OptimizerWaypoint[]; + onWaypointReorder?: ( + _reorderedWaypoints: OptimizerWaypoint[], + _result: RouteOptimizationResult, + ) => void; + snapRadiusDegrees?: number; } const DEFAULT_CENTER: [number, number] = [9.082, 8.6753]; // Nigeria centroid const INITIAL_ZOOM = 6; +const DEFAULT_SNAP_RADIUS_DEGREES = 0.25; -function pickCenter(drivers: Driver[]): [number, number] { - if (drivers.length === 0) return DEFAULT_CENTER; - const lat = - drivers.reduce((s, d) => s + d.location.lat, 0) / drivers.length; - const lng = - drivers.reduce((s, d) => s + d.location.lng, 0) / drivers.length; - return [lat, lng]; +function pickCenter( + drivers: Driver[], + waypoints?: OptimizerWaypoint[], +): [number, number] { + if (drivers.length > 0) { + const lat = + drivers.reduce((s, d) => s + d.location.lat, 0) / drivers.length; + const lng = + drivers.reduce((s, d) => s + d.location.lng, 0) / drivers.length; + return [lat, lng]; + } + if (waypoints && waypoints.length > 0) { + const lat = + waypoints.reduce((s, w) => s + w.coordinate.latitude, 0) / + waypoints.length; + const lng = + waypoints.reduce((s, w) => s + w.coordinate.longitude, 0) / + waypoints.length; + return [lat, lng]; + } + return DEFAULT_CENTER; } function radiusFor(count: number): number { @@ -36,6 +62,32 @@ function radiusFor(count: number): number { return 24; } +/** + * Finds the nearest valid waypoint drop zone index within `snapRadiusDegrees` + * of the given geographic coordinate. + */ +export function findNearestSnapIndex( + lat: number, + lng: number, + route: OptimizerWaypoint[], + snapRadiusDegrees: number = DEFAULT_SNAP_RADIUS_DEGREES, +): number | null { + let bestIndex: number | null = null; + let bestDistance = Infinity; + + route.forEach((wp, index) => { + const dLat = wp.coordinate.latitude - lat; + const dLng = wp.coordinate.longitude - lng; + const dist = Math.hypot(dLat, dLng); + if (dist <= snapRadiusDegrees && dist < bestDistance) { + bestDistance = dist; + bestIndex = index; + } + }); + + return bestIndex; +} + /** * Renders one marker per cluster and re-buckets whenever the map zoom changes. * @@ -43,7 +95,7 @@ function radiusFor(count: number): number { * context. Zooming out widens the clustering grid, so nearby drivers merge into * a single marker instead of overlapping pins. */ -function DriverClusterLayer({ drivers }: FleetMapClientProps) { +function DriverClusterLayer({ drivers }: { drivers: Driver[] }) { const [zoomFromEvent, setZoomFromEvent] = useState(null); const map = useMapEvents({ @@ -91,8 +143,260 @@ function DriverClusterLayer({ drivers }: FleetMapClientProps) { ); } -export default function FleetMapClient({ drivers }: FleetMapClientProps) { - const center = useMemo(() => pickCenter(drivers), [drivers]); +interface WaypointDragDropLayerProps { + waypoints: OptimizerWaypoint[]; + snapRadiusDegrees: number; + onWaypointReorder?: ( + _reorderedWaypoints: OptimizerWaypoint[], + _result: RouteOptimizationResult, + ) => void; +} + +/** + * Interactive waypoint drag-and-drop layer supporting: + * - Dragging markers to reorder their position in the route array + * - Dashed drop zone preview while dragging + * - Real-time connection lines between reordered waypoints + * - Smooth marker snapping to valid drop positions + */ +function WaypointDragDropLayer({ + waypoints, + snapRadiusDegrees, + onWaypointReorder, +}: WaypointDragDropLayerProps) { + const { optimizedRoute, reorder, result } = useRouteOptimization(waypoints); + + const [draggingIndex, setDraggingIndex] = useState(null); + const [targetDropIndex, setTargetDropIndex] = useState(null); + const [snappedWaypointId, setSnappedWaypointId] = useState( + null, + ); + + // Compute real-time preview order while a marker is being dragged over a valid drop zone + const previewRoute = useMemo(() => { + if ( + draggingIndex === null || + targetDropIndex === null || + draggingIndex === targetDropIndex || + draggingIndex < 0 || + draggingIndex >= optimizedRoute.length || + targetDropIndex < 0 || + targetDropIndex >= optimizedRoute.length + ) { + return optimizedRoute; + } + + const next = [...optimizedRoute]; + const [dragged] = next.splice(draggingIndex, 1); + next.splice(targetDropIndex, 0, dragged); + return next; + }, [optimizedRoute, draggingIndex, targetDropIndex]); + + const connectionCoordinates = useMemo( + () => + previewRoute.map( + (wp) => + `${wp.coordinate.latitude.toFixed(4)},${wp.coordinate.longitude.toFixed(4)}`, + ), + [previewRoute], + ); + + const handleDragStart = useCallback((index: number) => { + setDraggingIndex(index); + setTargetDropIndex(index); + setSnappedWaypointId(null); + }, []); + + const handleDragOverZone = useCallback( + (index: number, event?: React.DragEvent) => { + event?.preventDefault(); + if (draggingIndex === null) return; + if (index >= 0 && index < optimizedRoute.length) { + setTargetDropIndex(index); + } + }, + [draggingIndex, optimizedRoute.length], + ); + + const commitDrop = useCallback( + (dropIndex: number | null) => { + if ( + draggingIndex !== null && + dropIndex !== null && + dropIndex >= 0 && + dropIndex < optimizedRoute.length && + draggingIndex !== dropIndex + ) { + const movedWaypoint = optimizedRoute[draggingIndex]; + const nextOrder = [...optimizedRoute]; + const [removed] = nextOrder.splice(draggingIndex, 1); + nextOrder.splice(dropIndex, 0, removed); + + reorder(draggingIndex, dropIndex); + setSnappedWaypointId(movedWaypoint.id); + + if (onWaypointReorder) { + onWaypointReorder(nextOrder, { + ...result, + optimizedWaypoints: nextOrder, + polyline: nextOrder.map((wp) => [ + wp.coordinate.latitude, + wp.coordinate.longitude, + ]), + }); + } + } + + setDraggingIndex(null); + setTargetDropIndex(null); + }, + [draggingIndex, optimizedRoute, reorder, onWaypointReorder, result], + ); + + const handleDropOnCoordinate = useCallback( + (lat: number, lng: number) => { + const snappedIdx = findNearestSnapIndex( + lat, + lng, + optimizedRoute, + snapRadiusDegrees, + ); + commitDrop(snappedIdx ?? targetDropIndex); + }, + [optimizedRoute, snapRadiusDegrees, commitDrop, targetDropIndex], + ); + + const handleDragCancel = useCallback(() => { + setDraggingIndex(null); + setTargetDropIndex(null); + }, []); + + if (optimizedRoute.length === 0) { + return null; + } + + const isDragging = draggingIndex !== null; + + return ( +
+ {/* Real-time connection lines between waypoints */} + {previewRoute.length > 1 && ( +
w.id).join(',')} + aria-label="Route connection line" + className="pointer-events-none" + /> + )} + + {/* Dashed drop zone preview shown while dragging */} + {isDragging && ( +
+ + Drop preview: position {(targetDropIndex ?? draggingIndex) + 1} + +
+ )} + + {/* Drop zones and draggable waypoint markers */} +
+ {optimizedRoute.map((waypoint, index) => { + const isBeingDragged = draggingIndex === index; + const isValidDrop = isDragging && draggingIndex !== index; + const isHighlighted = + isDragging && + targetDropIndex === index && + draggingIndex !== index; + const isSnapped = snappedWaypointId === waypoint.id; + + return ( +
handleDragOverZone(index, e)} + onDragEnter={() => handleDragOverZone(index)} + onDrop={(e) => { + e.preventDefault(); + commitDrop(index); + }} + className={`rounded-md p-1 transition-all duration-200 ease-out ${ + isDragging ? 'border-2 border-dashed' : 'border border-solid' + } ${ + isHighlighted + ? 'scale-105 border-emerald-500 bg-emerald-500/20 ring-2 ring-emerald-400' + : isValidDrop + ? 'border-sky-400 bg-sky-500/10' + : 'border-slate-200 bg-white' + }`} + > +
handleDragStart(index)} + onDragEnd={handleDragCancel} + onMouseUp={(e) => { + const latAttr = e.currentTarget.getAttribute('data-drop-lat'); + const lngAttr = e.currentTarget.getAttribute('data-drop-lng'); + if (latAttr !== null && lngAttr !== null) { + handleDropOnCoordinate(Number(latAttr), Number(lngAttr)); + } + }} + className={`flex cursor-grab items-center gap-1.5 rounded px-2 py-1 text-xs font-semibold transition-transform duration-200 ease-out active:cursor-grabbing ${ + isBeingDragged + ? 'scale-95 opacity-60' + : isSnapped + ? 'scale-100 bg-emerald-50 text-emerald-900' + : 'bg-slate-50 text-slate-800' + }`} + > + + {index + 1} + + {waypoint.label} +
+
+ ); + })} +
+
+ ); +} + +export default function FleetMapClient({ + drivers, + waypoints, + onWaypointReorder, + snapRadiusDegrees = DEFAULT_SNAP_RADIUS_DEGREES, +}: FleetMapClientProps) { + const center = useMemo( + () => pickCenter(drivers, waypoints), + [drivers, waypoints], + ); // Ensure Leaflet picks up the CSS-loaded marker icons in environments // (Next.js bundles) where the default detection misfires. Without this @@ -115,6 +419,13 @@ export default function FleetMapClient({ drivers }: FleetMapClientProps) { url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> + {waypoints && waypoints.length > 0 && ( + + )} ); } diff --git a/components/fleet/__tests__/FleetMapClient.test.tsx b/components/fleet/__tests__/FleetMapClient.test.tsx index fd0d12bf..986793da 100644 --- a/components/fleet/__tests__/FleetMapClient.test.tsx +++ b/components/fleet/__tests__/FleetMapClient.test.tsx @@ -342,3 +342,118 @@ describe('FleetMapClient', () => { }); }); }); + +describe('FleetMapClient waypoint drag-and-drop reordering (Issue #832)', () => { + const routeWaypoints = [ + { + id: 'wp-1', + coordinate: { latitude: 6.5244, longitude: 3.3792 }, + label: 'Apapa Port', + }, + { + id: 'wp-2', + coordinate: { latitude: 6.6018, longitude: 3.3515 }, + label: 'Ikeja Hub', + }, + { + id: 'wp-3', + coordinate: { latitude: 6.4281, longitude: 3.4219 }, + label: 'Victoria Island', + }, + ]; + + it('displays connection lines between waypoints and shows dashed drop zone preview while dragging', () => { + const { fireEvent } = require('@testing-library/react'); + render( + , + ); + + const connectionLine = screen.getByTestId('waypoint-connection-line'); + expect(connectionLine).toBeInTheDocument(); + expect(connectionLine).toHaveAttribute( + 'data-waypoint-order', + 'wp-1,wp-2,wp-3', + ); + + const waypointMarkers = screen.getAllByTestId('waypoint-marker'); + expect(waypointMarkers).toHaveLength(3); + + // Start dragging the first marker + fireEvent.dragStart(waypointMarkers[0]); + + // Dashed drop preview appears + const preview = screen.getByTestId('dashed-drop-preview'); + expect(preview).toBeInTheDocument(); + expect(preview).toHaveClass('border-dashed'); + + // Drop zones highlight valid positions (positions 1 and 2 are valid drop targets for marker 0) + const dropZones = screen.getAllByTestId('waypoint-drop-zone'); + expect(dropZones[0]).toHaveAttribute('data-valid-drop', 'false'); + expect(dropZones[1]).toHaveAttribute('data-valid-drop', 'true'); + expect(dropZones[2]).toHaveAttribute('data-valid-drop', 'true'); + }); + + it('highlights the hovered drop zone and updates connection lines in real-time during drag', () => { + const { fireEvent } = require('@testing-library/react'); + render( + , + ); + + const waypointMarkers = screen.getAllByTestId('waypoint-marker'); + const dropZones = screen.getAllByTestId('waypoint-drop-zone'); + + fireEvent.dragStart(waypointMarkers[0]); + fireEvent.dragOver(dropZones[2]); + + // Drop zone 2 is highlighted + expect(dropZones[2]).toHaveAttribute('data-highlighted', 'true'); + + // Connection line updates in real-time to reflect the preview order (wp-2, wp-3, wp-1) + const connectionLine = screen.getByTestId('waypoint-connection-line'); + expect(connectionLine).toHaveAttribute( + 'data-waypoint-order', + 'wp-2,wp-3,wp-1', + ); + expect(connectionLine).toHaveAttribute( + 'data-positions', + '6.6018,3.3515;6.4281,3.4219;6.5244,3.3792', + ); + }); + + it('snaps marker smoothly to valid drop position and updates route visualization after reorder', () => { + const { fireEvent } = require('@testing-library/react'); + const onWaypointReorder = jest.fn(); + + render( + , + ); + + const waypointMarkers = screen.getAllByTestId('waypoint-marker'); + const dropZones = screen.getAllByTestId('waypoint-drop-zone'); + + fireEvent.dragStart(waypointMarkers[0]); + fireEvent.dragOver(dropZones[2]); + fireEvent.drop(dropZones[2]); + + // Dashed preview disappears after drop + expect(screen.queryByTestId('dashed-drop-preview')).not.toBeInTheDocument(); + + // Callback received reordered array and route optimization result + expect(onWaypointReorder).toHaveBeenCalledTimes(1); + const [reordered] = onWaypointReorder.mock.calls[0]; + expect(reordered.map((w: { id: string }) => w.id)).toEqual([ + 'wp-2', + 'wp-3', + 'wp-1', + ]); + + // Updated markers and snapped state + const updatedMarkers = screen.getAllByTestId('waypoint-marker'); + expect(updatedMarkers[2]).toHaveAttribute('data-waypoint-id', 'wp-1'); + expect(updatedMarkers[2]).toHaveAttribute('data-snapped', 'true'); + }); +}); diff --git a/components/shipments/TrackingMap.tsx b/components/shipments/TrackingMap.tsx index 78e10b2c..b1379914 100644 --- a/components/shipments/TrackingMap.tsx +++ b/components/shipments/TrackingMap.tsx @@ -1,15 +1,22 @@ /** * TrackingMap Component - * Interactive map for visualizing delivery routes with markers and polylines + * Interactive map for visualizing delivery routes with markers, polylines, + * and estimated delivery time / date (ETA) calculations. */ 'use client'; +import React, { useMemo } from 'react'; import React, { useMemo, useEffect } from 'react'; import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { useTrackingRoute } from '@/hooks/useTrackingRoute'; +import { calculateDistanceKm } from '@/services/geofenceService'; +import { Coordinate, MapBounds, RouteData } from '@/types/tracking'; +import { AlertCircle, Loader2, Package } from 'lucide-react'; + +const DEFAULT_AVERAGE_SPEED_KMH = 40; import { MapBounds } from '@/types/tracking'; import { AlertCircle, Loader2, Package } from 'lucide-react'; @@ -21,6 +28,75 @@ interface TrackingMapProps { onMapError?: (_error: string) => void; } +/** + * Calculates remaining ETA in minutes for a delivery route based on physical + * progress between origin, currentLocation, and destination, or falls back to + * `routeData.estimatedTime` / distance-based calculation. + */ +export function calculateRemainingEtaMinutes( + routeData: RouteData, + currentLocation?: Coordinate | null, +): number | null { + if (routeData.status === 'delivered') { + return 0; + } + + const baseEta = + typeof routeData.estimatedTime === 'number' + ? routeData.estimatedTime + : typeof routeData.distance === 'number' + ? Math.round((routeData.distance / DEFAULT_AVERAGE_SPEED_KMH) * 60) + : null; + + if ( + currentLocation && + routeData.status === 'in_transit' && + routeData.origin && + routeData.destination + ) { + const totalDirectKm = calculateDistanceKm( + routeData.origin, + routeData.destination, + ); + const remainingDirectKm = calculateDistanceKm( + currentLocation, + routeData.destination, + ); + + if (totalDirectKm > 0 && baseEta !== null) { + const ratio = Math.min(1, Math.max(0, remainingDirectKm / totalDirectKm)); + return Math.max(1, Math.round(baseEta * ratio)); + } + + if (remainingDirectKm >= 0) { + return Math.max( + 0, + Math.round((remainingDirectKm / DEFAULT_AVERAGE_SPEED_KMH) * 60), + ); + } + } + + return baseEta; +} + +/** + * Computes the ISO 8601 estimated delivery timestamp from a route timestamp + * and ETA in minutes. + */ +export function calculateEstimatedDeliveryDate( + timestamp: string | undefined, + etaMinutes: number | null | undefined, +): string | null { + if (!timestamp || typeof etaMinutes !== 'number' || !Number.isFinite(etaMinutes)) { + return null; + } + const baseMs = Date.parse(timestamp); + if (Number.isNaN(baseMs)) { + return null; + } + return new Date(baseMs + etaMinutes * 60 * 1000).toISOString(); +} + /** * Custom hook to fit bounds on map */ @@ -59,6 +135,9 @@ export function TrackingMap({ const { origin, destination, waypoints } = routeData; const coords = [origin, destination, ...waypoints.map((w) => w.coordinate)]; + if (currentLocation && routeData.status === 'in_transit') { + coords.push(currentLocation); + } const lats = coords.map((c) => c.latitude); const lngs = coords.map((c) => c.longitude); @@ -69,7 +148,7 @@ export function TrackingMap({ east: Math.max(...lngs), west: Math.min(...lngs), }; - }, [routeData]); + }, [routeData, currentLocation]); // Create polyline coordinates const polylineCoordinates: [number, number][] = useMemo(() => { @@ -78,6 +157,17 @@ export function TrackingMap({ return routeData.waypoints.map((w) => [w.coordinate.latitude, w.coordinate.longitude]); }, [routeData]); + const dynamicEtaMinutes = useMemo(() => { + if (!routeData) return null; + return calculateRemainingEtaMinutes(routeData, currentLocation); + }, [routeData, currentLocation]); + + const estimatedDeliveryIso = useMemo(() => { + if (!routeData) return null; + const etaForDate = routeData.estimatedTime ?? dynamicEtaMinutes; + return calculateEstimatedDeliveryDate(routeData.timestamp, etaForDate); + }, [routeData, dynamicEtaMinutes]); + // Create custom marker icons const originMarkerIcon = useMemo( () => @@ -133,7 +223,10 @@ export function TrackingMap({ style={{ height }} >
- +

Loading map...

@@ -148,7 +241,10 @@ export function TrackingMap({ style={{ height }} >
- +

{error}

@@ -163,13 +259,18 @@ export function TrackingMap({ style={{ height }} >
- +

No delivery data available

); } + const displayedEstimatedTime = routeData.estimatedTime ?? dynamicEtaMinutes; + return (

Origin

{routeData.origin && ( -

+

{routeData.origin.latitude.toFixed(4)}, {routeData.origin.longitude.toFixed(4)}

)} @@ -214,7 +315,7 @@ export function TrackingMap({

Destination

{routeData.destination && ( -

+

{routeData.destination.latitude.toFixed(4)}, {routeData.destination.longitude.toFixed(4)}

@@ -232,7 +333,7 @@ export function TrackingMap({

Current Location

-

+

{currentLocation.latitude.toFixed(4)}, {currentLocation.longitude.toFixed(4)}

@@ -286,20 +387,42 @@ export function TrackingMap({ {routeData.status}

- {routeData.distance && ( + {routeData.distance !== undefined && (

Distance

-

+

{routeData.distance.toFixed(1)} km

)} - {routeData.estimatedTime && ( + {displayedEstimatedTime !== null && displayedEstimatedTime !== undefined && (

Est. Time

-

- {routeData.estimatedTime} min +

+ {displayedEstimatedTime} min

+ {dynamicEtaMinutes !== null && ( + + {dynamicEtaMinutes} min remaining + + )} + {estimatedDeliveryIso && ( + + {estimatedDeliveryIso} + + )}
)}
diff --git a/context/ToastProvider.tsx b/context/ToastProvider.tsx new file mode 100644 index 00000000..da11e070 --- /dev/null +++ b/context/ToastProvider.tsx @@ -0,0 +1,413 @@ +'use client'; + +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { AlertCircle, CheckCircle, Info, Loader, X } from 'lucide-react'; +import { + toastService, + type ToastConfig, + type ToastMessage, + type ToastVariant, +} from '@/lib/toast'; + +export interface QueuedToast { + id: string; + variant: ToastVariant; + message: string; + description?: string; + duration: number; + createdAt: number; + action?: { + label: string; + onClick: () => void; + }; +} + +export interface ToastContextValue { + visibleToasts: QueuedToast[]; + queuedToasts: QueuedToast[]; + maxVisible: number; + defaultDuration: number; + isLoading: boolean; + backendMessages: ToastMessage[]; + success: ( + _message: string, + _description?: string, + _duration?: number, + ) => string; + error: ( + _message: string, + _description?: string, + _duration?: number, + ) => string; + info: ( + _message: string, + _description?: string, + _duration?: number, + ) => string; + addToast: (_config: ToastConfig & { id?: string }) => string; + dismissToast: (_id: string) => void; + clearAll: () => void; + syncFromBackend: () => Promise; +} + +export interface ToastQueueProviderProps { + children: ReactNode; + maxVisible?: number; + defaultDuration?: number; + autoSyncBackend?: boolean; +} + +const DEFAULT_MAX_VISIBLE = 3; +const DEFAULT_TOAST_DURATION = 4000; + +const ToastContext = createContext(null); + +let toastSequence = 0; + +function createToastId(): string { + toastSequence += 1; + return `toast-${Date.now()}-${toastSequence}`; +} + +function getVariantStyles(variant: ToastVariant): { + container: string; + icon: React.ReactElement; +} { + switch (variant) { + case 'success': + return { + container: + 'border-emerald-200 bg-white text-slate-900 dark:border-emerald-800 dark:bg-slate-900 dark:text-slate-100', + icon: ( +