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,
+ });
+ });
+});