From 5bf0104b617818cb5f2b6dc7960c489a57a46aa5 Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Mon, 28 Sep 2026 15:36:59 +0530 Subject: [PATCH] updates on the minor changes like zone updates and datepicker fix --- src/components/ds/DatePicker.jsx | 79 +++++++++++++++++--- src/lib/ZoneContext.jsx | 11 ++- src/lib/dateRange.js | 22 ++++++ src/pages/doormile/reports/OrdersDetails.jsx | 66 ++++++++++++++-- src/pages/doormile/reports/OrdersSummary.jsx | 14 +++- tests/ds/datePicker.test.jsx | 69 +++++++++++++++++ tests/lib/ZoneContext.test.jsx | 15 +++- tests/lib/dateRange.test.js | 30 ++++++++ 8 files changed, 284 insertions(+), 22 deletions(-) diff --git a/src/components/ds/DatePicker.jsx b/src/components/ds/DatePicker.jsx index 1cd78eb..5568af3 100644 --- a/src/components/ds/DatePicker.jsx +++ b/src/components/ds/DatePicker.jsx @@ -24,8 +24,27 @@ export function DatePicker({ placeholder = 'Select date', disabled = false, align = 'end', + // Opt-in, so pages that pick ONE day (Orders, Deliveries) are unchanged. + // rangeSelect: the first calendar click sets the start and keeps the + // calendar open; the second sets the end and commits { from, to }. Clicking + // the end before the start is fine — the two are put in order. + rangeSelect = false, + // maxDate ('YYYY-MM-DD'): days after it are greyed out and can't be picked. + maxDate, }) { const [open, setOpen] = useState(false); + // rangeSelect only: the start day picked by the first click, and the day + // under the pointer, so the span between them previews before the 2nd click. + const [pendingFrom, setPendingFrom] = useState(null); + const [hoverDate, setHoverDate] = useState(null); + + const handleOpenChange = (next) => { + setOpen(next); + if (!next) { + setPendingFrom(null); + setHoverDate(null); + } + }; const today = useMemo(() => dayjs().startOf('day'), []); const todayStr = useMemo(() => today.format('YYYY-MM-DD'), [today]); @@ -110,6 +129,28 @@ export function DatePicker({ if (onChange) onChange(nextVal); } finally { setOpen(false); + setPendingFrom(null); + setHoverDate(null); + } + }; + + const isAfterMax = (dateStr) => Boolean(maxDate) && dateStr > maxDate; + + const handleDayClick = (dateStr) => { + if (isAfterMax(dateStr)) return; + if (rangeSelect) { + if (!pendingFrom) { + setPendingFrom(dateStr); + return; + } + const [from, to] = pendingFrom <= dateStr ? [pendingFrom, dateStr] : [dateStr, pendingFrom]; + commit({ from, to }); + return; + } + if (typeof value === 'object' && !parsed.isSingle && value.to) { + commit({ from: dateStr, to: dateStr }); + } else { + commit(dateStr); } }; @@ -147,9 +188,18 @@ export function DatePicker({ const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + // While a range is half-picked, highlight the start → hovered day instead of + // the committed value, so the operator sees what the second click will pick. + const pendingRange = useMemo(() => { + if (!pendingFrom) return null; + const end = hoverDate && !isAfterMax(hoverDate) ? hoverDate : pendingFrom; + return pendingFrom <= end ? { from: pendingFrom, to: end } : { from: end, to: pendingFrom }; + }, [pendingFrom, hoverDate, maxDate]); + const isSelected = (day) => { - if (parsed.isAll) return false; const s = day.format('YYYY-MM-DD'); + if (pendingRange) return s >= pendingRange.from && s <= pendingRange.to; + if (parsed.isAll) return false; if (parsed.from && parsed.to) { return s >= parsed.from && s <= parsed.to; } @@ -157,13 +207,14 @@ export function DatePicker({ }; const isRangeEndpoint = (day) => { - if (parsed.isAll) return false; const s = day.format('YYYY-MM-DD'); + if (pendingRange) return s === pendingRange.from || s === pendingRange.to; + if (parsed.isAll) return false; return s === parsed.from || s === parsed.to; }; return ( - +
{showSteppers && (
+ {rangeSelect && ( +

+ {pendingFrom + ? `From ${dayjs(pendingFrom).format('DD MMM')} — now pick the end date` + : 'Pick a start date, then an end date'} +

+ )} + {/* Quick Presets */}
} > - + setRange(toReportRange(v))} + allowAll={false} + align="start" + rangeSelect + maxDate={dayjs().format('YYYY-MM-DD')} + /> + diff --git a/tests/ds/datePicker.test.jsx b/tests/ds/datePicker.test.jsx index 8bbd3f8..b6d313c 100644 --- a/tests/ds/datePicker.test.jsx +++ b/tests/ds/datePicker.test.jsx @@ -122,3 +122,72 @@ describe('DatePicker — the popover after a choice', () => { expect(screen.queryByText('This Month')).not.toBeInTheDocument(); }); }); + +/** + * rangeSelect (Orders Summary): the first calendar click used to commit a + * single day and close the popover, so an operator could never pick a from + * AND a to date. With rangeSelect the first click only marks the start. + */ +describe('DatePicker — rangeSelect', () => { + // A month well in the past, so every day in it is selectable under maxDate. + const month = today.subtract(2, 'month').startOf('month'); + const day = (n) => month.date(n).format('YYYY-MM-DD'); + const dayButton = (n) => + screen.getAllByRole('button', { name: String(n) }).find((b) => !b.className.includes('text-ink-4/40')); + + const openRangePicker = (onChange, extra = {}) => { + render(); + openCalendar(); + }; + + it('keeps the calendar open after the first click and emits nothing yet', () => { + const onChange = jest.fn(); + openRangePicker(onChange); + fireEvent.click(dayButton(5)); + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByText(/now pick the end date/i)).toBeInTheDocument(); + }); + + it('emits { from, to } on the second click and closes', () => { + const onChange = jest.fn(); + openRangePicker(onChange); + fireEvent.click(dayButton(5)); + fireEvent.click(dayButton(12)); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith({ from: day(5), to: day(12) }); + expect(screen.queryByText(/pick the end date/i)).not.toBeInTheDocument(); + }); + + it('orders the two clicks when the end is picked before the start', () => { + const onChange = jest.fn(); + openRangePicker(onChange); + fireEvent.click(dayButton(20)); + fireEvent.click(dayButton(3)); + expect(onChange).toHaveBeenCalledWith({ from: day(3), to: day(20) }); + }); + + it('greys out and ignores days after maxDate', () => { + const onChange = jest.fn(); + render(); + openCalendar(); + const tomorrow = today.add(1, 'day'); + // Only assert when tomorrow is visible in this month's grid. + const btn = screen + .getAllByRole('button', { name: String(tomorrow.date()) }) + .find((b) => b.disabled); + if (tomorrow.month() === today.month()) { + expect(btn).toBeDefined(); + fireEvent.click(btn); + fireEvent.click(btn); + expect(onChange).not.toHaveBeenCalled(); + } + }); + + it('leaves the default (single-click) pickers unchanged', () => { + const onChange = jest.fn(); + render(); + openCalendar(); + fireEvent.click(screen.getByText('Yesterday')); + expect(onChange).toHaveBeenCalledWith(today.subtract(1, 'day').format('YYYY-MM-DD')); + }); +}); diff --git a/tests/lib/ZoneContext.test.jsx b/tests/lib/ZoneContext.test.jsx index c2e2150..14c75a9 100644 --- a/tests/lib/ZoneContext.test.jsx +++ b/tests/lib/ZoneContext.test.jsx @@ -347,10 +347,19 @@ describe('ZoneContext', () => { expect(result.current.matchesZone({ milerprofileid: 11, hubid: '2' })).toBe(true); }); - it('should match a rider by their app location', () => { + it('should match a rider by their app location (city id against city id)', () => { localStorage.setItem(ZONE_STORAGE_KEY, '2'); - const { result } = setup(); - expect(result.current.matchesZone({ milerprofileid: 11, applocationid: '2' })).toBe(true); + const { result } = setup({ hubs: [{ ...KORAMANGALA, applocationid: '3' }] }); + expect(result.current.matchesZone({ milerprofileid: 11, applocationid: '3' })).toBe(true); + }); + + // Regression: a rider's applocationid (a CITY id) was also compared with the + // selected HUB id. Coimbatore Neptune Hub is hubid 2 in city 1, Hyderabad is + // city 2 — so every Hyderabad rider matched the Coimbatore zone. + it('should not match a rider whose city id merely equals the hub id', () => { + localStorage.setItem(ZONE_STORAGE_KEY, '2'); + const { result } = setup({ hubs: [{ ...KORAMANGALA, applocationid: '1' }] }); + expect(result.current.matchesZone({ milerprofileid: 11, applocationid: '2', hubid: null })).toBe(false); }); it('should match a rider by city when they carry no hub id', () => { diff --git a/tests/lib/dateRange.test.js b/tests/lib/dateRange.test.js index 047c820..02dc3a9 100644 --- a/tests/lib/dateRange.test.js +++ b/tests/lib/dateRange.test.js @@ -52,3 +52,33 @@ describe('toDateRange', () => { expect(toDateRange(true)).toEqual({ from: '', to: '' }); }); }); + +describe('toReportRange — DatePicker value → report { from, to }', () => { + const { toReportRange } = require('@/lib/dateRange'); + const dayjsLib = require('dayjs'); + const todayStr = dayjsLib().format('YYYY-MM-DD'); + const past = (n) => dayjsLib().subtract(n, 'day').format('YYYY-MM-DD'); + const future = (n) => dayjsLib().add(n, 'day').format('YYYY-MM-DD'); + + it('turns a single day into a one-day range', () => { + expect(toReportRange(past(3))).toEqual({ from: past(3), to: past(3) }); + }); + + it('keeps a valid range as it is', () => { + expect(toReportRange({ from: past(7), to: past(1) })).toEqual({ from: past(7), to: past(1) }); + }); + + it('puts an inverted range in order', () => { + expect(toReportRange({ from: past(1), to: past(7) })).toEqual({ from: past(7), to: past(1) }); + }); + + it('never goes past today', () => { + expect(toReportRange(future(2))).toEqual({ from: todayStr, to: todayStr }); + expect(toReportRange({ from: past(2), to: future(5) })).toEqual({ from: past(2), to: todayStr }); + }); + + it('falls back to today for "all" or nothing', () => { + expect(toReportRange('all')).toEqual({ from: todayStr, to: todayStr }); + expect(toReportRange(undefined)).toEqual({ from: todayStr, to: todayStr }); + }); +});