diff --git a/.gitignore b/.gitignore index 4c457311..bc88a7e2 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,4 @@ services/vroom/data/ Claude.md .claude/ .cursor/ +Agents.md diff --git a/app/ui/src/app/edit/components/address/AddressCard.tsx b/app/ui/src/app/edit/components/address/AddressCard.tsx index e2b8de64..513eaaf4 100644 --- a/app/ui/src/app/edit/components/address/AddressCard.tsx +++ b/app/ui/src/app/edit/components/address/AddressCard.tsx @@ -10,6 +10,7 @@ import { hasRecipientContact, recipientSummary, } from "@/app/edit/utils/recipientSummary"; +import { formatLockedDeliveryTimeWindow } from "@/app/edit/utils/deliveryHelpers"; import { ADDRESS_ROW_EDIT_ROOT, ADDRESS_ROW_DESKTOP_WRAPPER, @@ -325,9 +326,7 @@ export default function AddressCard({ onClick={() => unlockAddress(a.id)} className={ADDRESS_ROW_LOCKED_CELL_DELIVERY_TIME} > - {a.deliveryTimeStart && a.deliveryTimeEnd - ? `${a.deliveryTimeStart} – ${a.deliveryTimeEnd}` - : a.deliveryTimeStart || a.deliveryTimeEnd || "—"} + {formatLockedDeliveryTimeWindow(a)} {/* Notes — locked */} @@ -692,9 +691,7 @@ export default function AddressCard({ className={MOBILE_ADDR_LOCKED_FIELD_BTN} > - {a.deliveryTimeStart && a.deliveryTimeEnd - ? `${a.deliveryTimeStart} – ${a.deliveryTimeEnd}` - : a.deliveryTimeStart || a.deliveryTimeEnd || "—"} + {formatLockedDeliveryTimeWindow(a)} diff --git a/app/ui/src/app/edit/components/vehicle/VehicleDetailsOverlay.tsx b/app/ui/src/app/edit/components/vehicle/VehicleDetailsOverlay.tsx index bd421a42..9c37a1b3 100644 --- a/app/ui/src/app/edit/components/vehicle/VehicleDetailsOverlay.tsx +++ b/app/ui/src/app/edit/components/vehicle/VehicleDetailsOverlay.tsx @@ -6,6 +6,10 @@ import type { VehicleType, CapacityUnit, } from "@/app/edit/types/delivery"; +import { + getMinutesAfterHourChange, + isValidDepartureHour, +} from "@/app/edit/utils/deliveryHelpers"; import { useFocusTrap } from "@/app/edit/hooks/useFocusTrap"; import FieldError from "@/app/edit/components/shared/FieldError"; import { @@ -81,13 +85,11 @@ const CHEVRON_DOWN_ICON = ( ); function isValidTime(h: string, m: string): boolean { - const hNum = parseInt(h, 10); const mNum = parseInt(m, 10); return ( h.trim() !== "" && m.trim() !== "" && - hNum >= 1 && - hNum <= 12 && + isValidDepartureHour(h) && mNum >= 0 && mNum <= 59 ); @@ -136,6 +138,8 @@ export default function VehicleDetailsOverlay({ const hoursRef = useRef(null); const minutesRef = useRef(null); + const minutesAutoFilledRef = useRef(false); + const focusMinutesAfterRenderRef = useRef(false); const nameError = submitted && !name.trim(); const typeError = submitted && !type; @@ -151,6 +155,13 @@ export default function VehicleDetailsOverlay({ return () => document.removeEventListener("keydown", handleKeyDown); }, [onClose]); + useEffect(() => { + if (!focusMinutesAfterRenderRef.current) return; + + focusMinutesAfterRenderRef.current = false; + minutesRef.current?.focus(); + }, [hours, minutes]); + function handleSave() { setSubmitted(true); if ( @@ -439,8 +450,23 @@ export default function VehicleDetailsOverlay({ const val = e.target.value .replace(/\D/g, "") .slice(0, 2); + const { minutes: nextMinutes, autoFilled } = + getMinutesAfterHourChange( + val, + minutes, + minutesAutoFilledRef.current, + ); + const hoursChanged = val !== hours; + const minutesChanged = nextMinutes !== minutes; setHours(val); - if (val.length === 2) minutesRef.current?.focus(); + minutesAutoFilledRef.current = autoFilled; + setMinutes(nextMinutes); + if ( + val.length === 2 && + (hoursChanged || minutesChanged) + ) { + focusMinutesAfterRenderRef.current = true; + } }} placeholder="HH" maxLength={2} @@ -458,12 +484,16 @@ export default function VehicleDetailsOverlay({ const val = e.target.value .replace(/\D/g, "") .slice(0, 2); + minutesAutoFilledRef.current = false; setMinutes(val); }} onKeyDown={(e) => { if (e.key === "Backspace" && minutes === "") hoursRef.current?.focus(); }} + onFocus={(e) => { + if (minutesAutoFilledRef.current) e.target.select(); + }} placeholder="MM" maxLength={2} inputMode="numeric" diff --git a/app/ui/src/app/edit/utils/deliveryHelpers.ts b/app/ui/src/app/edit/utils/deliveryHelpers.ts index 86fbc4e2..f8613c80 100644 --- a/app/ui/src/app/edit/utils/deliveryHelpers.ts +++ b/app/ui/src/app/edit/utils/deliveryHelpers.ts @@ -15,6 +15,52 @@ export function deliveryTimeFilled( ); } +/** Formats the locked-card delivery time label for one- or two-sided windows. */ +export function formatLockedDeliveryTimeWindow( + a: Pick, +): string { + const start = a.deliveryTimeStart?.trim() ?? ""; + const end = a.deliveryTimeEnd?.trim() ?? ""; + + if (start && end) return `${start} – ${end}`; + if (start) return `From ${start}`; + if (end) return `By ${end}`; + return "—"; +} + +export function isValidDepartureHour(hour: string | number): boolean { + if (typeof hour === "number") return hour >= 1 && hour <= 12; + const trimmed = hour.trim(); + if (!/^\d{1,2}$/.test(trimmed)) return false; + const hourNumber = parseInt(trimmed, 10); + return hourNumber >= 1 && hourNumber <= 12; +} + +/** + * Derives the next minutes value after the departure hour changes, and + * whether that value was auto-filled (vs. user-entered). Callers should use + * `autoFilled` as the single source of truth for tracking auto-fill state + * rather than re-deriving it from the returned minutes. + */ +export function getMinutesAfterHourChange( + hours: string, + currentMinutes: string, + minutesWereAutoFilled = false, +): { minutes: string; autoFilled: boolean } { + if (!isValidDepartureHour(hours)) { + return { + minutes: minutesWereAutoFilled ? "" : currentMinutes, + autoFilled: false, + }; + } + + if (currentMinutes === "" || minutesWereAutoFilled) { + return { minutes: "00", autoFilled: true }; + } + + return { minutes: currentMinutes, autoFilled: false }; +} + /** Capitalise the first letter of a string. Safe on empty strings. */ export function capitalize(s: string): string { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ""; diff --git a/app/ui/src/tests/deliveryHelpers.test.ts b/app/ui/src/tests/deliveryHelpers.test.ts index 9550aebc..90608ce0 100644 --- a/app/ui/src/tests/deliveryHelpers.test.ts +++ b/app/ui/src/tests/deliveryHelpers.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from "vitest"; -import { deliveryTimeFilled } from "@/app/edit/utils/deliveryHelpers"; +import { + deliveryTimeFilled, + formatLockedDeliveryTimeWindow, + isValidDepartureHour, +} from "@/app/edit/utils/deliveryHelpers"; describe("deliveryTimeFilled", () => { it("both empty → false", () => { @@ -35,3 +39,76 @@ describe("deliveryTimeFilled", () => { ).toBe(true); }); }); + +describe("formatLockedDeliveryTimeWindow", () => { + it("both empty → dash", () => { + expect( + formatLockedDeliveryTimeWindow({ + deliveryTimeStart: "", + deliveryTimeEnd: "", + }), + ).toBe("—"); + }); + + it("start + end → unchanged range", () => { + expect( + formatLockedDeliveryTimeWindow({ + deliveryTimeStart: "9:00 AM", + deliveryTimeEnd: "11:00 AM", + }), + ).toBe("9:00 AM – 11:00 AM"); + }); + + it("start only → From label", () => { + expect( + formatLockedDeliveryTimeWindow({ + deliveryTimeStart: "9:00 AM", + deliveryTimeEnd: "", + }), + ).toBe("From 9:00 AM"); + }); + + it("end only → By label", () => { + expect( + formatLockedDeliveryTimeWindow({ + deliveryTimeStart: "", + deliveryTimeEnd: "5:00 PM", + }), + ).toBe("By 5:00 PM"); + }); + + it("trims whitespace around time values", () => { + expect( + formatLockedDeliveryTimeWindow({ + deliveryTimeStart: " 9:00 AM ", + deliveryTimeEnd: " 11:00 AM ", + }), + ).toBe("9:00 AM – 11:00 AM"); + }); +}); + +describe("isValidDepartureHour", () => { + it("accepts plain 1-2 digit hours in range", () => { + expect(isValidDepartureHour("1")).toBe(true); + expect(isValidDepartureHour("9")).toBe(true); + expect(isValidDepartureHour("12")).toBe(true); + expect(isValidDepartureHour(9)).toBe(true); + }); + + it("rejects out-of-range hours", () => { + expect(isValidDepartureHour("0")).toBe(false); + expect(isValidDepartureHour("13")).toBe(false); + expect(isValidDepartureHour(0)).toBe(false); + }); + + it("rejects partially-numeric strings instead of silently truncating them", () => { + expect(isValidDepartureHour("9am")).toBe(false); + expect(isValidDepartureHour("1x")).toBe(false); + expect(isValidDepartureHour("9.5")).toBe(false); + }); + + it("rejects empty or whitespace-only strings", () => { + expect(isValidDepartureHour("")).toBe(false); + expect(isValidDepartureHour(" ")).toBe(false); + }); +}); diff --git a/app/ui/src/tests/vehicleDepartureTime.test.ts b/app/ui/src/tests/vehicleDepartureTime.test.ts new file mode 100644 index 00000000..f5954b70 --- /dev/null +++ b/app/ui/src/tests/vehicleDepartureTime.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { getMinutesAfterHourChange } from "@/app/edit/utils/deliveryHelpers"; + +describe("getMinutesAfterHourChange", () => { + it("defaults empty minutes to 00 and reports auto-fill when the hour is valid", () => { + expect(getMinutesAfterHourChange("9", "")).toEqual({ + minutes: "00", + autoFilled: true, + }); + expect(getMinutesAfterHourChange("12", "")).toEqual({ + minutes: "00", + autoFilled: true, + }); + }); + + it("keeps minutes that the user already entered and reports no auto-fill", () => { + expect(getMinutesAfterHourChange("9", "15")).toEqual({ + minutes: "15", + autoFilled: false, + }); + expect(getMinutesAfterHourChange("12", "30")).toEqual({ + minutes: "30", + autoFilled: false, + }); + }); + + it("leaves minutes empty when the hour is missing or invalid", () => { + expect(getMinutesAfterHourChange("", "")).toEqual({ + minutes: "", + autoFilled: false, + }); + expect(getMinutesAfterHourChange("0", "")).toEqual({ + minutes: "", + autoFilled: false, + }); + expect(getMinutesAfterHourChange("13", "")).toEqual({ + minutes: "", + autoFilled: false, + }); + }); + + it("defaults minutes after a leading-zero hour becomes valid", () => { + expect(getMinutesAfterHourChange("09", "")).toEqual({ + minutes: "00", + autoFilled: true, + }); + }); + + it("clears auto-filled minutes when the hour becomes invalid", () => { + expect(getMinutesAfterHourChange("13", "00", true)).toEqual({ + minutes: "", + autoFilled: false, + }); + }); + + it("preserves user-entered 00 when the hour becomes invalid", () => { + expect(getMinutesAfterHourChange("13", "00", false)).toEqual({ + minutes: "00", + autoFilled: false, + }); + }); + + it("re-auto-fills 00 when minutes were already auto-filled, even if currently 00", () => { + expect(getMinutesAfterHourChange("9", "00", true)).toEqual({ + minutes: "00", + autoFilled: true, + }); + }); +});