diff --git a/.changeset/route-stop-network-detours.md b/.changeset/route-stop-network-detours.md new file mode 100644 index 000000000..bfdead7cc --- /dev/null +++ b/.changeset/route-stop-network-detours.md @@ -0,0 +1,7 @@ +--- +"@openmapx/core": minor +"@openmapx/i18n": patch +--- + +Add bounded remaining-itinerary network detour evaluation and ordered stop insertion, +explicit entrance fallback, confidence labels, and cancellable provider-pinned requests. diff --git a/apps/web/src/components/navigation/RouteSearchControl.test.tsx b/apps/web/src/components/navigation/RouteSearchControl.test.tsx new file mode 100644 index 000000000..bac19739f --- /dev/null +++ b/apps/web/src/components/navigation/RouteSearchControl.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment jsdom + +import { type AlongRoutePoi, type CategoryPlace, useNavigationStore } from "@openmapx/core"; +import en from "@openmapx/i18n/locales/en.json"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { NextIntlClientProvider } from "next-intl"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const fixture = vi.hoisted(() => ({ + results: [] as AlongRoutePoi[], + addStop: vi.fn(), + cancel: vi.fn(), +})); +vi.mock("@/lib/navigation/useRouteSearch", () => ({ + routeSearchQueryFor: (value: unknown) => value, + useRouteSearch: () => ({ + results: fixture.results, + isLoading: false, + addStop: fixture.addStop, + cancelAddStop: fixture.cancel, + }), +})); +vi.mock("@/integration-api/map/MapContext", () => ({ useMapOptional: () => null })); +vi.mock("@openmapx/core", async (original) => ({ + ...(await original()), + useBrandSuggest: () => ({ data: undefined }), +})); +vi.mock("./RouteSearchResultsLayer", () => ({ + RouteSearchResultsLayer: ({ + results, + onSelect, + }: { + results: AlongRoutePoi[]; + onSelect: (poi: AlongRoutePoi) => void; + }) => ( + + ), +})); + +import { useRouteSearchStore } from "@/lib/navigation/routeSearchStore"; +import { RouteSearchControl } from "./RouteSearchControl"; + +function view() { + return ( + + + + ); +} +const poi = () => + ({ + place: { id: "stop", name: "River services", coordinates: [0.001, 0.001] }, + alongMeters: 100, + deviationMeters: 20, + detourSeconds: 3, + detourMeters: 40, + }) as AlongRoutePoi; +beforeEach(() => { + fixture.results = [poi()]; + fixture.cancel.mockReset(); + fixture.addStop.mockReset(); + useRouteSearchStore.getState().reset(); + useRouteSearchStore.getState().setCategoryKey("preset:amenity/fuel"); +}); +describe("route stop confidence", () => { + it("exits search after its successful add changes the active route", async () => { + let finish!: (value: boolean) => void; + fixture.addStop.mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }), + ); + render(view()); + fireEvent.click(screen.getByText("Select stop")); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + act(() => + useNavigationStore.setState({ + route: { + geometry: [ + [0, 0], + [0.002, 0], + ], + } as never, + }), + ); + await act(async () => finish(true)); + expect(useRouteSearchStore.getState().categoryKey).toBeNull(); + }); + it("cancels a pending request when the selected entrance changes", () => { + fixture.addStop.mockReturnValue(new Promise(() => {})); + const { rerender } = render(view()); + fireEvent.click(screen.getByText("Select stop")); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + fixture.cancel.mockClear(); + fixture.results = [{ ...poi(), place: { ...poi().place, routingEntrance: [0.003, 0] } }]; + rerender(view()); + expect(fixture.cancel).toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Add" }).hasAttribute("disabled")).toBe(false); + }); + it("labels approximation and coordinate fallback", () => { + render(view()); + fireEvent.click(screen.getByText("Select stop")); + expect(screen.getByText(/straight-line estimate/)).toBeTruthy(); + expect(screen.getByText(/entrance unknown/)).toBeTruthy(); + }); + it("updates the selected card and disables unreachable stops", () => { + const { rerender } = render(view()); + fireEvent.click(screen.getByText("Select stop")); + fixture.results = [ + { + ...poi(), + detour: { + id: "stop", + kind: "network", + seconds: 600, + meters: 4000, + provider: "routing-fixture", + access: { kind: "entrance", coordinates: [0.002, 0] }, + waypoints: [], + }, + }, + ]; + rerender(view()); + expect(screen.getByText("+10 min by road")).toBeTruthy(); + expect(screen.getByText("+4.0 km")).toBeTruthy(); + expect(screen.getByText("Routing to known entrance")).toBeTruthy(); + fixture.results = [ + { + ...poi(), + detour: { + id: "stop", + kind: "unreachable", + access: { kind: "coordinate", coordinates: [0.001, 0.001] }, + waypoints: [], + }, + }, + ]; + rerender(view()); + expect(screen.getByText("No route to this stop")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Add" }).hasAttribute("disabled")).toBe(true); + }); + it("cancels a pending selection when the card is dismissed", () => { + fixture.addStop.mockReturnValue(new Promise(() => {})); + render(view()); + fireEvent.click(screen.getByText("Select stop")); + fireEvent.click(screen.getByRole("button", { name: "Add" })); + fixture.cancel.mockClear(); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(fixture.cancel).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/navigation/RouteSearchControl.tsx b/apps/web/src/components/navigation/RouteSearchControl.tsx index 7b14cb538..ecdec6ef6 100644 --- a/apps/web/src/components/navigation/RouteSearchControl.tsx +++ b/apps/web/src/components/navigation/RouteSearchControl.tsx @@ -33,6 +33,7 @@ import { BrandLogo } from "@/components/search/BrandLogo"; import { useMapOptional } from "@/integration-api/map/MapContext"; import { BRAND } from "@/integration-api/runtime/theme"; import { useRouteSearchStore } from "@/lib/navigation/routeSearchStore"; +import { routeStopCopy } from "@/lib/navigation/routeStopCopy"; import { routeSearchQueryFor, useRouteSearch } from "@/lib/navigation/useRouteSearch"; import { RouteSearchResultsLayer } from "./RouteSearchResultsLayer"; @@ -101,8 +102,10 @@ export function RouteSearchControl() { const setBrand = useRouteSearchStore((s) => s.setBrand); const resetStore = useRouteSearchStore((s) => s.reset); const setCameraMode = useNavigationStore((s) => s.setCameraMode); - const [selected, setSelected] = useState | null>(null); + const [selectedId, setSelectedId] = useState(null); const [adding, setAdding] = useState(false); + const [addFailed, setAddFailed] = useState(false); + const selectionIntent = useRef(0); const [brandQuery, setBrandQuery] = useState(""); const debouncedBrandQuery = useDebounce(brandQuery, 200); const { data: brandSuggestData } = useBrandSuggest(debouncedBrandQuery); @@ -119,7 +122,33 @@ export function RouteSearchControl() { : brand ? routeSearchQueryFor({ brand }) : null; - const { results, isLoading, addStop } = useRouteSearch(query); + const { results, isLoading, addStop, cancelAddStop } = useRouteSearch(query); + const selected = results.find((poi) => poi.place.id === selectedId) ?? null; + const copy = selected ? routeStopCopy(selected) : null; + // A new search or route must retire any pending selection in this sheet. + const route = useNavigationStore((s) => s.route); + const session = useNavigationStore((s) => s.navigationStartedAtMs); + useEffect(() => { + void activeKey; + void route; + void session; + selectionIntent.current++; + setSelectedId(null); + setAdding(false); + setAddFailed(false); + cancelAddStop(); + }, [activeKey, route, session, cancelAddStop]); + const selectedTarget = selected?.place.routingEntrance ?? selected?.place.coordinates; + const targetLng = selectedTarget?.[0]; + const targetLat = selectedTarget?.[1]; + useEffect(() => { + void targetLng; + void targetLat; + selectionIntent.current++; + cancelAddStop(); + setAdding(false); + setAddFailed(false); + }, [targetLng, targetLat, cancelAddStop]); const handleSelectBrand = (b: BrandSummary) => { setBrand(b); @@ -174,13 +203,21 @@ export function RouteSearchControl() { }, [searching, results]); const reset = () => { + selectionIntent.current++; + cancelAddStop(); resetStore(); - setSelected(null); + setSelectedId(null); + setAdding(false); + setAddFailed(false); setBrandQuery(""); }; const handleSelect = (poi: AlongRoutePoi) => { - setSelected(poi); + selectionIntent.current++; + cancelAddStop(); + setSelectedId(poi.place.id); + setAdding(false); + setAddFailed(false); mapCtx?.flyTo(poi.place.coordinates, 15); }; @@ -190,10 +227,19 @@ export function RouteSearchControl() { const handleAdd = async () => { if (!selected) return; + const intent = ++selectionIntent.current; + setAddFailed(false); setAdding(true); - const ok = await addStop(selected.place.coordinates); + const ok = await addStop(selected.place); + // Success means this selection committed. Its route change also retires + // the card's intent, so complete search before guarding stale failures. + if (ok) { + reset(); + return; + } + if (intent !== selectionIntent.current) return; setAdding(false); - if (ok) reset(); + setAddFailed(true); }; return ( @@ -378,13 +424,41 @@ export function RouteSearchControl() { {selected.place.name} - - {t("rsDetour", { minutes: Math.max(1, Math.round(selected.detourSeconds / 60)) })} + + {copy && t(copy.key, copy.values)} + {selected.detour?.kind === "network" && ( + + {t("rsDetourDistance", { + kilometers: (selected.detour.meters / 1000).toFixed(1), + })} + + )} + + {t( + selected.detour?.access.kind === "entrance" || selected.place.routingEntrance + ? "rsDetourEntrance" + : "rsDetourCoordinate", + )} + + {addFailed && ( + + {t("rsAddFailed")} + + )} -