import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { MapContainer, TileLayer, Marker, Popup, Polyline, Tooltip, useMap, useMapEvents, ZoomControl } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
// Side-effect import: patches L.Polyline so pathOptions.offset (in screen px)
// renders the line perpendicular-shifted from its actual geometry. Used by
// Compare → Combined mode to render planned + actual as parallel rails when
// they share the same road geometry (otherwise they'd stack and read as one).
import '../../../utils/leafletPolylineOffset';
import dayjs from 'dayjs';
import { useInfiniteQuery, useQueries, useQuery, useMutation } from '@tanstack/react-query';
import axios from 'axios';
import {
MdMap,
MdDirectionsBike,
MdRestaurant,
MdPublic,
MdInventory2,
MdTrendingUp,
MdStraighten,
MdLocationOn,
MdMarkunreadMailbox,
MdMoveToInbox,
MdPlace,
MdTwoWheeler,
MdNotes,
MdSwapHoriz,
MdExpandMore,
MdInfoOutline,
MdBatteryFull,
MdSignalCellularAlt,
MdMyLocation,
MdSpeed,
MdExplore,
MdAccessTime,
MdGpsFixed,
MdPower,
MdSearch,
MdChevronLeft,
MdChevronRight,
MdLocalMall,
MdCheckCircle,
MdErrorOutline,
MdWarning,
MdClose,
MdFormatListBulleted,
MdTimer,
MdCalendarToday,
MdInsights,
MdRefresh
} from 'react-icons/md';
import { fetchDeliveries, fetchAppLocations, getRiderPeriodicLogs, fetchRidersLogs, fetchBatchEfficiency } from '../api/api';
import {
STATUS_STYLES,
getStatusStyle,
FINAL_STATUSES,
SKIPPED_STATUSES,
STEP_PALETTE,
stepColor
} from './dispatchShared';
import CompareDataPanel from './CompareDataPanel';
import './Dispatch.css';
import logger from '../../../utils/logger';
// Combined-mode rail colors. The per-step palette (STEP_PALETTE) is great for
// "which step is this" but useless for "is this line planned or actual" when
// both layers overlay. In Combined view only we drop the step palette on
// polylines and use fixed, high-contrast colors: indigo for the dispatched
// plan (matches the "Compare" button + the prior "Planned Route" label) and
// emerald for the actual GPS trail (signals "live / real" data). Per-step
// distinction in Combined view is carried by the numbered drop pins, which
// keep STEP_PALETTE so the timeline link to a specific delivery survives.
const COMBINED_PLANNED_COLOR = '#6366f1';
const COMBINED_ACTUAL_COLOR = '#10b981';
const toNum = (v) => {
const n = parseFloat(v);
return Number.isFinite(n) ? n : NaN;
};
// Long delivery addresses come in two shapes:
// 1. Comma-separated: "Room No:C-4, Second Floor, ..., Vetrilaikara St,
// Peelamedu" — we keep the last two segments (typically street + area).
// 2. Free-form / space-separated: "Vistara Homes 71 & 72 ... Uppilipalayam
// post Coimbatore - 641 015 Opposite ..." — Indian addresses often run
// everything into one comma-less string. There's no reliable way to
// pick the locality token, so we hard-cap to the last 6 words and trim
// to ~40 chars; the full address still lives in the row's title tooltip.
const extractArea = (addr) => {
if (!addr) return '';
const str = String(addr).trim();
if (!str) return '';
if (str.includes(',')) {
const parts = str.split(',').map((s) => s.trim()).filter(Boolean);
if (parts.length === 0) return str;
if (parts.length <= 2) return parts.join(', ');
return parts.slice(-2).join(', ');
}
const words = str.split(/\s+/).filter(Boolean);
const tail = words.length > 6 ? words.slice(-6).join(' ') : str;
return tail.length > 40 ? `${tail.slice(0, 40).trim()}…` : tail;
};
const hasValidDrop = (o) => Number.isFinite(toNum(o.droplat || o.deliverylat)) && Number.isFinite(toNum(o.droplon || o.deliverylong));
// Try multiple field-name variants — the live delivery API may return pickuplatitude/picklongitude
// or pickuplongitude instead of the shorter pickuplat/pickuplong used in the static data.
const pickupLat = (o) => o.pickuplat || o.pickuplatitude || o.pickup_lat;
const pickupLon = (o) => o.pickuplong || o.pickuplongitude || o.picklongitude || o.pickup_lon;
const hasValidPickup = (o) => Number.isFinite(toNum(pickupLat(o))) && Number.isFinite(toNum(pickupLon(o)));
// Named delivery batches — operator's mental model of the day's waves.
// Each entry covers a half-open range [startHour, endHour) measured in
// FRACTIONAL hours (e.g. 12.5 = 12:30). Half-hour boundaries are supported.
// Three named batches, bucketed by assigntime per spec:
// • Morning Batch: before 8 AM (00:00 → 08:00)
// • Afternoon Batch: 9 AM → 12:30 PM (09:00 → 12:30)
// • Evening Batch: 4 PM → 7 PM (16:00 → 19:00)
// Gaps (8–9 AM, 12:30 PM–4 PM, 7 PM+) intentionally fall outside every batch.
const BATCHES_DEFAULT_RAW = [
{ id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 },
{ id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
{ id: 'evening', name: 'Evening Batch', startHour: 16, endHour: 19 }
];
// v8: afternoon batch extended to 12:30 PM. Bumping from v7 wipes the
// cached layouts that still hold the old endHour: 12 value.
const SLOTS_STORAGE_KEY = 'dispatch.slots.v8';
// Every prior storage key. Wiped once on mount so stale layouts
// from earlier code versions can't reappear on the next page load.
const LEGACY_SLOTS_STORAGE_KEYS = [
'dispatch.slots.v1',
'dispatch.slots.v2',
'dispatch.slots.v3',
'dispatch.slots.v4',
'dispatch.slots.v5',
'dispatch.slots.v6',
'dispatch.slots.v7'
];
// Build a label like "Slot 1 · 8 AM" (or "Slot 2 · 12:30 PM") from a
// fractional startHour (24h, half-hour steps). Mirrors the human-readable
// form the defaults use, so user-edited slots still look consistent.
const formatSlotLabel = (idx, startHour) => {
return `Slot ${idx + 1} · ${formatHourLabel(startHour)}`;
};
// Render a fractional hour as a human-readable clock label. Whole hours
// render as "8 AM"; half-hour values render as "12:30 PM". Other fractions
// round to the nearest minute (covers any future extension to quarter-hours
// without losing precision in the label).
const formatHourLabel = (h) => {
const wholeHour = Math.floor(h);
const minutes = Math.round((h - wholeHour) * 60);
const hr = ((wholeHour + 11) % 12) + 1;
const ampm = wholeHour >= 12 && wholeHour < 24 ? 'PM' : 'AM';
if (minutes === 0) return `${hr} ${ampm}`;
const mm = String(minutes).padStart(2, '0');
return `${hr}:${mm} ${ampm}`;
};
const formatSlotRange = (startHour, endHour) => {
if (endHour >= 24) return `After ${formatHourLabel(startHour)}`;
return `${formatHourLabel(startHour)}–${formatHourLabel(endHour)}`;
};
// Derive the operator-facing label/range strings from BATCHES_DEFAULT_RAW.
// Doing it through the formatters (instead of hardcoding "Slot 2 · 12:30 PM"
// etc.) guarantees user-edited slots and default slots render the same way
// — no chance of drift between the two paths.
// Prefer the explicit `name` (e.g. "Morning Batch") when provided; fall back
// to the auto-generated "Slot N · 8 AM" label for any user-added slot that
// doesn't carry a name.
const BATCHES_DEFAULT = BATCHES_DEFAULT_RAW.map((s, i) => ({
...s,
label: s.name || formatSlotLabel(i, s.startHour),
range: formatSlotRange(s.startHour, s.endHour)
}));
const getBatchForHour = (h, batches) => {
for (const b of batches) {
if (h >= b.startHour && h < b.endHour) return b.id;
}
return null;
};
// Time fields the operator can pick from to drive slot bucketing. Each
// option maps to a column on the delivery row; the chosen one becomes the
// timestamp `getRowBatch` reads. "Delivery" defaults to actual deliverytime
// with a fallback to expecteddeliverytime so undelivered orders still bucket.
const TIME_FIELDS = [
{ id: 'delivered', label: 'Delivered', keys: ['deliverytime'] },
{ id: 'pending', label: 'Pending', keys: ['expecteddeliverytime'] },
{ id: 'assigned', label: 'Assigned', keys: ['assigntime'] },
{ id: 'accepted', label: 'Accepted', keys: ['acceptedtime'] },
{ id: 'started', label: 'Started', keys: ['starttime'] },
{ id: 'arrived', label: 'Arrived', keys: ['arrivaltime'] },
{ id: 'pickup', label: 'Pickup', keys: ['pickuptime'] },
{ id: 'all', label: 'All', keys: ['deliverytime', 'expecteddeliverytime', 'assigntime', 'acceptedtime', 'arrivaltime', 'pickuptime', 'starttime'] }
];
const getTimeFieldValue = (r, fieldId) => {
const field = TIME_FIELDS.find((f) => f.id === fieldId) || TIME_FIELDS[0];
for (const k of field.keys) {
if (r?.[k]) return r[k];
}
return null;
};
const getRowBatch = (r, fieldId = 'all', batches = BATCHES_DEFAULT) => {
const t = getTimeFieldValue(r, fieldId);
if (!t) return null;
const str = String(t).trim();
// Skip bare date strings — no time component, would always parse to midnight.
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
const d = dayjs(t);
if (!d.isValid()) return null;
// Pass FRACTIONAL hour so a delivery at 12:45 falls into slot 2 (which
// starts at 12:30 = 12.5) rather than slot 1 — d.hour() alone would
// truncate to 12 and mis-bucket the back half of every hour.
return getBatchForHour(d.hour() + d.minute() / 60, batches);
};
// Sits inside the Compare MapContainer and unpins any pinned popup whenever
// the operator clicks empty map space. Markers' click events do NOT bubble
// to the map, so this only fires on background clicks (which is what we
// want — clicking elsewhere should release the pin).
function CompareMapClickUnpin({ onUnpin }) {
useMapEvents({ click: () => onUnpin() });
return null;
}
// Captures the Leaflet map instance for the parent component via a ref. Kept
// available even after the two-map Compare layout was unified into one map,
// since future per-step imperative zoom logic still needs a handle on the
// planned map instance.
function CaptureMap({ targetRef }) {
const map = useMap();
useEffect(() => {
targetRef.current = map;
return () => { targetRef.current = null; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [map]);
return null;
}
// Haversine distance between two [lat, lng] points in kilometers. Good to
// ~0.1% across city scales; we use it to sum the length of an OSRM-snapped
// polyline so the Compare delta panel can show "actual km" without depending
// on the backend's actualkms field (which can be stale or missing).
function haversineKm(a, b) {
const R = 6371; // km
const toRad = (d) => (d * Math.PI) / 180;
const lat1 = toRad(a[0]);
const lat2 = toRad(b[0]);
const dLat = toRad(b[0] - a[0]);
const dLon = toRad(b[1] - a[1]);
const s = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
}
function polylineLengthKm(points) {
if (!Array.isArray(points) || points.length < 2) return 0;
let total = 0;
for (let i = 1; i < points.length; i++) {
total += haversineKm(points[i - 1], points[i]);
}
return total;
}
// ─── Kalman filter for GPS pings ─────────────────────────────────────────
//
// Two independent 1D Kalman filters (one for lat, one for lng) applied to a
// chronologically sorted list of GPS pings. Per-axis state: [position,
// velocity]. Constant-velocity dynamics with random acceleration as process
// noise; measurement model H = [1, 0] (we measure position only).
//
// Why a Kalman filter here:
// Raw /getdeliverylogs pings contain jitter (multi-path in dense urban
// areas), brief stationary noise (rider parked at the drop, GPS still
// wandering), and occasional outliers (cold-start fix). The Kalman pass
// fuses each ping with the predicted trajectory from prior pings, weighted
// by their relative uncertainty. The output is a smooth polyline that
// tracks the rider's real path without the zig-zags and bunched-up dots
// near drops, and it costs O(N) — runs once per delivery on fetch.
//
// Tuning:
// processNoise (q) — random-acceleration variance (deg²/s²). Lower = a
// smoother result but slower to follow sharp turns.
// measurementNoise (r) — GPS-fix variance (deg²). Higher = trust pings
// less, lean on the predicted state more.
// The defaults below correspond loosely to ~5m GPS accuracy and gentle
// urban acceleration. Bump q if smoothing eats genuine turns; bump r if
// the line still wiggles between pings.
function kalmanSmoothGps(pings, options = {}) {
if (!Array.isArray(pings) || pings.length === 0) return [];
if (pings.length === 1) {
return [{ lat: pings[0].lat, lng: pings[0].lng, logdate: pings[0].logdate }];
}
const processNoise =
options.processNoise != null ? options.processNoise : 1e-9;
const measurementNoise =
options.measurementNoise != null ? options.measurementNoise : 1e-7;
const tsOf = (p) =>
p._ts || (p.logdate ? new Date(p.logdate).getTime() : 0);
// Run a 1D Kalman over one axis (lat or lng). State: [pos, vel]; cov: 2x2.
const smoothAxis = (axisKey) => {
let x = pings[0][axisKey]; // position estimate
let v = 0; // velocity estimate
// Initial covariance — large enough that the first few measurements
// dominate over the initial state.
let p00 = 1, p01 = 0, p10 = 0, p11 = 1;
const out = [x];
let prevTs = tsOf(pings[0]);
for (let i = 1; i < pings.length; i++) {
const ts = tsOf(pings[i]) || prevTs + 1000;
const dt = Math.max(0.1, (ts - prevTs) / 1000);
prevTs = ts;
// ─── Predict ───
// x' = F x where F = [[1, dt], [0, 1]]
const xPred = x + v * dt;
const vPred = v;
// P' = F P F^T + Q where Q = q · [[dt⁴/4, dt³/2], [dt³/2, dt²]]
const dt2 = dt * dt;
const dt3 = dt2 * dt;
const dt4 = dt3 * dt;
const np00 = p00 + dt * (p01 + p10) + dt2 * p11 + (dt4 / 4) * processNoise;
const np01 = p01 + dt * p11 + (dt3 / 2) * processNoise;
const np10 = p10 + dt * p11 + (dt3 / 2) * processNoise;
const np11 = p11 + dt2 * processNoise;
// ─── Update ───
// y = z − Hx' (innovation, measurement vs prediction)
const z = pings[i][axisKey];
const y = z - xPred;
// S = H P' H^T + R (innovation covariance)
const S = np00 + measurementNoise;
// K = P' H^T / S (Kalman gain)
const K0 = np00 / S;
const K1 = np10 / S;
// x = x' + K y
x = xPred + K0 * y;
v = vPred + K1 * y;
// P = (I − K H) P'
p00 = (1 - K0) * np00;
p01 = (1 - K0) * np01;
p10 = np10 - K1 * np00;
p11 = np11 - K1 * np01;
out.push(x);
}
return out;
};
const lats = smoothAxis('lat');
const lngs = smoothAxis('lng');
return pings.map((p, i) => ({
lat: lats[i],
lng: lngs[i],
logdate: p.logdate,
_ts: p._ts
}));
}
// Splits a routed OSRM polyline into per-step segments by finding the
// polyline index closest to each drop waypoint. Returns an array of
// segments where segments[i] runs from the previous waypoint (or polyline
// start when i===0) to drops[i]. Adjacent segments share their endpoint
// so the rendered polylines visually touch with no gap.
//
// Used by renderRoutes in Compare mode to recolor the planned-route
// polyline per step, so the left map's step colors match the right map's
// per-step palette + the timeline strip. Without per-segment splitting
// the planned polyline would be a single rider-colored line that doesn't
// visually link to step N's actual GPS polyline.
function splitPolylineByDrops(polyline, drops) {
if (!Array.isArray(polyline) || polyline.length < 2 || !drops || !drops.length) {
return [];
}
const findClosestIndex = (target) => {
let best = 0;
let bestD = Infinity;
for (let i = 0; i < polyline.length; i++) {
const dy = polyline[i][0] - target[0];
const dx = polyline[i][1] - target[1];
const d = dy * dy + dx * dx;
if (d < bestD) { bestD = d; best = i; }
}
return best;
};
const cutIndices = drops.map(findClosestIndex);
// Force monotonically non-decreasing — in pathological route shapes the
// raw "closest" indices can briefly regress (OSRM doubles back near a
// waypoint), which would produce empty/negative slices below.
for (let i = 1; i < cutIndices.length; i++) {
if (cutIndices[i] < cutIndices[i - 1]) cutIndices[i] = cutIndices[i - 1];
}
const segments = [];
let prev = 0;
cutIndices.forEach((idx) => {
const end = Math.max(idx, prev);
segments.push(polyline.slice(prev, end + 1));
prev = end;
});
return segments;
}
// --- Marker popup helpers ---
// Strip the date portion from an API timestamp — operators viewing today's
// orders only care about the wall-clock time. Falls back to the raw string
// if dayjs can't parse the value.
const formatTimeOnly = (t) => {
if (!t) return null;
const d = dayjs(t);
if (!d.isValid()) return String(t);
return d.format('HH:mm:ss');
};
// Stages the popup walks through, top → bottom, in real-world delivery order.
// Each row only renders when the corresponding API field is populated, so the
// timeline visually shrinks for orders that haven't reached later stages yet.
const POPUP_TIMELINE = [
{ key: 'assigntime', label: 'Assigned' },
{ key: 'acceptedtime', label: 'Accepted' },
{ key: 'arrivaltime', label: 'Arrived' },
{ key: 'pickuptime', label: 'Pickup' },
{ key: 'starttime', label: 'Started' },
{ key: 'deliverytime', label: 'Delivered', final: true }
];
// Build a polyline-ready point list for a sorted trip:
// - drop NaN drops
// - prepend the first valid pickup we can find (so the line starts at the kitchen)
const buildTripPoints = (sorted) => {
const valid = sorted.filter(hasValidDrop);
if (!valid.length) return [];
const pickupSrc = sorted.find(hasValidPickup);
const pts = [];
if (pickupSrc) pts.push([toNum(pickupLat(pickupSrc)), toNum(pickupLon(pickupSrc))]);
valid.forEach((o) => pts.push([toNum(o.droplat || o.deliverylat), toNum(o.droplon || o.deliverylong)]));
return pts;
};
// Fix for default leaflet marker icons
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});
const RIDER_COLORS = ['#0055FF', '#00D82C', '#FF6B00', '#9D00FF', '#FF00A8', '#00C2B2', '#FF9900', '#FF0000'];
// Deterministic rider-id → palette slot mapping. The main `riders` array
// assigns colors by iteration order (see useMemo around line 1308), which
// means the same rider can shuffle to a different color across live-data
// refetches. The Rider Info list needs a fixed color per rider so the dot
// next to "Rajan A" doesn't flip from blue to green on the next poll, so
// it uses this hash-based lookup instead of `getRiderColor(id)`.
const getStableRiderColor = (id) => {
const s = String(id ?? '');
if (!s) return RIDER_COLORS[0];
let h = 0;
for (let i = 0; i < s.length; i++) {
h = (h * 31 + s.charCodeAt(i)) >>> 0;
}
return RIDER_COLORS[h % RIDER_COLORS.length];
};
// STATUS_STYLES, getStatusStyle, FINAL_STATUSES, SKIPPED_STATUSES,
// STEP_PALETTE, stepColor — moved to ./dispatchShared.js so the
// extracted CompareDataPanel component can import them without forcing
// a circular dependency on Dispatch.js.
const MapController = ({ focusedItem, viewMode, orders, kitchens, locationKey }) => {
const map = useMap();
// Last fit signature. We only call fitBounds when this changes — otherwise
// every parent render (data refetch, sidebar tick, etc.) would refit the
// map and snap it back mid-drag, which felt like the map was un-draggable.
const lastFitKeyRef = useRef('');
// Stable identifier for the current focus target. Uses ids where possible
// so it doesn't churn when the underlying objects get rebuilt by useMemo.
// `locationKey` is included everywhere so switching hubs (Coimbatore →
// Nagercoil etc.) always triggers a refit onto the new region, even when
// the new hub happens to have the same kitchen/order count.
//
// We also append a coarse centroid (lat/lon rounded to 1 decimal) of the
// current dataset. This is what fixes the Nagercoil case: when only the
// hub id changes but the kitchen/order array is briefly the previous
// hub's stale data (or empty during refetch), the fit only happens once
// truly-new data arrives — at which point the centroid signature flips
// (e.g. 11.0,77.0 → 8.2,77.4) and we refit to the new region.
const fitKey = useMemo(() => {
const loc = locationKey != null ? `loc:${locationKey}|` : '';
const centroidSig = (pairs) => {
let lat = 0;
let lon = 0;
let n = 0;
for (const p of pairs) {
if (Number.isFinite(p[0]) && Number.isFinite(p[1])) {
lat += p[0];
lon += p[1];
n += 1;
}
}
return n === 0 ? '0' : `${(lat / n).toFixed(1)},${(lon / n).toFixed(1)}`;
};
if (focusedItem) {
const id =
focusedItem.id ??
focusedItem.kitchenName ??
focusedItem.name ??
(focusedItem.lat != null ? `${focusedItem.lat},${focusedItem.lon}` : 'item');
const n = focusedItem.orders ? focusedItem.orders.length : 0;
return `${loc}f|${id}|${n}`;
}
const kPairs = (kitchens || []).map((k) => [k.lat, k.lon]);
const kSig = centroidSig(kPairs);
if (viewMode === 'kitchens') {
const n = kPairs.filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1])).length;
return `${loc}k|${n}|${kSig}`;
}
if (viewMode === 'all') {
const oPairs = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
return `${loc}a|${oPairs.length}|${centroidSig(oPairs)}`;
}
return `${loc}m|${viewMode || ''}|${kPairs.length}|${kSig}`;
}, [focusedItem, viewMode, orders, kitchens, locationKey]);
useEffect(() => {
if (lastFitKeyRef.current === fitKey) return;
let pts = [];
if (focusedItem) {
if (focusedItem.orders) {
pts = focusedItem.orders.map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
focusedItem.orders.forEach((o) => pts.push([toNum(pickupLat(o)), toNum(pickupLon(o))]));
} else {
pts = [[focusedItem.lat, focusedItem.lon]];
}
} else if (viewMode === 'kitchens') {
pts = (kitchens || [])
.filter((k) => Number.isFinite(k.lat) && Number.isFinite(k.lon))
.map((k) => [k.lat, k.lon]);
if (pts.length === 0) {
pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
}
} else if (viewMode === 'all') {
pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
} else {
// No focus, viewMode is 'riders' / 'zones' / etc. — still fit to the
// current hub's footprint so switching from Coimbatore → Nagercoil
// (or any hub change) recenters the map onto the new region instead
// of leaving it on the previous city.
pts = (kitchens || [])
.filter((k) => Number.isFinite(k.lat) && Number.isFinite(k.lon))
.map((k) => [k.lat, k.lon]);
if (pts.length === 0) {
pts = (orders || []).map((o) => [parseFloat(o.droplat || o.deliverylat), parseFloat(o.droplon || o.deliverylong)]);
}
}
const filtered = pts.filter((p) => Number.isFinite(p[0]) && Number.isFinite(p[1]));
if (filtered.length > 0) {
const bounds = L.latLngBounds(filtered);
if (bounds.isValid()) {
// Zoom into a single-point bounds (focused rider with one drop, or
// a single kitchen) instead of using maxZoom defaults which would
// leave the map zoomed out.
const isSinglePoint = filtered.length === 1 || bounds.getNorthEast().equals(bounds.getSouthWest());
if (isSinglePoint) {
map.setView(filtered[0], 15, { animate: true, duration: 0.6 });
} else {
map.flyToBounds(bounds, { padding: [60, 60], duration: 0.6, maxZoom: 16 });
}
lastFitKeyRef.current = fitKey;
}
return;
}
// No data to fit to yet — hub switch is mid-flight, or this hub has no
// orders today. Do NOT lock the fit key: when real hub data arrives the
// fitKey will change (its centroid signature flips, e.g. 11.0,77.0 →
// 8.2,77.4) and this effect will re-run with proper coordinates.
//
// Also do NOT call setView([11.022, 76.982], 12) here — that was the
// bug that left Nagercoil (and every non-Coimbatore hub) stuck on the
// Coimbatore default during the brief window between picking the hub
// and its data arriving.
}, [fitKey, focusedItem, viewMode, orders, kitchens, map]);
return null;
};
// Inline-icon wrapper used wherever a Material icon precedes some text — keeps the
// SVG vertically centered with the adjacent text and inherits the parent color.
const Ico = ({ children }) => (
{children}
);
// Batch windows for the standalone Dispatch Analysis view. Maps the UI label
// to the X-Batch-Window header value the backend expects.
const ANALYSIS_BATCH_WINDOWS = [
{ key: 'morning', label: 'Morning', timeRange: '12:00 AM – 8:00 AM', sub: 'Early shift orders', color: '#f59e0b', bg: '#fffbeb', border: '#fde68a' },
{ key: 'afternoon', label: 'Noon', timeRange: '9:00 AM – 12:30 PM', sub: 'Lunch rush window', color: '#10b981', bg: '#ecfdf5', border: '#a7f3d0' },
{ key: 'evening', label: 'Evening', timeRange: '4:00 PM – 7:00 PM', sub: 'Dinner & end-of-day', color: '#6366f1', bg: '#eef2ff', border: '#c7d2fe' }
];
// Tolerant field-name lookup so the Analysis card still renders cleanly even
// if the API response uses slightly different keys than expected.
const analysisPick = (obj, keys) => {
for (const k of keys) {
if (obj && obj[k] != null && obj[k] !== '') return obj[k];
}
return null;
};
const analysisFormatNum = (v) => {
if (v == null) return '—';
if (typeof v === 'number') return v.toLocaleString('en-IN');
const n = parseFloat(v);
if (Number.isFinite(n)) return n.toLocaleString('en-IN');
return String(v);
};
const analysisFormatKm = (v) => (v == null ? '—' : `${parseFloat(v).toFixed(1)} km`);
const analysisFormatRupees = (v) => (v == null ? '—' : `₹${parseFloat(v).toFixed(0)}`);
const analysisFormatPct = (v) => {
if (v == null) return '—';
const n = parseFloat(v);
if (!Number.isFinite(n)) return '—';
return `${n > 1 ? n.toFixed(1) : (n * 100).toFixed(1)}%`;
};
const Dispatch = ({
data,
embedded = false,
// Controlled focus: when selectedRiderId is defined, the focused rider is derived from prop
// and clicks inside Dispatch only fire onRiderSelect (parent owns the state). When undefined,
// Dispatch falls back to its internal focusedRider state (standalone /dispatch behavior).
selectedRiderId,
onRiderSelect,
// Highlight a single marker (e.g. on table-row hover). Adds a `.pulse` class to that cmark.
pulseOrderId,
// Optional. When provided, focused-rider order cards render a small "change rider"
// icon in their header. Receiving callsite owns the rider-picker dialog. Standalone
// /dispatch usage leaves this undefined so the icon never appears there.
onChangeRider
}) => {
// Default to "By Zone" when the caller passes pre-zoned data (AI preview); fall back to
// "By Rider" for the standalone live page where zones are synthesized but riders are primary.
const initialViewMode = data?.zones && data.zones.length > 0 ? 'zones' : 'riders';
const [viewMode, setViewMode] = useState(initialViewMode);
// Top-level page mode for the standalone Dispatch screen: 'live' (existing
// map + sidebar UI) or 'analysis' (batch efficiency panel). Embedded use
// (Preview) never reaches the Analysis switcher, so default doesn't matter
// there.
const [topView, setTopView] = useState('live');
const [analysisResults, setAnalysisResults] = useState({});
const [analysisLoadingWindow, setAnalysisLoadingWindow] = useState(null);
const [activeBatchKey, setActiveBatchKey] = useState(null);
// TODO: wire to real tenant context once the standalone Dispatch screen
// surfaces it. 916 matches the example tenant in the API spec.
const ANALYSIS_TENANT_ID = 916;
const batchEfficiencyMutation = useMutation({
mutationFn: fetchBatchEfficiency,
onMutate: (vars) => setAnalysisLoadingWindow(vars.batch),
onSuccess: (resp, vars) => {
setAnalysisResults((prev) => ({
...prev,
[vars.batch]: { data: resp, fetchedAt: dayjs().format('HH:mm:ss') }
}));
},
onSettled: () => setAnalysisLoadingWindow(null)
});
const handleFetchAnalysisBatch = (windowKey) => {
setActiveBatchKey(windowKey);
// Re-use cached non-error response if already loaded
const cached = analysisResults[windowKey];
if (cached && cached.data && cached.data.success !== false) return;
batchEfficiencyMutation.mutate({
batch: windowKey,
tenantId: ANALYSIS_TENANT_ID
});
};
const [activeRiders, setActiveRiders] = useState(new Set());
const [internalFocusedRider, setInternalFocusedRider] = useState(null);
const [focusedKitchen, setFocusedKitchen] = useState(null);
const [focusedZone, setFocusedZone] = useState(null);
// Single delivery stop pinned by clicking its sidebar row — overrides the rider's full-route bounds on the map.
const [focusedStop, setFocusedStop] = useState(null);
// Holds leaflet marker instances keyed by orderid so we can imperatively open
// their popups when the user clicks a step in the focused-rider sidebar.
const orderMarkerRefs = useRef({});
// Orderids whose popup was explicitly pinned open by a click. mouseout skips
// the close for these so the popup stays visible after the cursor leaves the
// marker; clicking the marker again unpins it. Stored in a ref because this
// only drives imperative leaflet calls — no re-render needed.
const pinnedPopupsRef = useRef(new Set());
// Rider ids whose LIVE GPS marker popup was pinned open by a click. Same
// semantics as pinnedPopupsRef but tracked separately because live-GPS
// popups use leaflet's marker-attached (openPopup/closePopup) rather
// than the centered overlay used for order popups.
const pinnedLivePopupsRef = useRef(new Set());
// Short-lived close timer for the general map order/marker popups.
// Gives the cursor a ~200ms window to travel from the marker onto the popup
// or vice versa without immediately triggering a close.
const activePopupMarkerRef = useRef(null);
const popupHoverTimerRef = useRef(null);
// Order shown in the centered popup overlay. Rendered outside the leaflet
// map (see `dispatch-popup-center` overlay near the bottom of the JSX) so
// `position: fixed` actually centers on the viewport. Drives behavior that
// used to flow through leaflet's marker-attached : hover opens it,
// mouseout closes it after a ~200ms grace window (unless pinned), click
// toggles a pinned state stored in pinnedPopupsRef.
const [centerPopupOrder, setCenterPopupOrder] = useState(null);
const isControlled = selectedRiderId !== undefined;
const [clock, setClock] = useState('');
// Fetch all hubs/locations the logged-in user has access to. The list is
// rendered as a dropdown next to the Dispatch title so the operator can
// switch the active hub without going back to login.
const { data: appLocations } = useQuery({
queryKey: ['appLocations'],
queryFn: fetchAppLocations,
staleTime: 5 * 60 * 1000
});
// The user's last-picked location is mirrored into localStorage so it
// survives reloads and stays in sync with other pages that read it.
const initialAppLocationId = typeof window !== 'undefined' ? localStorage.getItem('applocationid') : null;
const [selectedAppLocationId, setSelectedAppLocationId] = useState(
initialAppLocationId != null ? Number(initialAppLocationId) : 0
);
const [locationMenuOpen, setLocationMenuOpen] = useState(false);
const locationMenuRef = useRef(null);
// Which timestamp column drives slot bucketing. Default = assigntime so
// orders bucket into Morning/Afternoon/Evening by when they were assigned,
// per current spec. The status-wise time-field dropdown is hidden for now
// (see commented-out block in JSX), so this stays fixed at 'assigned'.
const [selectedTimeField, setSelectedTimeField] = useState('assigned');
const [timeFieldMenuOpen, setTimeFieldMenuOpen] = useState(false);
const timeFieldMenuRef = useRef(null);
// Operator-editable slot configuration. Seeded from localStorage so edits
// survive reloads; falls back to BATCHES_DEFAULT otherwise. Each entry has
// id/label/range/startHour/endHour just like the default list — the rest
// of the file reads BATCHES (derived below) without caring whether the
// values came from defaults or from operator edits.
const [slotsConfig, setSlotsConfig] = useState(() => {
if (typeof window === 'undefined') return BATCHES_DEFAULT;
try {
const raw = window.localStorage.getItem(SLOTS_STORAGE_KEY);
if (!raw) return BATCHES_DEFAULT;
const parsed = JSON.parse(raw);
// If the parsed slots length does not match BATCHES_DEFAULT_RAW, the data
// is stale (e.g. written from the older 5-slot layout). Discard it and
// load the default 3-batch layout.
if (!Array.isArray(parsed) || parsed.length !== BATCHES_DEFAULT_RAW.length) return BATCHES_DEFAULT;
// Re-derive label + range from the saved hours so any UI tweaks to the
// formatter (e.g. AM/PM style) flow through to old persisted slots. If
// the saved id matches a default batch, prefer that batch's friendly
// name ("Morning Batch", etc.) over the generated "Slot N · time" label.
return parsed.map((s, i) => {
const id = s.id || `slot-${i + 1}`;
const startHour = Number(s.startHour) || 0;
const endHour = Number(s.endHour) || 24;
const defaultMatch = BATCHES_DEFAULT.find((b) => b.id === id);
return {
id,
startHour,
endHour,
label: defaultMatch?.name || formatSlotLabel(i, startHour),
range: formatSlotRange(startHour, endHour)
};
});
} catch (e) {
return BATCHES_DEFAULT;
}
});
const BATCHES = slotsConfig;
const [slotEditOpen, setSlotEditOpen] = useState(false);
const slotEditRef = useRef(null);
// One-shot housekeeping: drop every prior slot-storage key so a stale
// layout written under an older schema version (e.g. v1/v2/v3 with five
// slots) can never resurface. Runs once per mount because deps are [].
useEffect(() => {
if (typeof window === 'undefined') return;
try {
LEGACY_SLOTS_STORAGE_KEYS.forEach((k) => window.localStorage.removeItem(k));
} catch (e) { /* private-mode / quota — ignore */ }
}, []);
// Persist edits whenever slotsConfig changes (skip the first render — the
// initializer already loaded from storage).
const slotsInitMountedRef = useRef(false);
useEffect(() => {
if (!slotsInitMountedRef.current) { slotsInitMountedRef.current = true; return; }
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(SLOTS_STORAGE_KEY, JSON.stringify(
slotsConfig.map(({ id, startHour, endHour }) => ({ id, startHour, endHour }))
));
} catch (e) { /* quota / private-mode — ignore */ }
}, [slotsConfig]);
// Close the location dropdown on any click outside its wrapper.
useEffect(() => {
if (!locationMenuOpen) return;
const onDocClick = (e) => {
if (locationMenuRef.current && !locationMenuRef.current.contains(e.target)) {
setLocationMenuOpen(false);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [locationMenuOpen]);
// Same click-outside behavior for the slot-time-field dropdown.
useEffect(() => {
if (!timeFieldMenuOpen) return;
const onDocClick = (e) => {
if (timeFieldMenuRef.current && !timeFieldMenuRef.current.contains(e.target)) {
setTimeFieldMenuOpen(false);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [timeFieldMenuOpen]);
// Click-outside dismissal for the slot-edit popover.
useEffect(() => {
if (!slotEditOpen) return;
const onDocClick = (e) => {
if (slotEditRef.current && !slotEditRef.current.contains(e.target)) {
setSlotEditOpen(false);
}
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, [slotEditOpen]);
// Rider Info view — operator picks a rider in the sidebar, the main panel
// shows that rider's getriderperiodiclogs snapshot. Lives behind a viewMode
// ('rider-info') so it follows the same toggle pattern as the other modes.
// Importantly, the rider list here is built from the FULL day's rows
// (`liveRows`) — NOT the slot-filtered `filteredLiveRows`. Operators using
// Rider Info want to look up any rider regardless of which slot they were
// active in.
const [riderInfoUserid, setRiderInfoUserid] = useState(null);
const [riderInfoSearch, setRiderInfoSearch] = useState('');
const { data: riderInfoData, isFetching: riderInfoFetching, isError: riderInfoIsError, error: riderInfoError } = useQuery({
queryKey: ['riderPeriodicLog', riderInfoUserid],
queryFn: () => getRiderPeriodicLogs(riderInfoUserid),
enabled: viewMode === 'rider-info' && riderInfoUserid != null,
// Auto-refresh the rider snapshot every 15s while the view is open and a
// rider is selected. Don't poll while the tab is hidden so we don't burn
// requests on background tabs.
refetchInterval: viewMode === 'rider-info' && riderInfoUserid != null ? 15_000 : false,
refetchIntervalInBackground: false,
staleTime: 5 * 1000,
refetchOnWindowFocus: false
});
// Reverse-geocode the selected rider's GPS so we can show a small banner
// above the map pin telling the operator which suburb/area the rider is in.
// Nominatim is rate-limited (1 req/sec public), so we round coords to 4
// decimals (~11 m) — that turns the query key into a stable cache slot and
// stops jittery GPS fixes from re-firing the request every poll cycle.
const riderInfoCoordsKey = useMemo(() => {
const lat = parseFloat(riderInfoData?.latitude);
const lon = parseFloat(riderInfoData?.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return { lat: lat.toFixed(4), lon: lon.toFixed(4) };
}, [riderInfoData?.latitude, riderInfoData?.longitude]);
const { data: riderInfoArea } = useQuery({
queryKey: ['reverseGeocode', riderInfoCoordsKey?.lat, riderInfoCoordsKey?.lon],
queryFn: async () => {
const res = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${riderInfoCoordsKey.lat}&lon=${riderInfoCoordsKey.lon}&format=json&zoom=16&addressdetails=1`,
{ headers: { Accept: 'application/json' } }
);
if (!res.ok) return null;
const j = await res.json();
const a = j?.address || {};
// Prefer the most specific locality name available; the bigger admin
// levels (city/county/state) are kept only as last-resort fallbacks.
const area =
a.suburb || a.neighbourhood || a.village || a.hamlet ||
a.city_district || a.town || a.city || a.county || a.state || '';
return { area, display: j?.display_name || '' };
},
enabled: viewMode === 'rider-info' && !!riderInfoCoordsKey,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1
});
const locationName = useMemo(() => {
if (!appLocations) return null;
const match = appLocations.find((l) => String(l.applocationid) === String(selectedAppLocationId));
return match?.locationname || null;
}, [appLocations, selectedAppLocationId]);
const handleLocationPick = (id) => {
logger.info('Switching hub/location ID:', id);
setSelectedAppLocationId(Number(id));
setLocationMenuOpen(false);
if (typeof window !== 'undefined') {
try { localStorage.setItem('applocationid', String(id)); } catch (e) { /* ignore quota errors */ }
}
// Switching hubs invalidates the current focused rider/kitchen/zone since
// they belong to the previous hub's data. handleRiderFocus is declared
// further down the component but the closure is only called after click,
// so the reference resolves cleanly.
handleRiderFocus(null);
setFocusedKitchen(null);
setFocusedZone(null);
};
const [osrmRoutes, setOsrmRoutes] = useState({});
// Mirror of osrmRoutes held in a ref so fetchRoute can check the cache without
// being listed in useCallback deps (which caused a render-loop: fetch → state
// update → new fetchRoute → effect re-runs → repeat).
const osrmRoutesRef = useRef({});
// Road-snapped polylines for each delivery's actual GPS trace (Compare map).
// Keyed by deliveryid. Same null/false/array semantics as osrmRoutes.
const [osrmTrackRoutes, setOsrmTrackRoutes] = useState({});
const osrmTrackRoutesRef = useRef({});
const [isAnimating, setIsAnimating] = useState(false);
const [animatedSegments, setAnimatedSegments] = useState([]);
// Per-step polyline progress for the Compare actual-GPS map. Keyed by
// sequenceStep, value is the index up to which the step's polyline should
// be rendered (positions.slice(0, value)). Lets each step's polyline DRAW
// progressively across the map — matches the planned animation's
// pair-by-pair drawing style instead of the older "whole step pops in"
// behavior. Cleared when Animate stops.
const [animatedActualProgress, setAnimatedActualProgress] = useState({});
// Mirror of isAnimating accessible inside the rAF loop. The loop needs to
// bail mid-animation if the user clicks Stop, but it can't read isAnimating
// directly (closure captures the value at scheduling time).
const isAnimatingRef = useRef(false);
const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD'));
// Custom date-picker popover state. The visible month can drift from the
// selected date (operator browses months without committing), so we keep
// the "viewing" month separately. Resets to the selected date's month
// every time the popover opens so the user never has to navigate back
// from wherever they last browsed.
const [datePickerOpen, setDatePickerOpen] = useState(false);
const [calViewMonth, setCalViewMonth] = useState(() =>
dayjs(selectedDate).isValid() ? dayjs(selectedDate).startOf('month') : dayjs().startOf('month')
);
const datePickerRef = useRef(null);
// Close the date-picker on outside click or Escape. Mounted only while the
// popover is open so we're not leaving global listeners around when it's
// not needed.
useEffect(() => {
if (!datePickerOpen) return undefined;
const onDocClick = (e) => {
if (!datePickerRef.current) return;
if (!datePickerRef.current.contains(e.target)) setDatePickerOpen(false);
};
const onKey = (e) => {
if (e.key === 'Escape') setDatePickerOpen(false);
};
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onKey);
};
}, [datePickerOpen]);
// When the popover opens, snap the visible month back to whichever month
// contains the currently-selected date. Otherwise the user could open it
// expecting to see "May 2026" and find themselves still parked in January
// from their last browsing session.
useEffect(() => {
if (datePickerOpen) {
const d = dayjs(selectedDate);
if (d.isValid()) setCalViewMonth(d.startOf('month'));
}
}, [datePickerOpen, selectedDate]);
// Compare mode — when ON, the body splits 50/50: left half is the regular
// dispatch map (planned routes), right half is a second map that overlays
// the focused rider's ACTUAL GPS tracks per delivery, pulled from the same
// /deliveries/getdeliverylogs/?deliveryid=X endpoint the Order Details map
// uses. Lets the operator visually verify whether the rider stuck to the
// dispatched plan. Disabled when no rider is focused (the comparison is
// per-rider). Kept as a single state flag so we don't entangle it with
// viewMode/focused* logic.
const [compareOpen, setCompareOpen] = useState(false);
// Which layer the unified compare map renders.
// 'combined' = planned (dashed) + actual GPS (solid) overlaid on one map
// 'planned' = planned route only
// 'actual' = actual GPS tracks only
// Driven by the segmented control overlaid on the compare map's top-left.
const [compareViewMode, setCompareViewMode] = useState('combined');
// Default-open toggle for the "Route sequence" section in the compare data
// panel — shows planned vs actual visit order with out-of-order steps flagged.
const [sequenceOpen, setSequenceOpen] = useState(true);
// Default-open toggle for the planned/actual timeline strip above the
// compare actual-map. Lets the operator collapse the dual step rows when
// they want maximum map real estate.
const [compareTimelineOpen, setCompareTimelineOpen] = useState(true);
// Set of route-sequence "diff group" indices that are currently expanded.
// Cascade-aware: when N consecutive steps share the same shift amount,
// they're collapsed into one summary row by default; clicking it adds the
// run's index to this set so the individual steps reveal.
const [expandedSeqGroups, setExpandedSeqGroups] = useState(() => new Set());
// In controlled mode the parent owns selectedRiderId, so handleRiderFocus only
// fires onRiderSelect — focusedRider won't update until the parent re-renders.
// This ref lets us defer setCompareOpen(true) until focusedRider is confirmed.
const pendingCompareRef = useRef(false);
// (Compare popup-pin state removed — the marker tooltips now open on hover
// automatically and click drives focusedCompareStep, so there's nothing left
// to "pin". See the timeline + delta panel for the persistent detail surface.)
// Sidebar collapse — hides the 400px sidebar (slides to width 0) so the
// two maps in Compare mode can use the full body width. Auto-collapses when
// Compare opens and restores the prior state when Compare closes; a peek
// tab on the left edge of the body lets the operator override at any time.
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const preCompareCollapsedRef = useRef(false);
const prevCompareOpenRef = useRef(false);
// Compare-data-panel collapse — mirrors the left sidebar peek tab on the
// opposite edge so the operator can hide the right rail and let the map
// claim the full width during compare. Resets to expanded each time
// Compare opens fresh.
const [compareDataCollapsed, setCompareDataCollapsed] = useState(false);
// Compare UI — step focus on the unified compare map.
// focusedCompareStep: null = "overall" (whole day); 1..N = drill into
// that single delivery. The unified map zooms to that step's bounds
// and the data panel switches from day-summary to per-step delta.
// leftMapRef: Leaflet map instance captured via CaptureMap — kept so
// future per-step zoom logic can drive the map imperatively.
const [focusedCompareStep, setFocusedCompareStep] = useState(null);
const leftMapRef = useRef(null);
// Stable Canvas renderer for the planned (left) map. Leaflet's default
// canvas (used when `preferCanvas` is true) only draws ~10% beyond the
// viewport — the moment a drag carries a polyline edge outside that
// padding, the off-area pixels are blank until `moveend` triggers a
// redraw, which makes polylines look "broken" mid-drag. Bumping padding
// to 1.5 makes the canvas ~4× viewport area, so dragging within a screen
// of travel keeps every polyline drawn without re-raster gaps. The
// actual (right) map's renderer is created later, after focusedRider is
// in scope, since it remounts per rider.
const plannedMapRendererRef = useRef(null);
if (!plannedMapRendererRef.current) {
plannedMapRendererRef.current = L.canvas({ padding: 1.5, tolerance: 5 });
}
// Pull the partners/getriderlogs feed for the currently selected hub + date.
// This endpoint returns the exact live GPS position for every rider at the
// hub (latitude/longitude/logdate/status). We render those positions as
// markers on the main dispatch map so the operator sees where each rider
// actually is — matching the Reports → Riders Logs page.
const { data: ridersLocationLogs } = useQuery({
queryKey: [selectedAppLocationId, selectedDate, ''],
queryFn: fetchRidersLogs,
refetchInterval: 15_000,
refetchIntervalInBackground: false,
staleTime: 5 * 1000,
refetchOnWindowFocus: false
});
// Normalize the feed into map-ready rider points. Drop entries without a
// usable lat/lon — those would crash the Leaflet Marker.
const liveRiderLocations = useMemo(() => {
return (ridersLocationLogs || [])
.map((r) => {
const lat = parseFloat(r?.latitude);
const lon = parseFloat(r?.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return {
id: String(r.userid ?? ''),
userid: r.userid,
username: r.username || `Rider #${r.userid}`,
status: String(r.status || '').toLowerCase(),
contactno: r.contactno,
orderid: r.orderid,
logdate: r.logdate,
lat,
lon
};
})
.filter(Boolean);
}, [ridersLocationLogs]);
// Default to the slot containing the current wall-clock time. Use a
// fractional hour so 12:45 lands in the 12:30+ slot 2 (not slot 1). If
// the current time falls outside every slot window (e.g. before 8 AM)
// fall back to the first slot.
const [selectedBatch, setSelectedBatch] = useState(() => {
const now = dayjs();
return getBatchForHour(now.hour() + now.minute() / 60, BATCHES_DEFAULT) || BATCHES_DEFAULT[0].id;
});
// If the operator deletes the slot currently selected, fall back to the
// first remaining slot so the page doesn't show an empty bucket.
useEffect(() => {
if (selectedBatch === 'all') return;
if (!BATCHES.some((b) => b.id === selectedBatch)) {
setSelectedBatch(BATCHES[0]?.id || 'all');
}
}, [BATCHES, selectedBatch]);
const activeBatchRef = useRef(null);
// Live deliveries query — runs only when no `data` prop is passed (i.e., standalone page).
const shouldFetchLive = !data;
const liveUserid = typeof window !== 'undefined' ? localStorage.getItem('userid') || 0 : 0;
const {
data: livePagesData,
isFetching: liveIsFetching,
isError: liveIsError,
fetchNextPage: liveFetchNextPage,
hasNextPage: liveHasNextPage,
isFetchingNextPage: liveIsFetchingNextPage
} = useInfiniteQuery({
queryKey: ['dispatchDeliveries', selectedAppLocationId, liveUserid, 'all', selectedDate, selectedDate, 50, '', 0, 0, 0],
queryFn: fetchDeliveries,
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
enabled: shouldFetchLive
});
// Auto-page through all results for the selected date.
useEffect(() => {
if (!shouldFetchLive) return;
if (liveHasNextPage && !liveIsFetchingNextPage) liveFetchNextPage();
}, [shouldFetchLive, liveHasNextPage, liveIsFetchingNextPage, liveFetchNextPage]);
const liveRows = useMemo(() => {
// Flatten infinite-query pages, then dedupe by orderid. The deliveries API
// can return the same orderid more than once (e.g. when page bookkeeping
// overlaps), which would otherwise inflate counts like the header's
// "N orders today" pill. First occurrence wins.
const all = (livePagesData?.pages || []).flatMap((p) => p.rows || []);
const seen = new Set();
const out = [];
for (const r of all) {
const key = r.orderid != null ? String(r.orderid) : null;
if (key && seen.has(key)) continue;
if (key) seen.add(key);
out.push(r);
}
return out;
}, [livePagesData]);
// Distinct riders across the WHOLE day's rows — NOT slot-filtered. Used by
// the Rider Info view so the operator can pick any rider regardless of
// whether they had orders in the currently selected slot.
const ridersAllDay = useMemo(() => {
const map = new Map();
liveRows.forEach((r) => {
const id = String(r.userid || r.rider_id || '');
if (!id || id === 'unassigned' || id === '0') return;
if (!map.has(id)) {
map.set(id, {
id,
riderName: r.ridername || r.rider_name || r.username || `Rider ${id}`
});
}
});
return Array.from(map.values()).sort((a, b) =>
String(a.riderName).localeCompare(String(b.riderName))
);
}, [liveRows]);
// Per-batch counts shown on the batch selector pills (uses unfiltered rows so counts stay
// visible even when a single batch is active). Recomputes whenever the operator
// picks a different timestamp column to bucket on, or edits the slot ranges.
const batchCounts = useMemo(() => {
const counts = { all: liveRows.length };
BATCHES.forEach((b) => { counts[b.id] = 0; });
liveRows.forEach((r) => {
const b = getRowBatch(r, selectedTimeField, BATCHES);
if (b) counts[b] = (counts[b] || 0) + 1;
});
return counts;
}, [liveRows, selectedTimeField, BATCHES]);
// Apply the batch filter before grouping so zones/riders/bikes all reflect the chosen wave.
const filteredLiveRows = useMemo(() => {
if (selectedBatch === 'all') return liveRows;
return liveRows.filter((r) => getRowBatch(r, selectedTimeField, BATCHES) === selectedBatch);
}, [liveRows, selectedBatch, selectedTimeField, BATCHES]);
// Reshape flat delivery rows into the zones/riders/orders structure Dispatch consumes.
const liveData = useMemo(() => {
if (!shouldFetchLive) return null;
if (!filteredLiveRows.length) return { code: 200, zone_summary: [], zones: [] };
// Bucket by delivery area (suburb) so each zone card represents a real place like
// "Uppilipalayam" — the operator-facing concept of a zone. Suburb strings from the
// API have inconsistent whitespace and casing, so we key on the normalized form
// (trimmed + lowercased) and keep the first-seen original casing as the display label.
const normSuburb = (v) => String(v || '').trim();
const zoneBuckets = {};
filteredLiveRows.forEach((r) => {
const rawZone = normSuburb(r.deliverysuburb) || normSuburb(r.locationsuburb) || normSuburb(r.zone_name);
const displayName = rawZone || 'Unzoned';
const zoneKey = displayName.toLowerCase();
const riderKey = String(r.userid || r.rider_id || 'unassigned');
const riderName = r.ridername || r.rider_name || r.username || (riderKey === 'unassigned' ? 'Unassigned' : `Rider ${riderKey}`);
if (!zoneBuckets[zoneKey]) zoneBuckets[zoneKey] = { zone_name: displayName, riders: {} };
if (!zoneBuckets[zoneKey].riders[riderKey]) {
zoneBuckets[zoneKey].riders[riderKey] = { rider_id: riderKey, rider_name: riderName, orders: [] };
}
zoneBuckets[zoneKey].riders[riderKey].orders.push(r);
});
const zones = Object.values(zoneBuckets).map((z) => {
const riders = Object.values(z.riders).map((rd) => {
const sorted = [...rd.orders].sort((a, b) =>
dayjs(a.deliverydate || a.assigntime || 0).valueOf() - dayjs(b.deliverydate || b.assigntime || 0).valueOf()
);
return {
...rd,
orders: sorted.map((o, idx) => ({
...o,
trip_number: o.trip_number || 1,
step: o.step || idx + 1
}))
};
});
return { zone_name: z.zone_name, riders };
});
const zone_summary = zones.map((z) => {
const allOrds = z.riders.flatMap((r) => r.orders);
const assigned = allOrds.filter((o) => o.userid || o.rider_id).length;
return {
zone_name: z.zone_name,
total_orders: allOrds.length,
assigned_orders: assigned,
unassigned_orders_count: allOrds.length - assigned,
active_riders_count: z.riders.filter((r) => r.rider_id !== 'unassigned').length,
total_delivery_kms: allOrds.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0),
total_profit: allOrds.reduce((s, o) => s + parseFloat(o.profit || 0), 0)
};
});
return { code: 200, zone_summary, zones };
}, [shouldFetchLive, filteredLiveRows]);
// Merge each zone's per-rider data with its summary metrics for sidebar rendering.
// Also derive aggregates the AI response doesn't pre-compute: which suburbs the zone
// delivers to, which kitchens it picks up from, and the order-status breakdown.
const zoneCards = useMemo(() => {
const source = data || liveData || { zones: [], zone_summary: [] };
const zonesArr = source.zones || [];
const summaryByName = {};
(source.zone_summary || []).forEach((s) => { summaryByName[s.zone_name] = s; });
const tally = (arr, keyFn) => {
const m = {};
arr.forEach((o) => {
const k = keyFn(o);
if (!k) return;
m[k] = (m[k] || 0) + 1;
});
return Object.entries(m)
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count);
};
return zonesArr.map((z) => {
const summary = summaryByName[z.zone_name] || {};
const allOrders = (z.riders || []).flatMap((r) => r.orders || []);
const activeRiderCount = (z.riders || []).filter((r) => r.rider_id && r.rider_id !== 'unassigned').length;
const suburbs = tally(allOrders, (o) => o.deliverysuburb || o.locationsuburb);
const kitchens = tally(allOrders, (o) => o.pickupcustomer || o.kitchen_key);
const statusCounts = {};
allOrders.forEach((o) => {
const s = String(o.orderstatus || 'unknown').toLowerCase();
statusCounts[s] = (statusCounts[s] || 0) + 1;
});
return {
id: z.zone_name,
name: z.zone_name,
riders: z.riders || [],
orders: allOrders,
totalOrders: summary.total_orders ?? allOrders.length,
activeRidersCount: summary.active_riders_count ?? activeRiderCount,
totalKms: summary.total_delivery_kms ?? allOrders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0),
totalProfit: summary.total_profit ?? allOrders.reduce((s, o) => s + parseFloat(o.profit || 0), 0),
suburbs,
kitchens,
statusCounts
};
});
}, [data, liveData]);
// Data processing logic
const { riders, kitchens, allOrders, stats } = useMemo(() => {
const source = data || liveData || { zones: [], zone_summary: [] };
const orders = [];
(source.zones || []).forEach(z => {
(z.riders || []).forEach(r => {
r.orders.forEach(o => {
orders.push({ ...o, zone_name: z.zone_name, rider_name: r.rider_name, rider_id: r.rider_id });
});
});
});
const riderMap = {};
orders.forEach(o => {
const key = o.rider_id || o.userid || 'unknown';
if (!riderMap[key]) {
riderMap[key] = {
id: key,
riderName: o.rider_name || o.username || o.rider || key,
orders: [],
color: RIDER_COLORS[Object.keys(riderMap).length % RIDER_COLORS.length]
};
}
riderMap[key].orders.push(o);
});
const kitchenMap = {};
orders.forEach(o => {
const name = o.pickupcustomer || o.kitchen_key || 'Unknown';
const key = name.toLowerCase().trim();
if (!kitchenMap[key]) {
kitchenMap[key] = {
id: key,
kitchenName: name,
lat: toNum(pickupLat(o)),
lon: toNum(pickupLon(o)),
orders: [],
riders: new Set()
};
} else if (!Number.isFinite(kitchenMap[key].lat) && hasValidPickup(o)) {
// Upgrade to first valid pickup coords we see for this kitchen
kitchenMap[key].lat = toNum(pickupLat(o));
kitchenMap[key].lon = toNum(pickupLon(o));
}
kitchenMap[key].orders.push(o);
if (o.rider_id) kitchenMap[key].riders.add(o.rider_id);
});
const totalKms = orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0);
const totalProfit = orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0);
// Sort each rider's orders by (trip_number, step) so every downstream view —
// sidebar step list, step-id badges on the card, OSRM trip points, animation —
// sees them in delivery order 1→N.
const sortedRiders = Object.values(riderMap).map((r) => ({
...r,
orders: [...r.orders].sort((a, b) => {
const tA = a.trip_number || 1;
const tB = b.trip_number || 1;
if (tA !== tB) return tA - tB;
return (a.step || 0) - (b.step || 0);
})
}));
return {
riders: sortedRiders.sort((a, b) => b.orders.length - a.orders.length),
kitchens: Object.values(kitchenMap).sort((a, b) => b.orders.length - a.orders.length),
allOrders: orders,
stats: {
totalOrders: orders.length,
totalKms,
totalProfit,
totalRiders: Object.keys(riderMap).length
}
};
}, [data, liveData]);
// Resolve focusedRider: prop-derived when controlled, local state otherwise.
const focusedRider = isControlled
? (selectedRiderId ? (riders.find((r) => r.id === selectedRiderId) || null) : null)
: internalFocusedRider;
// Per-rider canvas renderer for the actual (right) map in Compare mode.
// Single setter used by every interactive site in the UI. In uncontrolled mode it
// updates local state; in controlled mode it only notifies the parent.
const handleRiderFocus = useCallback(
(r) => {
if (onRiderSelect) onRiderSelect(r ? r.id : null);
if (!isControlled) setInternalFocusedRider(r);
setFocusedStop(null);
},
[isControlled, onRiderSelect]
);
// ─── Logger Effects for Rider & Order Updates ───────────────────────
// Log when the active focused rider changes (uncontrolled click or controlled prop change)
const prevFocusedRiderIdRef = useRef(null);
useEffect(() => {
const activeId = focusedRider ? focusedRider.id : null;
if (activeId !== prevFocusedRiderIdRef.current) {
if (focusedRider) {
logger.info(`Focused rider changed to: ${focusedRider.riderName} (${focusedRider.orders.length} orders)`);
} else {
logger.info('Focused rider reset to: None');
}
prevFocusedRiderIdRef.current = activeId;
}
}, [focusedRider]);
// Log when selected focused stop (individual order) changes
useEffect(() => {
if (focusedStop) {
logger.info(`Focused order updated: ID ${focusedStop.orderid}`);
} else {
logger.debug('Focused order selection cleared');
}
}, [focusedStop]);
// Log when periodic query refetch completes and updates the overall orders list
const prevOrdersCountRef = useRef(0);
useEffect(() => {
if (allOrders) {
if (allOrders.length !== prevOrdersCountRef.current) {
logger.info(`Orders database updated: ${allOrders.length} orders actively tracked`);
prevOrdersCountRef.current = allOrders.length;
}
}
}, [allOrders]);
// Log when periodic query refetch completes and updates the live rider locations
const prevRidersCountRef = useRef(0);
useEffect(() => {
if (liveRiderLocations) {
if (liveRiderLocations.length !== prevRidersCountRef.current) {
logger.info(`Live riders list updated: ${liveRiderLocations.length} active riders mapped`);
prevRidersCountRef.current = liveRiderLocations.length;
}
}
}, [liveRiderLocations]);
const activeStats = useMemo(() => {
if (focusedRider) {
return {
orders: focusedRider.orders.length,
riders: 1,
km: focusedRider.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0),
profit: focusedRider.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0),
label: 'Focused Rider'
};
}
if (focusedKitchen) {
return {
orders: focusedKitchen.orders.length,
riders: focusedKitchen.riders.size,
km: focusedKitchen.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0),
profit: focusedKitchen.orders.reduce((s, o) => s + parseFloat(o.profit || 0), 0),
label: 'Focused Kitchen'
};
}
return {
orders: stats.totalOrders,
riders: stats.totalRiders,
km: stats.totalKms,
profit: stats.totalProfit,
label: 'Total Fleet'
};
}, [focusedRider, focusedKitchen, stats]);
// List of deliveryids we want GPS logs for. Drives two pipelines:
// • renderRoutes() — actual-route polylines on the main map for
// every visible rider/trip
// • riderActualTracks (Compare) — per-step deltas + the right-map drop pins
// Scoped to currently-visible orders (zone/kitchen/rider focus) so the
// default "see all orders" view doesn't fan-out N requests for orders
// that aren't on screen. Deduped; ignores rows without a deliveryid
// (e.g. unaccepted orders) since /getdeliverylogs needs a real id.
const visibleDeliveryIds = useMemo(() => {
let scope;
if (focusedRider) scope = focusedRider.orders;
else if (focusedKitchen) scope = focusedKitchen.orders;
else if (focusedZone) scope = focusedZone.orders;
else scope = allOrders;
const set = new Set();
(scope || []).forEach((o) => {
if (o.deliveryid != null && o.deliveryid !== '' && o.deliveryid !== 0) {
set.add(String(o.deliveryid));
}
});
return Array.from(set);
}, [allOrders, focusedRider, focusedKitchen, focusedZone]);
// Fan-out per-delivery GPS log queries in parallel. Each result is cached by
// deliveryid in React Query, so navigating between focus modes / opening
// Compare for a previously-loaded rider is instant. Queries are driven by
// `visibleDeliveryIds` (currently rendered orders) so we only fetch what
// the map will actually draw.
const deliveryLogQueries = useQueries({
queries: visibleDeliveryIds.map((deliveryid) => ({
queryKey: ['deliveryLogs', deliveryid],
queryFn: async () => {
const res = await axios.get(
`${process.env.REACT_APP_URL3}/deliveries/getdeliverylogs/?deliveryid=${deliveryid}`
);
// Accept several possible response shapes — the live API has shipped
// {details:[…]}, plain arrays, and {data:[…]} variants over time, and
// any of those should produce a polyline. Pick the first non-empty
// array-like found.
const candidates = [res?.data?.details, res?.data?.data, res?.data, res];
const rows = candidates.find((c) => Array.isArray(c)) || [];
// Also accept lat/lng (alternate naming) in case the endpoint ever
// returns the same shape the front-end uses internally.
// Sort by logdate ascending so the polyline follows the rider's
// chronological path. The endpoint isn't guaranteed to return rows
// in order — without this, consecutive points can be out of sequence
// and the rendered track zig-zags between them.
const sorted = rows
.map((r) => {
const ts = r?.logdate ? dayjs(r.logdate) : null;
return {
lat: parseFloat(r?.latitude ?? r?.lat),
lng: parseFloat(r?.longitude ?? r?.lng ?? r?.lon),
logdate: r?.logdate,
_ts: ts && ts.isValid() ? ts.valueOf() : Number.MAX_SAFE_INTEGER
};
})
.filter((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng))
.sort((a, b) => a._ts - b._ts);
// Kalman pass: smooths raw GPS jitter (multi-path noise, stationary
// wobble at the drop, occasional cold-start outliers) before the
// pings ever reach the renderer or OSRM. The filter needs the
// _ts on each ping to compute per-step dt, so we run it BEFORE
// stripping _ts on the way out.
const smoothed = kalmanSmoothGps(sorted);
return smoothed.map(({ _ts, ...p }) => p);
},
// Always-on so the main map can render actual-route polylines (not just
// Compare). Cached results survive view changes; nothing to gate.
enabled: true,
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1
}))
});
// Index of `visibleDeliveryIds` → query state, keyed by deliveryid. Used by
// renderRoutes() to draw actual-route polylines on the main map, and by
// `riderActualTracks` for the Compare right map / delta panel. Keeping the
// index here (instead of recomputing inside each consumer) means downstream
// useMemos can depend on just this map rather than the whole query array.
const actualTracksByDeliveryId = useMemo(() => {
const map = new Map();
visibleDeliveryIds.forEach((did, i) => {
const q = deliveryLogQueries[i];
map.set(did, {
coords: q?.data || [],
isLoading: !!(q?.isLoading || q?.isFetching),
isError: !!q?.isError
});
});
return map;
}, [visibleDeliveryIds, deliveryLogQueries]);
// Normalize the parallel query results into a flat array of per-delivery
// tracks for the Compare map. Tracks are sorted by `deliverytime` (with
// `expecteddeliverytime` and original step number as fallbacks) so the
// sequence on the map mirrors the order they were actually completed.
// A 1-indexed `sequenceStep` is attached to drive the numbered markers.
const riderActualTracks = useMemo(() => {
if (!focusedRider) return [];
const tsOf = (o) => {
const t = o.deliverytime || o.expecteddeliverytime;
if (!t) return Number.MAX_SAFE_INTEGER;
const d = dayjs(t);
return d.isValid() ? d.valueOf() : Number.MAX_SAFE_INTEGER;
};
const sorted = focusedRider.orders
.filter((o) => o.deliveryid != null && o.deliveryid !== '' && o.deliveryid !== 0)
.sort((a, b) => {
const diff = tsOf(a) - tsOf(b);
if (diff !== 0) return diff;
return (a.step || 0) - (b.step || 0);
});
return sorted.map((o, i) => {
const track = actualTracksByDeliveryId.get(String(o.deliveryid));
return {
sequenceStep: i + 1,
orderid: o.orderid,
deliveryid: o.deliveryid,
deliverycustomer: o.deliverycustomer,
pickupcustomer: o.pickupcustomer,
step: o.step,
tripNumber: o.trip_number || 1,
deliverytime: o.deliverytime || o.expecteddeliverytime,
kms: parseFloat(o.actualkms || o.kms || 0) || 0,
profit: parseFloat(o.profit || 0) || 0,
orderstatus: o.orderstatus,
isLoading: !!track?.isLoading,
isError: !!track?.isError,
coords: track?.coords || []
};
});
}, [focusedRider, actualTracksByDeliveryId]);
// Per-step planned-vs-actual deltas for the Compare delta panel. Pure
// computation off the data we already have:
// plannedKm — order.kms field (set when dispatch planned this trip)
// actualKm — prefer the OSRM-snapped polyline length (most accurate
// once snapping completes); fall back to order.actualkms
// (backend-computed) and finally to the raw GPS polyline
// length so the panel never shows blank while OSRM is
// still in flight.
// timeDeltaMin — actual deliverytime - expecteddeliverytime, in minutes.
// Negative = ahead of plan, positive = late.
// anomaly — true when actualKm > 1.25 * plannedKm OR
// timeDeltaMin > 15 (configurable thresholds; chosen so
// small noise doesn't trip the flag but a wrong-turn does).
const compareDeltas = useMemo(() => {
if (!focusedRider) return [];
return riderActualTracks.map((t) => {
const order = focusedRider.orders.find(
(o) => String(o.deliveryid) === String(t.deliveryid)
);
const plannedKm = parseFloat(order?.kms || 0) || 0;
const snapped = osrmTrackRoutes[t.deliveryid];
let actualKm = 0;
if (Array.isArray(snapped) && snapped.length >= 2) {
actualKm = polylineLengthKm(snapped);
} else if (order?.actualkms != null && order.actualkms !== '') {
actualKm = parseFloat(order.actualkms) || 0;
} else if (t.coords.length >= 2) {
actualKm = polylineLengthKm(t.coords.map((p) => [p.lat, p.lng]));
}
const kmDelta = actualKm - plannedKm;
const kmDeltaPct = plannedKm > 0 ? (kmDelta / plannedKm) * 100 : null;
const expectedTs = order?.expecteddeliverytime
? dayjs(order.expecteddeliverytime)
: null;
const actualTs = order?.deliverytime ? dayjs(order.deliverytime) : null;
const timeDeltaMin =
expectedTs?.isValid() && actualTs?.isValid()
? actualTs.diff(expectedTs, 'minute')
: null;
const anomaly =
(plannedKm > 0 && actualKm > plannedKm * 1.25) ||
(timeDeltaMin != null && timeDeltaMin > 15);
return {
sequenceStep: t.sequenceStep,
deliveryid: t.deliveryid,
orderid: t.orderid,
order,
plannedKm,
actualKm,
kmDelta,
kmDeltaPct,
expectedTs: expectedTs?.isValid() ? expectedTs : null,
actualTs: actualTs?.isValid() ? actualTs : null,
timeDeltaMin,
anomaly,
orderstatus: t.orderstatus,
deliverycustomer: t.deliverycustomer,
pickupcustomer: order?.pickupcustomer,
isLoading: t.isLoading,
coordsCount: t.coords.length
};
});
}, [riderActualTracks, focusedRider, osrmTrackRoutes]);
// Roll-up of the per-step deltas — used by the overall summary card shown
// when no specific step is focused. Excluding loading rows from the
// anomaly count prevents the "47% deviation" headline from churning while
// OSRM is still snapping.
const compareSummary = useMemo(() => {
if (compareDeltas.length === 0) {
return { plannedKm: 0, actualKm: 0, kmDeltaPct: null, anomalies: 0, late: 0, onTime: 0 };
}
const ready = compareDeltas.filter((d) => !d.isLoading && d.coordsCount > 0);
const plannedKm = ready.reduce((s, d) => s + d.plannedKm, 0);
const actualKm = ready.reduce((s, d) => s + d.actualKm, 0);
const kmDeltaPct = plannedKm > 0 ? ((actualKm - plannedKm) / plannedKm) * 100 : null;
const anomalies = ready.filter((d) => d.anomaly).length;
const late = ready.filter((d) => d.timeDeltaMin != null && d.timeDeltaMin > 5).length;
const onTime = ready.filter((d) => d.timeDeltaMin != null && d.timeDeltaMin <= 5).length;
return { plannedKm, actualKm, kmDeltaPct, anomalies, late, onTime };
}, [compareDeltas]);
const plannedOrdered = useMemo(() => {
return [...compareDeltas].sort(
(a, b) => (a.order?.step || a.sequenceStep) - (b.order?.step || b.sequenceStep)
);
}, [compareDeltas]);
const actualOrdered = useMemo(() => {
return [...compareDeltas].sort(
(a, b) => a.sequenceStep - b.sequenceStep
);
}, [compareDeltas]);
// When Compare is open and a step is focused, override MapController's
// focusedItem with a synthetic single-order item so the planned (left)
// map zooms onto the same delivery the operator is scrutinizing. Without
// this, only the actual (right) map would zoom and the two views would
// disagree.
const compareFocusItem = useMemo(() => {
if (!compareOpen || !focusedCompareStep || !focusedRider) return null;
const track = riderActualTracks.find((t) => t.sequenceStep === focusedCompareStep);
if (!track) return null;
const order = focusedRider.orders.find(
(o) => String(o.deliveryid) === String(track.deliveryid)
);
if (!order) return null;
return { orders: [order], id: `cmp-step-${focusedCompareStep}-${order.orderid}` };
}, [compareOpen, focusedCompareStep, focusedRider, riderActualTracks]);
// Reset focused step when leaving Compare, switching riders, or re-entering
// Compare. Otherwise "step 5" leaks across riders and the next rider opens
// stuck on a step that may not exist in their day.
useEffect(() => {
setFocusedCompareStep(null);
setExpandedSeqGroups(new Set());
setCompareViewMode('combined');
}, [compareOpen, focusedRider?.id]);
// When the focused rider changes, close any open comparison so the operator
// doesn't see stale tracks from a previous rider.
useEffect(() => {
if (!focusedRider && compareOpen) setCompareOpen(false);
}, [focusedRider, compareOpen]);
// Controlled-mode deferred compare open: once the parent has updated
// selectedRiderId and focusedRider resolves, honour the pending open request.
useEffect(() => {
if (pendingCompareRef.current && focusedRider) {
pendingCompareRef.current = false;
setCompareOpen(true);
}
}, [focusedRider]);
// Auto-collapse the sidebar when Compare turns on (two maps need the room),
// and restore the operator's prior sidebar state when Compare turns off.
// Manual toggle is still available via the peek tab while compare is open.
useEffect(() => {
if (compareOpen && !prevCompareOpenRef.current) {
preCompareCollapsedRef.current = sidebarCollapsed;
setSidebarCollapsed(true);
// Fresh compare-open always reveals the data panel — last-collapsed state
// shouldn't carry across compare sessions.
setCompareDataCollapsed(false);
} else if (!compareOpen && prevCompareOpenRef.current) {
setSidebarCollapsed(preCompareCollapsedRef.current);
}
prevCompareOpenRef.current = compareOpen;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [compareOpen]);
const fetchRoute = useCallback(async (riderId, tripKey, points) => {
const cacheKey = `${riderId}-${tripKey}`;
// Use the ref (not state) for the in-flight / already-cached check so this
// callback doesn't need osrmRoutes in its deps — that old pattern caused a
// render loop: each resolved route updated state → recreated fetchRoute →
// re-ran all route-fetching effects for every rider.
if (osrmRoutesRef.current[cacheKey] !== undefined) return;
if (points.length < 2) return;
// Mark in-flight in both ref (immediate) and state (triggers re-render).
osrmRoutesRef.current[cacheKey] = null;
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: null }));
const coords = points.map(p => `${p[1]},${p[0]}`).join(';');
const url = `https://router.project-osrm.org/route/v1/driving/${coords}?overview=full&geometries=geojson`;
try {
const res = await fetch(url);
const json = await res.json();
if (json.routes && json.routes[0]) {
const poly = json.routes[0].geometry.coordinates.map(c => [c[1], c[0]]);
osrmRoutesRef.current[cacheKey] = poly;
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: poly }));
} else {
// OSRM responded but couldn't route — record as failed so renderRoutes
// shows the aerial fallback instead of an empty gap.
osrmRoutesRef.current[cacheKey] = false;
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: false }));
}
} catch (e) {
console.error('OSRM Fetch error:', e);
osrmRoutesRef.current[cacheKey] = false;
setOsrmRoutes(prev => ({ ...prev, [cacheKey]: false }));
}
}, []); // stable — cache reads go through osrmRoutesRef, not state
// Clear the OSRM route cache whenever the date or batch changes. Without this,
// routes fetched for the previous day/slot linger and are shown against the new
// data — especially visible when the same rider ID appears across different batches
// and the cached polyline from the earlier slot is drawn over the new orders.
useEffect(() => {
osrmRoutesRef.current = {};
setOsrmRoutes({});
osrmTrackRoutesRef.current = {};
setOsrmTrackRoutes({});
}, [selectedDate, selectedBatch]);
// Snap a raw GPS trace (lat/lng pings from /getdeliverylogs) to the road
// network so the Compare map always shows a smooth road-following polyline,
// never a zig-zag of aerial segments between sparse pings.
//
// Strategy: try OSRM's match service first (purpose-built for noisy GPS
// traces). If it returns nothing useful (sparse data, off-road pings, etc.)
// fall back to OSRM's route service over a subsampled set of the same
// points — that still produces a smooth road polyline, just with less
// fidelity. Cached per-deliveryid.
const fetchTrackRoute = useCallback(async (deliveryid, points) => {
if (osrmTrackRoutesRef.current[deliveryid] !== undefined) return;
if (!Array.isArray(points) || points.length < 2) return;
osrmTrackRoutesRef.current[deliveryid] = null;
setOsrmTrackRoutes((prev) => ({ ...prev, [deliveryid]: null }));
const storeSuccess = (poly) => {
osrmTrackRoutesRef.current[deliveryid] = poly;
setOsrmTrackRoutes((prev) => ({ ...prev, [deliveryid]: poly }));
};
const storeFailure = () => {
osrmTrackRoutesRef.current[deliveryid] = false;
setOsrmTrackRoutes((prev) => ({ ...prev, [deliveryid]: false }));
};
// Even subsample to <=N coordinates while keeping first + last. Public
// OSRM caps match at 100 and route is slower with many waypoints, so
// we pick a different N for each service.
const subsample = (arr, max) => {
if (arr.length <= max) return arr;
const step = Math.ceil(arr.length / max);
const out = arr.filter((_, i) => i % step === 0);
const last = arr[arr.length - 1];
if (out[out.length - 1] !== last) out.push(last);
return out;
};
// Attempt 1 — map-matching (best fidelity for dense traces).
try {
const ptsM = subsample(points, 90);
const coordsM = ptsM.map((p) => `${p[1]},${p[0]}`).join(';');
const matchUrl = `https://router.project-osrm.org/match/v1/driving/${coordsM}?overview=full&geometries=geojson&gaps=ignore&tidy=true`;
const resM = await fetch(matchUrl);
const jsonM = await resM.json();
if (jsonM.matchings && jsonM.matchings.length > 0) {
const poly = jsonM.matchings.flatMap((m) =>
(m.geometry?.coordinates || []).map((c) => [c[1], c[0]])
);
if (poly.length >= 2) {
storeSuccess(poly);
return;
}
}
} catch (e) {
console.warn('OSRM Match error, trying route fallback:', e);
}
// Attempt 2 — waypoint routing through a coarser subsample. Always
// returns a road polyline as long as the points are routable.
try {
const ptsR = subsample(points, 25);
const coordsR = ptsR.map((p) => `${p[1]},${p[0]}`).join(';');
const routeUrl = `https://router.project-osrm.org/route/v1/driving/${coordsR}?overview=full&geometries=geojson`;
const resR = await fetch(routeUrl);
const jsonR = await resR.json();
if (jsonR.routes && jsonR.routes[0]) {
const poly = jsonR.routes[0].geometry.coordinates.map((c) => [c[1], c[0]]);
if (poly.length >= 2) {
storeSuccess(poly);
return;
}
}
storeFailure();
} catch (e) {
console.error('OSRM Route fallback error:', e);
storeFailure();
}
}, []);
// Kick a snap-to-road request for every delivery whose GPS log just landed.
// Runs for every visible delivery (not just Compare) so the main map's
// actual-route polylines follow real roads. fetchTrackRoute's per-id ref
// dedupes — repeated effect fires after a query resolves are no-ops.
useEffect(() => {
actualTracksByDeliveryId.forEach((track, did) => {
if (!did || !track || track.coords.length < 2) return;
const pts = track.coords.map((c) => [c.lat, c.lng]);
fetchTrackRoute(did, pts);
});
}, [actualTracksByDeliveryId, fetchTrackRoute]);
// Mirror isAnimating into a ref so the rAF tick loop in startAnimation can
// bail mid-animation when the user clicks Stop (the rAF closure captures
// state at scheduling time and would otherwise keep ticking).
useEffect(() => {
isAnimatingRef.current = isAnimating;
}, [isAnimating]);
useEffect(() => {
if (embedded) return undefined;
const tick = () => {
const n = new Date();
setClock([n.getHours(), n.getMinutes(), n.getSeconds()].map(v => String(v).padStart(2, '0')).join(':'));
};
const timer = setInterval(tick, 1000);
tick();
return () => clearInterval(timer);
}, [embedded]);
useEffect(() => {
setActiveRiders(new Set(riders.map(r => r.id)));
}, [riders]);
useEffect(() => {
riders.forEach(r => {
const isActive = activeRiders.has(r.id);
if (!isActive) return;
if (focusedRider && focusedRider.id !== r.id) return;
const trips = {};
r.orders.forEach(o => {
const t = o.trip_number || 1;
if (!trips[t]) trips[t] = [];
trips[t].push(o);
});
Object.entries(trips).forEach(([tNum, tOrders]) => {
const sorted = [...tOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
const pts = buildTripPoints(sorted);
if (pts.length >= 2) fetchRoute(r.id, tNum, pts);
});
});
}, [riders, activeRiders, focusedRider, fetchRoute]);
// Auto-advance the selected slot when the wall-clock moves into a new slot's
// window — BUT only if the user is still sitting on the slot that's just been
// left, so a manual pick (e.g. "let me inspect Slot 1") is never overridden.
// Polls every 30s; only fires when the current hour actually changes.
// prevHourRef tracks the *fractional* hour (hour + minute/60) so the
// ticker notices slot boundaries that fall mid-hour — slot 1 ending at
// 12:30 means the auto-advance must fire on the 11:30→12:30 crossing,
// not just on the wall-clock hour change.
const prevHourRef = useRef(null);
useEffect(() => {
if (!shouldFetchLive) return;
const nowFracHour = () => {
const d = dayjs();
return d.hour() + d.minute() / 60;
};
if (prevHourRef.current === null) prevHourRef.current = nowFracHour();
const tick = () => {
const h = nowFracHour();
const fromSlot = getBatchForHour(prevHourRef.current, BATCHES);
const toSlot = getBatchForHour(h, BATCHES);
prevHourRef.current = h;
if (!toSlot || toSlot === fromSlot) return;
setSelectedBatch((cur) => (cur === fromSlot ? toSlot : cur));
};
const id = setInterval(tick, 30 * 1000);
return () => clearInterval(id);
}, [shouldFetchLive, BATCHES]);
// Reset focusedStop when the focused kitchen changes so a stale stop from a
// previously focused kitchen doesn't linger after switching kitchens.
// (For riders, handleRiderFocus already clears focusedStop.)
useEffect(() => {
setFocusedStop(null);
}, [focusedKitchen?.id]);
// Scroll the active slot chip into the visible part of the horizontal scroller
// — used when the default slot is set late in the day and overflows off-screen,
// or when the user clicks a chip that's only partially visible.
useEffect(() => {
const btn = activeBatchRef.current;
if (!btn || typeof btn.scrollIntoView !== 'function') return;
btn.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
}, [selectedBatch]);
// When the user clicks a step in the focused-rider sidebar (sets focusedStop),
// surface that order in the centered popup overlay so the details show up
// without a second click. focusedStop is a slim { orderid, lat, lon }, so
// hydrate the full order out of allOrders before passing it to the popup
// (renderOrderPopupContent needs the rich timeline + status fields).
// Wait ~350ms so MapController has a chance to recenter first (matches
// the prior delay before openPopup was called).
useEffect(() => {
if (!focusedStop) return;
const t = setTimeout(() => {
const fullOrder = allOrders?.find?.((o) => String(o.orderid) === String(focusedStop.orderid));
if (fullOrder) setCenterPopupOrder(fullOrder);
}, 350);
return () => clearTimeout(t);
}, [focusedStop, allOrders]);
const startAnimation = () => {
if (isAnimating) {
setIsAnimating(false);
setAnimatedSegments([]);
setAnimatedActualProgress({});
return;
}
setIsAnimating(true);
setAnimatedSegments([]);
setAnimatedActualProgress({});
// Capture into locals so the setTimeout callbacks below don't read stale
// state if the user toggles focus mid-animation.
const isCompareFocused = compareOpen && focusedRider;
const compareDeliveryToStep = isCompareFocused
? new Map(riderActualTracks.map((t) => [String(t.deliveryid), t.sequenceStep]))
: null;
const allSegs = [];
riders.forEach(r => {
if (!activeRiders.has(r.id)) return;
if (focusedRider && focusedRider.id !== r.id) return;
if (focusedKitchen && !focusedKitchen.riders.has(r.id)) return;
const trips = {};
r.orders.forEach(o => {
const t = o.trip_number || 1;
if (!trips[t]) trips[t] = [];
trips[t].push(o);
});
Object.entries(trips).forEach(([tNum, tOrders]) => {
// Filter orders by focused kitchen if active
const filteredTOrders = focusedKitchen
? tOrders.filter(o => (o.pickupcustomer || o.kitchen_key || 'Unknown').toLowerCase().trim() === focusedKitchen.id)
: tOrders;
if (filteredTOrders.length === 0) return;
const cacheKey = `${r.id}-${tNum}`;
const roadPath = osrmRoutes[cacheKey];
const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
// Aerial fallback — NaN-safe build
const aerialPath = buildTripPoints(sorted);
const isKitchenAerial = (viewMode === 'kitchens' || focusedKitchen);
const path = roadPath || aerialPath;
if (path.length < 2) return;
// Compare mode for the focused rider: color each animation pair by
// its step using STEP_PALETTE so the planned animation visually
// matches the static per-step polylines + the right map's actual
// GPS animation. We reuse splitPolylineByDrops to assign each
// polyline-pair (path[i] → path[i+1]) to the step containing it.
const isCompareRider = isCompareFocused && r.id === focusedRider.id;
let pairColorAt = () => r.color; // (idx) → color
if (isCompareRider) {
const validDrops = sorted.filter(hasValidDrop);
const dropCoords = validDrops.map((o) => [
parseFloat(o.droplat || o.deliverylat),
parseFloat(o.droplon || o.deliverylong)
]);
const stepSegs = roadPath
? splitPolylineByDrops(roadPath, dropCoords)
: (() => {
const hasPickup = aerialPath.length > dropCoords.length;
const out = [];
for (let i = 0; i < dropCoords.length; i++) {
const a = hasPickup ? i : i - 1;
const b = hasPickup ? i + 1 : i;
if (a < 0 || a >= aerialPath.length || b >= aerialPath.length) {
out.push([]);
} else {
out.push([aerialPath[a], aerialPath[b]]);
}
}
return out;
})();
// Map each path index → step index by accumulating segment lengths
// (segments share endpoints so we use length-1 per segment).
const pathIdxToStepIdx = [];
let acc = 0;
stepSegs.forEach((seg, sIdx) => {
const segLen = Math.max(0, (seg?.length || 0) - 1);
for (let k = 0; k < segLen; k++) pathIdxToStepIdx[acc + k] = sIdx;
acc += segLen;
});
pairColorAt = (idx) => {
const sIdx = pathIdxToStepIdx[idx];
if (sIdx == null) return r.color;
const order = sorted.filter(hasValidDrop)[sIdx];
const ss = order ? compareDeliveryToStep.get(String(order.deliveryid)) : null;
return ss ? stepColor(ss - 1) : r.color;
};
}
for (let i = 0; i < path.length - 1; i++) {
allSegs.push({
from: path[i],
to: path[i + 1],
color: pairColorAt(i),
delay: (parseInt(r.id.slice(-3)) || 0) * 0.05 + (parseInt(tNum) * 40) + i * (isKitchenAerial ? 40 : 8)
});
}
});
});
allSegs.sort((a, b) => a.delay - b.delay);
allSegs.forEach((s, idx) => {
setTimeout(() => {
setAnimatedSegments(prev => [...prev, s]);
if (idx === allSegs.length - 1) {
setTimeout(() => {
setIsAnimating(false);
setAnimatedActualProgress({});
}, 1000);
}
}, s.delay);
});
// Compare-mode: progressively DRAW the actual-GPS polylines on the right
// map — same visual style as the planned animation on the left (line
// grows from start to end), but driven by a single rAF loop instead of
// hundreds of per-pair setTimeouts. For each step we know:
// stepStart = idx * perStep — when this step begins drawing
// stepEnd = stepStart + perStep — when its polyline is fully drawn
// progress = ceil(t * positions.length) where t = (now - stepStart) / perStep
// The tick updates one state object containing all steps' progress so
// React batches the re-render across the whole right map.
if (isCompareFocused && riderActualTracks.length > 0) {
const tracksSnapshot = [...riderActualTracks];
// Snapshot the resolved positions per step at animation start. Prefer
// OSRM-snapped path when available so the drawing follows real roads;
// fall back to Kalman-smoothed coords otherwise.
const stepPositions = tracksSnapshot.map((t) => {
const snap = osrmTrackRoutes[t.deliveryid];
if (Array.isArray(snap) && snap.length >= 2) return snap;
return t.coords.map((p) => [p.lat, p.lng]);
});
const lastDelay = allSegs.length > 0
? allSegs[allSegs.length - 1].delay
: tracksSnapshot.length * 800;
const totalDuration = Math.max(lastDelay, tracksSnapshot.length * 600);
const perStep = totalDuration / Math.max(1, tracksSnapshot.length);
const animStartTs = Date.now();
const tick = () => {
if (!isAnimatingRef.current) return; // user clicked Stop
const elapsed = Date.now() - animStartTs;
const next = {};
tracksSnapshot.forEach((t, idx) => {
const positions = stepPositions[idx];
if (!positions || positions.length < 2) return;
const stepStart = idx * perStep;
const stepEnd = stepStart + perStep;
if (elapsed >= stepEnd) {
next[t.sequenceStep] = positions.length;
} else if (elapsed >= stepStart) {
const ratio = (elapsed - stepStart) / perStep;
next[t.sequenceStep] = Math.max(2, Math.ceil(ratio * positions.length));
}
// else: step hasn't started — leave undefined (filter will hide)
});
setAnimatedActualProgress(next);
if (elapsed < totalDuration + 200) {
requestAnimationFrame(tick);
}
};
requestAnimationFrame(tick);
}
};
const createKitchenIcon = (name, focused = false) => L.divIcon({
className: '',
iconSize: focused ? [56, 56] : [46, 46],
iconAnchor: focused ? [28, 28] : [23, 23],
popupAnchor: [0, focused ? -30 : -24],
html: `
{r.orders.reduce((s, o) => s + parseFloat(o.actualkms || o.kms || 0), 0).toFixed(1)} km
{estMeters !== null && (
{formatMeters(estMeters)} to drop
)}
{r.orders.slice(0, 15).map(o => S{o.step})}
);
};
// Returns true when the order's centered popup should stay open even after
// the cursor leaves the marker: either explicitly pinned via click, or the
// matching compare-step is focused (so clicking a step in the right panel
// keeps the card visible while the maps recenter).
const isOrderPopupPinned = (o) => {
if (!o) return false;
if (pinnedPopupsRef.current.has(String(o.orderid))) return true;
if (compareOpen && focusedRider && o.deliveryid != null) {
const track = riderActualTracks.find((t) => String(t.deliveryid) === String(o.deliveryid));
if (track && focusedCompareStep === track.sequenceStep) return true;
}
return false;
};
// Shared order-popup body used by both the planned-route number markers
// and the Compare mode actual-track drop pins. Extracted so clicking a
// pin on either layer surfaces the exact same Timeline + Details + KM
// chips — operators learn the layout once and trust it everywhere.
// Hover behavior (keep-open while cursor is over the card) is owned by
// the centered overlay wrapper, not this function.
const renderOrderPopupContent = (o) => {
const statusStyle = getStatusStyle(o.orderstatus);
const orderRiderId = o.rider_id || o.userid;
const isDelivered = FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase());
const estMeters = isDelivered ? null : calculateEstMeters(orderRiderId, o);
return (
ORDER #{o.orderid}
{o.orderstatus && (
{statusStyle.label}
)}
{o.rider_name || o.ridername || 'Unassigned'}
{o.deliveryid != null && (
Delivery #{o.deliveryid}
)}
{POPUP_TIMELINE.some((t) => o[t.key]) && (
Timeline
{POPUP_TIMELINE.map((t) => {
const time = formatTimeOnly(o[t.key]);
if (!time) return null;
return (
);
};
const renderMarkers = () => {
// Compare "Actual GPS only" view: drop the planned order markers entirely.
// The actual-track drop pins rendered later on the same map carry the
// same step numbers and step-palette colors, so showing both creates
// duplicate, slightly-offset pins that clutter the view.
if (compareOpen && focusedRider && compareViewMode === 'actual') return null;
let ordersToRender = allOrders;
if (focusedZone) ordersToRender = focusedZone.orders;
if (focusedKitchen) ordersToRender = focusedKitchen.orders;
if (focusedRider) ordersToRender = focusedRider.orders;
ordersToRender = ordersToRender.filter(hasValidDrop);
// Pre-build the deliveryid → sequenceStep lookup once per render so each
// marker can resolve its step palette color without an O(N) scan.
const compareDeliveryToStep =
compareOpen && focusedRider
? new Map(riderActualTracks.map((t) => [String(t.deliveryid), t.sequenceStep]))
: null;
return ordersToRender.map((o, idx) => {
const rid = o.rider_id;
const active = rid ? activeRiders.has(rid) : true;
let color = getRiderColor(rid);
// Compare mode: recolor and renumber the focused rider's markers by
// sequenceStep (delivery-time order, same as the right map + timeline)
// instead of rider color + planned step. Both maps now show the same
// color + number for the same delivery — a step-3 pin on the left
// planned map matches the step-3 polyline + drop pin on the right.
let compareSeq;
if (
compareDeliveryToStep &&
rid === focusedRider.id &&
o.deliveryid != null
) {
const ss = compareDeliveryToStep.get(String(o.deliveryid));
if (ss) {
color = stepColor(ss - 1);
compareSeq = ss;
}
}
const isRiderFocused = !!focusedRider;
const showNumbers = isRiderFocused || !!focusedKitchen;
const statusStyle = getStatusStyle(o.orderstatus);
const statusLow = String(o.orderstatus || '').toLowerCase();
const isDelivered = statusLow === 'delivered';
const isPulsing = pulseOrderId && String(pulseOrderId) === String(o.orderid);
// Flag SVG: pole + swallow-tail banner. A check glyph appears on the banner when delivered.
const flagSvg = o.orderstatus
? ``
: '';
const icon = showNumbers
? (() => {
const seq = compareSeq || o.step || (ordersToRender.indexOf(o) + 1);
const sz = 32;
return L.divIcon({
className: '',
iconSize: [sz, sz],
iconAnchor: [sz / 2, sz / 2],
popupAnchor: [0, -28],
html: `
${seq > 0 ? seq : ''}${flagSvg}
`
});
})()
: L.divIcon({
className: '',
iconSize: [24, 30],
iconAnchor: [2, 30],
popupAnchor: [10, -25], // Lift popup above the flag, not just the larger 32px marker
html: `
${flagSvg}
`
});
return (
{
if (inst) orderMarkerRefs.current[String(o.orderid)] = inst;
else delete orderMarkerRefs.current[String(o.orderid)];
}}
eventHandlers={{
click: () => {
const id = String(o.orderid);
if (pinnedPopupsRef.current.has(id)) {
pinnedPopupsRef.current.delete(id);
setCenterPopupOrder(null);
} else {
pinnedPopupsRef.current.add(id);
setCenterPopupOrder(o);
}
}
}}
/>
);
});
};
const renderRoutes = () => {
// Compare "Actual GPS only" view hides EVERY planned polyline on the
// unified compare map — both the static rendering AND the Animate
// Routes per-pair segments. Without this gate the animation pours the
// planned route on top of the actual GPS in Actual-only mode, which
// is the opposite of what that view exists to show. The actual-track
// polylines render their own progressive draw via animatedActualProgress
// (see riderActualTracks.map inside the MapContainer), so the actual
// animation still plays correctly when planned is hidden.
const hidePlanned = compareOpen && focusedRider && compareViewMode === 'actual';
if (isAnimating) {
if (hidePlanned) return [];
return animatedSegments.map((s, i) => (
));
}
const routes = [];
const zoneRiderIds = focusedZone ? new Set(focusedZone.riders.map((zr) => String(zr.rider_id))) : null;
if (hidePlanned) return routes;
riders.forEach(r => {
const isActive = activeRiders.has(r.id);
if (focusedRider && focusedRider.id !== r.id) return;
if (focusedKitchen && !focusedKitchen.riders.has(r.id)) return;
if (zoneRiderIds && !zoneRiderIds.has(String(r.id))) return;
const rOrders = r.orders;
const trips = {};
rOrders.forEach(o => {
const t = o.trip_number || 1;
if (!trips[t]) trips[t] = [];
trips[t].push(o);
});
Object.entries(trips).forEach(([tNum, tOrders]) => {
// Filter orders by focused kitchen if active
const filteredTOrders = focusedKitchen
? tOrders.filter(o => (o.pickupcustomer || o.kitchen_key || 'Unknown').toLowerCase().trim() === focusedKitchen.id)
: tOrders;
if (filteredTOrders.length === 0) return;
const cacheKey = `${r.id}-${tNum}`;
const roadPoints = osrmRoutes[cacheKey];
const sorted = [...filteredTOrders].sort((a, b) => (a.step || 0) - (b.step || 0));
const isKitchenView = (viewMode === 'kitchens' || focusedKitchen);
const opacity = isActive ? 1.0 : 0.1;
const weight = isKitchenView ? 7 : 6;
// ─── ACTUAL ROUTE (primary, for non-Compare riders) ───────────────
// Prefer the rider's recorded GPS route over the dispatched plan.
// We concatenate each delivery's OSRM-snapped path (or raw Kalman-
// smoothed GPS when snapping hasn't finished) in step order.
// Duplicate boundary points between consecutive deliveries are
// dropped so the joined line reads as one continuous trip, not N
// stitched segments. Compare mode for the focused rider is handled
// by the dedicated planned-overlay path below, so we skip this for
// that one rider when Compare is open.
const isCompareTarget =
compareOpen && focusedRider && r.id === focusedRider.id;
if (!isCompareTarget) {
const actualPoints = [];
let anyActual = false;
sorted.forEach((o) => {
if (o.deliveryid == null || o.deliveryid === '' || o.deliveryid === 0) return;
const did = String(o.deliveryid);
const snapped = osrmTrackRoutes[did];
const track = actualTracksByDeliveryId.get(did);
let seg = null;
if (Array.isArray(snapped) && snapped.length >= 2) {
seg = snapped;
} else if (track && track.coords.length >= 2) {
seg = track.coords.map((p) => [p.lat, p.lng]);
}
if (!seg) return;
anyActual = true;
if (actualPoints.length === 0) {
actualPoints.push(...seg);
} else {
// Drop the first point if it duplicates the previous segment's
// last point (within ~1.1m at the equator). Prevents a visible
// hairline kink at delivery boundaries when the snapped paths
// meet at the same drop coordinate.
const last = actualPoints[actualPoints.length - 1];
const first = seg[0];
const sameJoin =
Math.abs(last[0] - first[0]) < 1e-5 &&
Math.abs(last[1] - first[1]) < 1e-5;
actualPoints.push(...(sameJoin ? seg.slice(1) : seg));
}
});
if (anyActual && actualPoints.length >= 2) {
routes.push(
);
return;
}
}
// ─── PLANNED ROUTE (fallback) ─────────────────────────────────────
// No actual GPS yet (orders still pending, or rider hasn't moved),
// so show the dispatched plan. Same waiting logic as before: don't
// draw the aerial fallback until OSRM has either returned or
// permanently failed — avoids the brief "aerial flash" before the
// road polyline lands.
// Cache values:
// Array → OSRM road polyline (use it)
// false → OSRM permanently failed (draw aerial fallback so user sees something)
// null → request in-flight (DON'T draw anything yet — avoids the aerial flash)
// undefined → not yet requested (same as in-flight, wait)
const hasRoad = Array.isArray(roadPoints) && roadPoints.length >= 2;
const failed = roadPoints === false;
if (!hasRoad && !failed) return; // still loading — don't show aerial flash
const finalPoints = hasRoad ? roadPoints : buildTripPoints(sorted);
if (!finalPoints || finalPoints.length < 2) return;
// Aerial fallback (OSRM permanently failed) is rendered dashed so it visually
// reads as an estimate vs. an actual routed road polyline.
// In Combined mode (planned + actual overlaid on a single map), the
// planned polyline renders dashed so the operator can visually tell
// it apart from the solid actual-GPS polyline drawn for the same step
// in the same step-palette color. OSRM-failed segments stay dashed
// either way — the existing aerial-fallback signal still wins.
const isCompareTargetForDash =
compareOpen && focusedRider && r.id === focusedRider.id;
const combinedPlannedDash =
isCompareTargetForDash && compareViewMode === 'combined' ? '6 5' : undefined;
const dashArray = failed ? '8 6' : combinedPlannedDash;
// Compare mode: recolor the planned polyline per step so the left
// (planned) map's segment for delivery X uses the same STEP_PALETTE
// color as the right (actual) map's GPS polyline for delivery X and
// the timeline dot for X. Without this, the operator can't visually
// link a step on the timeline to its planned segment on the left.
// (isCompareTarget was hoisted above the actual-route attempt.)
if (isCompareTarget) {
const deliveryToStep = new Map(
riderActualTracks.map((t) => [String(t.deliveryid), t.sequenceStep])
);
const validDrops = sorted.filter(hasValidDrop);
const dropCoords = validDrops.map((o) => [
parseFloat(o.droplat || o.deliverylat),
parseFloat(o.droplon || o.deliverylong)
]);
// hasRoad: split the OSRM polyline at each drop's nearest index.
// !hasRoad: finalPoints is [pickup?, drop1, drop2, ...] — buildTripPoints
// only prepends the pickup when one's available. Detect that and
// align segment[i] with validDrops[i] either way.
let segments;
if (hasRoad) {
segments = splitPolylineByDrops(finalPoints, dropCoords);
} else {
const hasPickup = finalPoints.length > dropCoords.length;
segments = [];
for (let i = 0; i < dropCoords.length; i++) {
const idxA = hasPickup ? i : i - 1;
const idxB = hasPickup ? i + 1 : i;
if (
idxA < 0 ||
idxA >= finalPoints.length ||
idxB >= finalPoints.length
) {
segments.push([]);
} else {
segments.push([finalPoints[idxA], finalPoints[idxB]]);
}
}
}
// Combined view rail-offset: shift the planned route +5px
// perpendicular to its direction of travel so it sits *next to*
// the actual GPS polyline (which gets -5px below) instead of
// stacking on the same pixels. When the rider follows the
// dispatched route, the two layers now read as parallel rails
// hugging the same road; when they diverge, the rails fan apart
// and the deviation is unmissable. Planned-only and Actual-only
// modes keep offset = 0 since there's only one layer to draw.
const plannedOffset = compareViewMode === 'combined' ? 5 : 0;
// One halo under the whole trip so crossing roads still read as
// a single planned route. Per-step segments draw on top with their
// step's color.
routes.push(
);
segments.forEach((seg, i) => {
if (!seg || seg.length < 2) return;
const order = validDrops[i];
const sequenceStep = order
? deliveryToStep.get(String(order.deliveryid))
: null;
// Combined view collapses the per-step palette to a single
// indigo so the planned polyline reads as one layer; the
// numbered drop pins (which keep STEP_PALETTE) still carry
// the per-step identity. Planned-only mode keeps per-step
// colors because there's no second layer to distinguish from.
const color = compareViewMode === 'combined'
? COMBINED_PLANNED_COLOR
: (sequenceStep ? stepColor(sequenceStep - 1) : r.color);
const isFocusedThisStep =
focusedCompareStep != null && focusedCompareStep === sequenceStep;
// Focused segment pops; non-focused dim when *some* step is
// focused so the active one stands out. When no step is focused
// every segment renders at full opacity.
const segWeight = isFocusedThisStep ? weight + 1.5 : weight;
const segOpacity = isFocusedThisStep
? 1
: focusedCompareStep
? opacity * 0.5
: opacity;
routes.push(
);
});
return; // skip the default single-color render below
}
// Fell through both the actual-route attempt (no GPS yet) and the
// Compare-target overlay (rider isn't focused in Compare). Draw the
// dispatched plan so the operator sees something while GPS lands.
routes.push(
);
});
});
return routes;
};
const toggleRider = (rid) => {
const newActive = new Set(activeRiders);
if (newActive.has(rid)) newActive.delete(rid);
else newActive.add(rid);
setActiveRiders(newActive);
};
return (
{/* Header right-cluster: total-orders pill, date picker. Sits to the
LEFT of the running clock so the operator sees current wave size
+ selected date together in one row. */}
{shouldFetchLive && (
<>
{liveIsFetching && (
Loading {liveRows.length ? `· ${liveRows.length} loaded` : ''}
)}
{!liveIsFetching && !liveIsError && (
{filteredLiveRows.length} orders
/ {liveRows.length} total
)}
{liveIsError && (
Failed to load
)}
{(() => {
// Date-picker chip + custom calendar popover. Replaces the
// OS-native dialog (which looks different
// on every browser and can't pick up the design system) with
// a single popover that always renders the same way. The
// chip has three regions:
// • prev-day arrow — one-click ±1 day scrubbing
// • center card — opens the calendar popover on click
// • next-day arrow — disabled when viewing today
// The popover itself carries the month grid + quick presets.
const today = dayjs().startOf('day');
const todayStr = today.format('YYYY-MM-DD');
const picked = dayjs(selectedDate);
const isToday = selectedDate === todayStr;
const isFuture = picked.isAfter(today, 'day');
const commitDate = (next) => {
if (!next) return;
const str = next.format('YYYY-MM-DD');
if (str === selectedDate) {
setDatePickerOpen(false);
return;
}
if (next.isAfter(today, 'day')) return; // guard future
setSelectedDate(str);
handleRiderFocus(null);
setFocusedKitchen(null);
setFocusedZone(null);
setDatePickerOpen(false);
};
const goPrevDay = () => commitDate(picked.subtract(1, 'day'));
const goNextDay = () => {
if (isToday || isFuture) return;
commitDate(picked.add(1, 'day'));
};
// Build a fixed 6×7 grid of dayjs instances starting from
// the Sunday before the month's first day. Days outside the
// visible month render as faded "other-month" cells so the
// grid never jumps in height as months change.
const monthStart = calViewMonth.startOf('month');
const gridStart = monthStart.subtract(monthStart.day(), 'day');
const cells = Array.from({ length: 42 }, (_, i) => gridStart.add(i, 'day'));
const prevMonth = () => setCalViewMonth((m) => m.subtract(1, 'month'));
const nextMonth = () => {
const candidate = calViewMonth.add(1, 'month');
// Allow navigating into the current month (so "today" can
// be reached) but not into purely-future months.
if (candidate.startOf('month').isAfter(today, 'month')) return;
setCalViewMonth(candidate);
};
const canGoNextMonth = !calViewMonth.add(1, 'month').startOf('month').isAfter(today, 'month');
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
return (
{datePickerOpen && (
{/* Month header — month/year title flanked by
prev/next arrows. The next-month arrow disables
once we'd cross into a purely-future month so
the operator never lands on a month they can't
pick a date in. */}
Batch
{/* Status-wise (time-field) filter is hidden for now per spec —
bucketing is locked to `assigntime`. Restore this block to bring
back the Delivered/Pending/Assigned/... dropdown.
*/}
{/* Slot editor (Edit slots button + panel) is hidden for now per
spec — the three batches (Morning / Afternoon / Evening) are
fixed. Restore this block to bring back the operator-editable
start/end hours, add-slot, and reset-to-defaults controls.
{/* `key` forces a remount when the rider changes so the
MapContainer re-centers on the new coords (leaflet's
center prop is only read on mount). */}
{/* Permanent banner above the pin — Nominatim
reverse-geocode tells the operator which
suburb/area the rider is in. Falls back to
a "Locating…" hint while the request is in
flight so the pin never looks unlabeled. */}
{riderInfoArea?.area || 'Locating area…'}
{d.username || `Rider #${d.userid}`}
{riderInfoArea?.area && (
{riderInfoArea.area}
)}
{d.logdate ? `Last seen ${d.logdate}` : `${lat.toFixed(6)}, ${lon.toFixed(6)}`}
)}
);
})()}
>
)}
) : (
{compareOpen && focusedRider && (
)}
{/* Sidebar header — replaces the top-bar meta line. Hidden when a specific
rider is focused, since the focused-rider view already shows that rider's
stats prominently (name + Orders/Distance tiles). */}
{!focusedRider && (
RIDER DISPATCH
{activeStats.label}
{activeStats.orders}
{activeStats.orders === 1 ? 'Order' : 'Orders'}
{activeStats.riders}
{activeStats.riders === 1 ? 'Rider' : 'Riders'}
)}
{/* Stats strip hidden for now — restore by removing this comment wrapper.
setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon }) : undefined}
onKeyDown={canFocus ? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setFocusedStop(isStopActive ? null : { orderid: o.orderid, lat, lon });
}
} : undefined}
title={canFocus ? (isStopActive ? 'Click to show full trip' : `Show ${o.deliverycustomer || `order #${o.orderid}`} on map`) : undefined}
>
{o.step || idx + 1}
Order #{o.orderid}
{(() => {
// Stack the status pill and delivery time vertically on
// the right of the header so the operator sees the order
// outcome and the wall-clock time it landed at a glance.
const actual = formatTimeOnly(o.deliverytime);
const expected = formatTimeOnly(o.expecteddeliverytime);
const isDelivered = FINAL_STATUSES.has(String(o.orderstatus || '').toLowerCase());
const showEstDrop = !isDelivered && estMeters !== null;
if (!o.orderstatus && !actual && !expected && !showEstDrop) return null;
return (
{/* Render the kitchen's orders with the same zone-order-card layout
used by the focused-zone view, so By Location, By Zone, and By
Rider all look consistent. Kitchen name is omitted from each
card because the focused kitchen header already provides it. */}
))
}
{renderMarkers()}
{renderRoutes()}
{/* Live rider GPS markers from /partners/getriderlogs/. Mirrors the
Reports → Riders Logs map: green pin when the rider's last log
row is `active`, red otherwise, with the rider's username as a
label. Scoped to riders who actually have orders in the
currently selected slot — `riders` is derived from
filteredLiveRows so it already reflects the slot filter. A
rider with zero orders in the current slot is hidden, even if
getriderlogs still returns their GPS row. When a specific
rider is focused, only that one is shown. */}
{liveRiderLocations
.filter((r) => riders.some((rd) => String(rd.id) === String(r.id)))
.filter((r) => !focusedRider || String(focusedRider.id) === String(r.id))
.map((r) => {
const isActive = r.status === 'active';
const pinColor = isActive ? '#16a34a' : '#dc2626';
// Look up the rider's in-progress order so the popup can show
// where they're heading next (drop customer/area + originating
// kitchen). Falls back to nothing when every order is final.
const matchingRider = riders.find((rd) => String(rd.id) === String(r.id));
const nextOrder = matchingRider?.orders
?.slice()
.sort((a, b) => {
const tA = a.trip_number || 1;
const tB = b.trip_number || 1;
if (tA !== tB) return tA - tB;
return (a.step || 0) - (b.step || 0);
})
.find((o) => {
const s = String(o.orderstatus || '').toLowerCase();
return !FINAL_STATUSES.has(s) && !SKIPPED_STATUSES.has(s);
});
const nextDropArea = nextOrder
? (nextOrder.deliverysuburb || extractArea(nextOrder.deliveryaddress))
: null;
const liveIcon = L.divIcon({
className: '',
iconSize: [140, 56],
iconAnchor: [12, 41],
popupAnchor: [58, -40],
html: `
Last Seen{' '}
{dayjs(r.logdate).isValid() ? dayjs(r.logdate).format('hh:mm:ss A') : r.logdate}
)}
Position
{r.lat.toFixed(5)}, {r.lon.toFixed(5)}
);
})}
{/* Compare mode — actual GPS tracks for the focused rider, drawn
on the same MapContainer as the planned route. Gated by
compareViewMode so the segmented control on the map's top-left
can hide them ("Planned only") or hide the planned polylines
instead ("Actual only"). In Combined mode both render and the
planned polylines switch to a dashed stroke (see renderRoutes)
so the operator can read overlap at a glance. */}
{compareOpen && focusedRider && compareViewMode !== 'planned' && (riderActualTracks.map((t, i) => {
if (t.coords.length === 0) return null;
// `color` drives the drop pin, start pin, and tooltip header so
// those keep their per-step palette identity (the same colors
// the timeline uses). `polylineColor` is what the GPS line
// itself draws with — collapsed to a single emerald in Combined
// view so the actual layer reads as one cohesive trail next to
// the indigo planned rail; Actual-only mode keeps step palette
// on the polyline since there's no second layer to confuse with.
const color = stepColor(i);
const polylineColor = compareViewMode === 'combined'
? COMBINED_ACTUAL_COLOR
: color;
const startPos = [t.coords[0].lat, t.coords[0].lng];
const endPos = [t.coords[t.coords.length - 1].lat, t.coords[t.coords.length - 1].lng];
const snapped = osrmTrackRoutes[t.deliveryid];
const hasRoad = Array.isArray(snapped) && snapped.length >= 2;
const fullPositions = hasRoad
? snapped
: t.coords.map((p) => [p.lat, p.lng]);
let positions = fullPositions;
let drawPolyline = true;
if (isAnimating) {
const progress = animatedActualProgress[t.sequenceStep] || 0;
if (progress < 2) {
drawPolyline = false;
} else {
positions = fullPositions.slice(0, Math.min(progress, fullPositions.length));
}
}
const isFocusedStep = focusedCompareStep === t.sequenceStep;
const statusLow = String(t.orderstatus || '').toLowerCase();
const isDelivered = FINAL_STATUSES.has(statusLow);
const isSkipped = SKIPPED_STATUSES.has(statusLow);
const delta = compareDeltas.find((d) => d.sequenceStep === t.sequenceStep);
const isAnomaly = !!delta?.anomaly;
// Look up the corresponding order in the focused rider's set so
// the actual-track drop pin can render the same rich popup the
// planned-route number marker uses (Timeline, Details, KM chips).
// Matched by deliveryid since order.orderid is not in the track
// payload but deliveryid is the join key for /getdeliverylogs.
const orderForTrack = focusedRider?.orders?.find(
(o) => o.deliveryid != null && String(o.deliveryid) === String(t.deliveryid)
);
const statusStyle = getStatusStyle(t.orderstatus);
const flagSvg = t.orderstatus
? ``
: '';
const dropClasses = ['compare-step-pin'];
if (isFocusedStep) dropClasses.push('is-focused');
if (isDelivered) dropClasses.push('is-delivered');
if (isSkipped) dropClasses.push('is-skipped');
if (isAnomaly) dropClasses.push('is-anomaly');
const dropPinHtml =
`
';
const sequenceIcon = L.divIcon({
className: '',
iconSize: [36, 36],
iconAnchor: [18, 18],
// Lift the popup ~22px above the pin so its tip clears the
// marker icon. Without this the popup centers on the marker,
// its body overlaps the pin, and the cursor moves onto the
// popup → fires mouseout on the marker → popup snaps shut.
popupAnchor: [0, -22],
html: dropPinHtml
});
const showStartMarker = t.sequenceStep === 1;
const startIconEl = showStartMarker
? L.divIcon({
className: '',
iconSize: [40, 40],
iconAnchor: [20, 20],
html:
`
` +
'
'
})
: null;
const handleStepClick = (e) => {
if (e.originalEvent) e.originalEvent.stopPropagation();
setFocusedCompareStep((prev) =>
prev === t.sequenceStep ? null : t.sequenceStep
);
};
// Drop pin click — toggles step focus AND opens the rich order
// popup, so the actual / combined view answers the same "tell me
// about this delivery" question the planned-route number marker
// answers. The pickup (start) pin keeps the focus-only handler;
// there's no per-delivery order modal attached to step 1's
// origin since it's the rider's pickup point, not the drop.
const handleEndMarkerClick = (e) => {
if (e.originalEvent) e.originalEvent.stopPropagation();
setFocusedCompareStep((prev) =>
prev === t.sequenceStep ? null : t.sequenceStep
);
if (orderForTrack && e.target && typeof e.target.openPopup === 'function') {
e.target.openPopup();
}
};
// Combined view rail-offset (negative side): mirror image of
// the +5px shift applied to the planned polyline in
// renderRoutes. With planned at +5 and actual at -5, the two
// layers sit as parallel rails ~10px apart when they share
// a road, so the operator can read both even on tight match.
// Actual-only and Planned-only modes leave offset = 0 since
// there's only one layer drawing on the map.
const actualOffset = compareViewMode === 'combined' ? -5 : 0;
return (
{drawPolyline && (
)}
{drawPolyline && (
)}
{showStartMarker && (
{/* Right-corner rider/kitchen legend hidden per spec — the same
delivered/total count now renders inside the left sidebar's
rider card badge (see renderRiderCard). Restore this block to
bring back the floating top-right chip list.
handleRiderFocus(r)} title={`${delivered} delivered of ${total} total`}>
{r.riderName}{delivered}/{total}
);
})
)}
*/}
{/* Compare-mode second pane — actual GPS tracks per delivery for the
focused rider. Fans out /deliveries/getdeliverylogs/?deliveryid=X
(one query per order, cached by deliveryid) and overlays each
track as a colored polyline + start/end markers. Renders alongside
#map-wrap when compareOpen so the operator can eyeball planned vs.
actual side by side. */}
{compareOpen && focusedRider && (
{/* Step timeline — every delivery as a tappable dot in chronological
order. The operator can drill into any step to scrutinize that
single delivery on both maps. Filled = delivered, ring = pending,
dim = cancelled/skipped, ring with spinner = GPS still loading.
Whole strip (timeline + progress + legend) collapses via the
header chevron — open by default. */}
{compareTimelineOpen && (
<>
{/* Legend strip — one line of icons so the operator instantly knows
what each line/marker means. Lives in the header so it doesn't
compete with the map for vertical real estate. */}
{(() => {
// Two color stories depending on view mode:
// • Combined: polylines collapse to fixed indigo (planned)
// and emerald (actual) so the two overlaid layers can be
// told apart at a glance. Legend swatches mirror this.
// • Planned-only / Actual-only: single layer on the map,
// so polylines keep STEP_PALETTE and the swatch shows
// the focused step's color or a step-gradient strip
// (signals "varies by step").
const isCombined = compareViewMode === 'combined';
const stepSwatchBg = focusedDelta
? stepColor(focusedDelta.sequenceStep - 1)
: `linear-gradient(90deg, ${STEP_PALETTE.slice(0, 6).join(', ')})`;
const plannedSwatchBg = isCombined ? COMBINED_PLANNED_COLOR : stepSwatchBg;
const actualSwatchBg = isCombined ? COMBINED_ACTUAL_COLOR : stepSwatchBg;
return (
Planned (dashed)
Actual GPS (solid)
Kalman-smoothed GPS · OSRM road-snapped
);
})()}
>
)}
);
})()}
)}
{/* Right-side data panel — full-height column in Compare mode. Renders
day-overview tiles (distance, deviation, on-time), the
focused-step delta when a step is selected, a deviations list of
anomaly steps, and a per-step list showing whether each step was
followed correctly (delivered + within plan) and the delivery time
vs expected. Clicking any step row mirrors the timeline click —
sets focusedCompareStep so both maps zoom onto that delivery. */}
{compareOpen && focusedRider && (
setCompareOpen(false)}
/>
)}
)}
{/* Centered order popup — sibling of the map (NOT inside leaflet's
transformed panes) so position: fixed actually pins it to the
viewport. Replaces the marker-attached leaflet Popup so the rich
order card stays fully visible at the screen center on small
laptop displays. */}
{centerPopupOrder && (
{s.travel_to_kitchen_km} km
{s.travel_to_kitchen_minutes} min travel
arrives {s.arrive_at_kitchen}
{s.total_orders_transferred} orders
+{s.extra_km_for_idle_rider} km for idle rider