diff --git a/DECISIONS.md b/DECISIONS.md
index 266025ec..9edfdf93 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -2623,18 +2623,20 @@ follows `NEXT_PUBLIC_MAP_TILE_URL` to a self-hosted server.
## A trip part's place is self-describing, so nothing has to be resolved
`TripPartsField` is built on the same `CreatableCombobox` as `DiveSiteMultiSelect` but holds
-`{name, display_name, latitude, longitude, bbox_*}` objects, the snapshot the API stores, rather
-than uuids, so a row renders from its own content with nothing to fetch and no loading state.
-
-Places therefore have no id. `locationKey` derives one from content:
-`geo:{lat}:{lon}:{display_name}` for a geocoded place, `txt:{name}` lowercased and trimmed for a
-typed one. `mapSearchResults` collapses results sharing a key, since Nominatim can return the same
-place twice, and the row's `value`/`selectedItem` pair is keyed by it. Nothing compares keys across
-parts: two parts may name the same place — Dahab, then Sharm, then back to Dahab — so there is no
-`excludeIds` and no "already in the list" refusal. Rows are keyed and removed by position, or a
-repeat would go as a pair. Position-keyed rows swap content under a focused drag handle, so
-`useDragSort` moves focus to the destination handle (`data-drag-handle`) a frame after a keyboard
-reorder; for id-keyed lists that is a no-op.
+`{name, full_name, latitude, longitude, bbox_*}` objects, the snapshot the API stores, rather than
+uuids, so a row renders from its own content with nothing to fetch and no loading state. A row shows
+the name alone: `name` carries the place's country, and the only string a second line could add is
+the provider's postal chain, which nothing renders.
+
+Places therefore have no id. `locationKey` derives one from content: `geo:{lat}:{lon}:{full_name}`
+for a geocoded place, `txt:{name}` lowercased and trimmed for a typed one. `mapSearchResults`
+collapses results sharing a key, since Nominatim can return the same place twice, and the row's
+`value`/`selectedItem` pair is keyed by it. Nothing compares keys across parts: two parts may name
+the same place — Dahab, then Sharm, then back to Dahab — so there is no `excludeIds` and no "already
+in the list" refusal. Rows are keyed and removed by position, or a repeat would go as a pair.
+Position-keyed rows swap content under a focused drag handle, so `useDragSort` moves focus to the
+destination handle (`data-drag-handle`) a frame after a keyboard reorder; for id-keyed lists that is
+a no-op.
## An unmatched query is addable as text, and that is an outage hatch as much as a long tail
@@ -2673,12 +2675,14 @@ otherwise write the stale name back.
## Coordinates that were picked, not typed, are numbers
-`lib/validations/dive-site.ts` validates coordinates as strings against a regex because a diver
-typing a latitude passes through "-" and "-17." on the way to "-17.9". A trip location's coordinates
+`lib/validations/dive-site.ts` validates a _site's own pin_ as strings against a regex because a
+diver typing a latitude passes through "-" and "-17." on the way to "-17.9". A place's coordinates
arrive whole inside an object picked from a menu, or are absent for a typed-in place; there is no
-half-entered state. So `tripLocationSchema` uses plain `z.number()` with the API's bounds, catching
-a nonsense object before the round trip. It carries no `z.preprocess` or `.transform`, which keeps
-`z.input<>` inference working and `TripLocationFormValue` usable as the form's own type.
+half-entered state, and no field for one. So `locationSchema` in `lib/validations/location.ts` uses
+plain `z.number()` with the API's bounds, catching a nonsense object before the round trip. It
+carries no `z.preprocess` or `.transform`, which keeps `z.input<>` inference working and
+`LocationFormValue` usable as either form's own type — one schema, because a dive site's locality
+and a trip part's are one object.
## The confirmation map is not `MapPicker`, and that is most of why it is short
@@ -2757,17 +2761,25 @@ skeleton's height cannot drift from the map's.
## A "+N" is a promise that hovering will say what N was
-`Moalboal, Bohol +2` compacts a list the payload already holds, so both surfaces carry the full list
-as a `title`.
+`Dahab, Egypt +2` compacts a list the payload already holds, so every surface showing it carries the
+full list as a `title`.
The hint sits on the whole label, not the "+N": a two-character badge is a small hover target and
splits the answer in two.
A hint that repeats the label is worse than none, so `formatTripLocationNamesHint` sits beside
`formatTripLocationNames`, answers `undefined` when nothing is hidden, and decides that under the
-same blank-dropping rule: `["Moalboal", " ", "Bohol"]` under `max: 2` shows no "+N" and gets no
-tooltip. `TripLocationsLabel` owns the limit and calls both, as `DiveSitesLabel` does for dives; the
-trips table and the dashboard card pass locations and a fallback only.
+same blank-dropping rule: `["Moalboal", " "]` under `max: 1` shows no "+N" and gets no tooltip.
+`TripLocationsLabel` calls both, as `DiveSitesLabel` does for dives; the trips table and the
+dashboard card pass locations and a fallback only.
+
+`SHOWN_LOCATIONS` is **one**: a place's name carries its country, so two of "Dahab, Egypt" do not
+fit a table cell. It stays private because every surface that joins a trip's places for a reader —
+the trips table, the dashboard card, the dive sidebar and the trip page's own subtitle — renders
+this component, so there is one number and nothing for a second one to disagree with. Every such
+list separates with `; `, a comma being indistinguishable from the commas inside each name.
+`LocationsMap`'s accessible label takes the separator and not the cap: a cap withholds names from
+the one reader who cannot count the pins.
`title` answers a mouse and nobody else: no hover on touch, unreachable by keyboard on a ``.
The alternative is a `Popover` trigger nested in a link; hover-only stands until the app has a
@@ -3301,39 +3313,43 @@ threaded through three shared primitives.
## A location's full label is trimmed of the name it sits beside, at render time
-Nominatim's `display_name` opens with the name it matched, and surfaces show the name first.
-`formatLocationContext` in `lib/trip-locations.ts` drops the leading parts of the label the name
-repeats, returning `undefined` when nothing is left so callers drop the element with `&&`.
+Nominatim's label opens with the name it matched, and the surface shows the name first.
+`formatLocationContext` in `lib/locations.ts` drops the leading parts of the label the name repeats,
+returning `undefined` when nothing is left so callers drop the element with `&&`.
It aligns whole comma-separated parts, never substrings: "Dahab" is a duplicate in "Dahab, South
Sinai" and context in "Blue Hole, Dahab, South Sinai", and whole parts stop "Ko Tao" eating "Ko Tao
Island".
-It runs at render, not in `geocodeResultToLocation`: `display_name` is stored on the part's place,
-so saved trips would stay untrimmed. It is what the menu's `hint` shows, so the row the diver reads
-is the one that lands in the field a click later. The stored label is the short form; see "The label
-a trip location keeps is the API's short form, chosen on the way in".
+Its one caller is the dive site place search, and both arguments come from a `GeocodeResult`: the
+row's own bare name against the API's composed form, so a row reading "Dahab" carries "Egypt" beside
+it and two same-named results come apart. Nothing renders a _stored_ place's `full_name`, so there
+is no saved shape left to trim — a place's `name` carries its country, and a line under it would be
+the provider's postal chain. See "The label a trip location keeps is the API's short form, chosen on
+the way in".
## The label a trip location keeps is the API's short form, chosen on the way in
-`geocodeResultToLocation` stores `GeocodeResult.location` — the API's `_short_location`, place plus
-country from the provider's structured address — as a trip part's `location.display_name`, not
-Nominatim's `display_name`; a dive log records "Dahab, Egypt", as the dive site form does
-(`dive_site.location`).
+`geocodeResultToLocation` (`lib/locations.ts`) stores `GeocodeResult.location` — the API's
+`_short_location`, place plus country from the provider's structured address — as a place's `name`,
+on a dive site and a trip part alike, rather than Nominatim's `display_name`; a dive log records
+"Dahab, Egypt".
It is received, not derived: the flat string cannot say whether the name is the settlement ("Dahab"
→ "Dahab, Egypt") or sits inside one ("Blue Hole" → "Blue Hole, Dahab, Egypt"); the structured
address can.
-Costs: a place saved before this keeps the provider's label until re-picked, and `locationKey`
-(`geo:{lat}:{lon}:{label}`) keys it differently from a fresh pick of the same place. A trip stops
-matching its region in search: `search_conditions` in `crud_trips.py` ORs the term against a part's
-`display_name`, and `_short_location` composes place or region, never both;
-`test_the_display_name_matches_too` hand-writes its fixture and misses this. Neither repair (a
-second stored field, a geocoder-backed search) is worth it.
+Costs: a place saved before this keeps the provider's label as its name until re-picked, and
+`locationKey` (`geo:{lat}:{lon}:{full_name}`) keys it differently from a fresh pick of the same
+place.
-Menu hints show it too; `ComboboxItem` has only `id`, `name`, `hint`. `placeKey` in
-`place-search.tsx` keys on the provider's label.
+The provider's whole label is stored beside it as `full_name`, because the format asks for the
+fullest form the source held. It is written on every geocoded pick and read by `locationKey`, and no
+surface renders it, so the choice above decides everything a diver sees. It also carries the region
+`_short_location` omits, which is what the API matches a search term against alongside the name.
+
+The dive site place search's menu hint shows the short form too; `ComboboxItem` has only `id`,
+`name`, `hint`. `placeKey` in `place-search.tsx` keys on the provider's label.
## The geocoder's attribution is a wire format, not display copy
@@ -3371,6 +3387,14 @@ A position arrives three ways — pin, place search, pasted latitude/longitude p
`DiveSiteDialog` sees all three, so the reverse geocode and its guards live in
`hooks/useGeocodedLocation.ts`; `DiveSiteMapField` renders search, map and credit.
+**A reverse-geocoded place is a name and nothing else**, where a forward search fills the whole
+place. The coordinates that come back are the _site's_ — it is the pin the diver just dropped that
+was looked up — so taking them would file the wreck as the centre of the town around it, on the one
+path where the two points are identical and nothing on screen could show it. The Location field
+holds the whole object and shows its name, so a save sends back the fuller name, the centre and the
+extent a pick brought with it; typing a name replaces the place outright, and clearing the box sends
+an explicit `null`.
+
The guards — newest request wins, the reply checked against the fields as they stand on arrival,
`unknown` never clearing a field while `nameless` does, a nameless answer silent with nothing to
clear — are reasoned in "The map writes into the coordinate fields".
@@ -5171,12 +5195,13 @@ come first; the `hint` slot, not a new `CreatableCombobox` prop, marks which is
A pick is `{ kind: "catalog", site }` or `{ kind: "geocode", result }`, and `DiveSiteDialog` forks
on the tag, not on the namespaced row id. A catalog pick always fills Name; a geocoded one does not.
-Location is `region, country`, never an ISO code; where neither resolved the field stays as typed,
-so `adopt` takes `AdoptedPlace | null`. `suggestDiveSites` guards its own query length because the
-combobox calls `onSearch` with `""` on open. Distance is computed here (`haversineMeters`,
-`formatDistance`) so the unit preference holds. Catalog `attribution` joins the search credit, never
-the map's. `DiveSiteMapField` passes the form's position whole or not at all; the endpoint answers
-422 to half.
+Location is a place named `region, country`, never an ISO code, and nothing else — the record's
+coordinates are the site's, and the catalog resolved no centre or extent for the region it names.
+Where neither resolved, the field stays as it was, so `adopt` takes `AdoptedPlace | null`.
+`suggestDiveSites` guards its own query length because the combobox calls `onSearch` with `""` on
+open. Distance is computed here (`haversineMeters`, `formatDistance`) so the unit preference holds.
+Catalog `attribution` joins the search credit, never the map's. `DiveSiteMapField` passes the form's
+position whole or not at all; the endpoint answers 422 to half.
## A refused save has to be announced, and `role="alert"` alone does not do it
diff --git a/src/app/dives/page.tsx b/src/app/dives/page.tsx
index ec8d5707..e055fda3 100644
--- a/src/app/dives/page.tsx
+++ b/src/app/dives/page.tsx
@@ -124,7 +124,7 @@ export default function DivesPage() {
{formatDiveDateTime(dive.start_time)}
{/* The primary site's location - the site the first column names. */}
- {dive.dive_sites[0]?.location ?? "-"}
+ {dive.dive_sites[0]?.location?.name ?? "-"}
{formatDurationHoursMinutes(dive.duration)}
diff --git a/src/app/sites/[id]/page.tsx b/src/app/sites/[id]/page.tsx
index 8e2fa0c0..e2b4953c 100644
--- a/src/app/sites/[id]/page.tsx
+++ b/src/app/sites/[id]/page.tsx
@@ -88,7 +88,7 @@ export default function DiveSiteDetailPage() {
backHref="/sites"
backLabel="Back to dive sites"
title={diveSite.name}
- subtitle={diveSite.location ? diveSite.location : undefined}
+ subtitle={diveSite.location?.name ?? undefined}
actions={
<>
- {diveSite.location || "-"}
+ {diveSite.location?.name || "-"}
{/* Named per row, not per action: ten identical "Edit"s tell a
screen reader's controls list nothing about which site.
diff --git a/src/app/trips/[id]/page.tsx b/src/app/trips/[id]/page.tsx
index 43593622..1636c879 100644
--- a/src/app/trips/[id]/page.tsx
+++ b/src/app/trips/[id]/page.tsx
@@ -7,10 +7,8 @@ import { useResource } from "@/hooks/useResource";
import { useDeleteResource } from "@/hooks/useDeleteResource";
import { tripsAPI, Trip } from "@/lib/api/trips";
import { formatDateTime, formatTripDateRange } from "@/lib/date-time";
-import {
- formatLocationContext,
- formatTripLocationNames,
-} from "@/lib/trip-locations";
+import { formatTripLocationNames } from "@/lib/trip-locations";
+import { TripLocationsLabel } from "@/components/trips/trip-locations-label";
import { formatTripSpan, tripPartLocations } from "@/lib/trip-parts";
import { RecentDivesCard } from "@/components/dives/recent-dives-card";
import { Button } from "@/components/ui/button";
@@ -65,6 +63,10 @@ export default function TripDetailPage() {
const tripDateRange = formatTripSpan(tripParts, LONG_DATE);
const tripLocations = tripPartLocations(tripParts);
+ // Only whether there is a place to name, which is what decides the separator
+ // below. The subtitle's own text and hover hint come from rendering
+ // `TripLocationsLabel`, so the cap lives there with the trips table's and the
+ // dashboard card's rather than being passed a second time from here.
const tripLocationNames = formatTripLocationNames(tripLocations);
// Only places the geocoder gave a position to can be drawn; the parts below
// list all of them either way, so a typed-in place isn't silently dropped.
@@ -103,9 +105,13 @@ export default function TripDetailPage() {
backLabel="Back to trips"
title={trip.name}
subtitle={
- tripLocationNames && tripDateRange
- ? `${tripDateRange} · ${tripLocationNames}`
- : (tripLocationNames ?? tripDateRange ?? undefined)
+ tripLocationNames || tripDateRange ? (
+ <>
+ {tripDateRange}
+ {tripDateRange && tripLocationNames ? " · " : null}
+
+ >
+ ) : undefined
}
actions={
<>
@@ -182,19 +188,13 @@ export default function TripDetailPage() {
{tripParts.length > 1 ? "Parts" : "Part"}
{/* One row per part, in the order the diver arranged them,
- rather than the joined line the header and the trips table
- show: this is the one surface with room to put the country
- under the name and the part's own dates beneath that. A
- part with no place is still a row - it is a stretch of the
- trip, and dropping it would renumber the rest. */}
+ rather than the capped joined line the header and the trips
+ table show: this is the one surface with room to name every
+ place and put the part's own dates beneath each. A part
+ with no place is still a row - it is a stretch of the trip,
+ and dropping it would renumber the rest. */}
{tripParts.map((part, index) => {
- // The label with the name above it trimmed off its front,
- // so the two lines don't read "Dahab" over "Dahab,
- // Egypt".
- const context = part.location
- ? formatLocationContext(part.location)
- : undefined;
const dates = formatTripDateRange(
part.start_date ?? undefined,
part.end_date ?? undefined,
@@ -213,11 +213,6 @@ export default function TripDetailPage() {
)}
- {context && (
-
- {context}
-
- )}
{dates && (
{dates}
diff --git a/src/components/dives/delete-with-reassign-dialog.render.test.tsx b/src/components/dives/delete-with-reassign-dialog.render.test.tsx
index 27222e85..e4466392 100644
--- a/src/components/dives/delete-with-reassign-dialog.render.test.tsx
+++ b/src/components/dives/delete-with-reassign-dialog.render.test.tsx
@@ -20,7 +20,7 @@ const OTHER_TRIP = { uuid: "trip-2", name: "Cebu 2026" };
const OTHER_SITE = {
uuid: "site-2",
name: "Blue Hole",
- location: "Dahab, Egypt",
+ location: { name: "Dahab, Egypt" },
};
const onePageOf = (data: unknown[]) =>
diff --git a/src/components/dives/delete-with-reassign-dialog.tsx b/src/components/dives/delete-with-reassign-dialog.tsx
index b0e508fc..144db441 100644
--- a/src/components/dives/delete-with-reassign-dialog.tsx
+++ b/src/components/dives/delete-with-reassign-dialog.tsx
@@ -82,7 +82,7 @@ const COPY: Record = {
items: response.data.map((site) => ({
id: site.uuid,
name: site.name,
- hint: site.location,
+ hint: site.location?.name,
})),
hasMore: response.has_more,
};
diff --git a/src/components/dives/dive-detail-sidebar.tsx b/src/components/dives/dive-detail-sidebar.tsx
index 964fe518..763d3774 100644
--- a/src/components/dives/dive-detail-sidebar.tsx
+++ b/src/components/dives/dive-detail-sidebar.tsx
@@ -161,9 +161,9 @@ export function DiveDetailSidebar({
linked
className="font-medium"
/>
- {dive.dive_sites[0]?.location && (
+ {dive.dive_sites[0]?.location?.name && (
- {dive.dive_sites[0].location}
+ {dive.dive_sites[0].location.name}
)}
diff --git a/src/components/dives/dive-site-multi-select.render.test.tsx b/src/components/dives/dive-site-multi-select.render.test.tsx
index 8a770341..d8c8784d 100644
--- a/src/components/dives/dive-site-multi-select.render.test.tsx
+++ b/src/components/dives/dive-site-multi-select.render.test.tsx
@@ -18,11 +18,15 @@ vi.mock("@/lib/api/dive-sites", () => ({
const { diveSitesAPI } = await import("@/lib/api/dive-sites");
const getDiveSites = vi.mocked(diveSitesAPI.getDiveSites);
-const SITE = { uuid: "site-1", name: "Blue Hole", location: "Dahab, Egypt" };
+const SITE = {
+ uuid: "site-1",
+ name: "Blue Hole",
+ location: { name: "Dahab, Egypt" },
+};
const SECOND_SITE = {
uuid: "site-2",
name: "Thistlegorm",
- location: "Red Sea, Egypt",
+ location: { name: "Red Sea, Egypt" },
};
beforeEach(() => {
diff --git a/src/components/dives/dive-site-multi-select.tsx b/src/components/dives/dive-site-multi-select.tsx
index f1b4d6e9..e430b19a 100644
--- a/src/components/dives/dive-site-multi-select.tsx
+++ b/src/components/dives/dive-site-multi-select.tsx
@@ -117,7 +117,7 @@ export function DiveSiteMultiSelect({
items: response.data.map((site) => ({
id: site.uuid,
name: site.name,
- hint: site.location,
+ hint: site.location?.name,
})),
hasMore: response.has_more,
};
@@ -202,9 +202,9 @@ export function DiveSiteMultiSelect({
)}
{label}
- {site?.location && (
+ {site?.location?.name && (
- , {site.location}
+ , {site.location.name}
)}
diff --git a/src/components/dives/dive-sites-label.render.test.tsx b/src/components/dives/dive-sites-label.render.test.tsx
index d717e889..03ccf731 100644
--- a/src/components/dives/dive-sites-label.render.test.tsx
+++ b/src/components/dives/dive-sites-label.render.test.tsx
@@ -8,7 +8,11 @@ import { DiveSitesLabel } from "./dive-sites-label";
// shows.
const sites = [
- { uuid: "a", name: "Pescador Island", location: "Moalboal, Philippines" },
+ {
+ uuid: "a",
+ name: "Pescador Island",
+ location: { name: "Moalboal, Philippines" },
+ },
{ uuid: "b", name: "Panagsama Wall" },
{ uuid: "c", name: "Tongo Point" },
];
diff --git a/src/components/dives/recent-dives-card.tsx b/src/components/dives/recent-dives-card.tsx
index 3b61a278..d07283cd 100644
--- a/src/components/dives/recent-dives-card.tsx
+++ b/src/components/dives/recent-dives-card.tsx
@@ -199,10 +199,10 @@ export function RecentDivesCard({
{/* The title names the site; this line says where it
is. */}
- {dive.dive_sites[0]?.location && (
+ {dive.dive_sites[0]?.location?.name && (
{" \u00b7 "}
- {dive.dive_sites[0].location}
+ {dive.dive_sites[0].location.name}
)}
diff --git a/src/components/map/locations-map.browser.test.tsx b/src/components/map/locations-map.browser.test.tsx
index d2f46e4d..1870481b 100644
--- a/src/components/map/locations-map.browser.test.tsx
+++ b/src/components/map/locations-map.browser.test.tsx
@@ -107,7 +107,7 @@ describe("LocationsMap", () => {
);
await waitFor(() => expect(markers().length).toBe(2));
expect(screen.getByRole("img").getAttribute("aria-label")).toBe(
- "Map of Moalboal, Bohol",
+ "Map of Moalboal; Bohol",
);
});
@@ -145,7 +145,7 @@ describe("LocationsMap", () => {
// And its name is a name like any other, so it reaches the label.
expect(screen.getByRole("img").getAttribute("aria-label")).toBe(
- "Map of Blue Hole, Exit",
+ "Map of Blue Hole; Exit",
);
});
diff --git a/src/components/map/locations-map.tsx b/src/components/map/locations-map.tsx
index 2ac022f5..b8d4f7ca 100644
--- a/src/components/map/locations-map.tsx
+++ b/src/components/map/locations-map.tsx
@@ -280,6 +280,11 @@ export function LocationsMap({
// " reads as a bug to anyone hearing it - hence the caller's `subject` as the
// fallback. `formatTripLocationNames` is the same joining rule the trip's own
// header uses, and it drops the blanks.
+ //
+ // Uncapped, unlike every surface that is looked at: a cap withholds names
+ // from a reader who cannot see the pins, which is the one reader this label
+ // exists for. The separator is shared with those surfaces, because it is
+ // about telling one place from the next and withholds nothing.
const names = formatTripLocationNames(placed);
// The empty frame says what it is rather than borrowing the label of the
// places it doesn't have: "Map of the trip's locations" over a blank world is
diff --git a/src/components/sites/dive-site-dialog.render.test.tsx b/src/components/sites/dive-site-dialog.render.test.tsx
index 5e6b2cb0..8bf3db9f 100644
--- a/src/components/sites/dive-site-dialog.render.test.tsx
+++ b/src/components/sites/dive-site-dialog.render.test.tsx
@@ -40,6 +40,7 @@ const { geocodingAPI } = await import("@/lib/api/geocoding");
const reverseGeocode = vi.mocked(geocodingAPI.reverseGeocode);
const { diveSitesAPI } = await import("@/lib/api/dive-sites");
const createDiveSite = vi.mocked(diveSitesAPI.createDiveSite);
+const updateDiveSite = vi.mocked(diveSitesAPI.updateDiveSite);
const { diveSiteCatalogAPI } = await import("@/lib/api/dive-site-catalog");
const suggestDiveSites = vi.mocked(diveSiteCatalogAPI.suggestDiveSites);
@@ -400,3 +401,111 @@ describe("DiveSiteDialog API refusal", () => {
expect(name.value).toBe("Diving Spot (west)");
});
});
+
+// The Location field holds a whole place and shows only its name, so what the
+// field looks like says nothing about what a save sends. Only a save does, and
+// there is no way back from one: the migration that made the member an object
+// does not run twice.
+describe("DiveSiteDialog location writes", () => {
+ const PICKED = {
+ uuid: "site-1",
+ name: "Blue Hole",
+ location: {
+ name: "Dahab, Egypt",
+ full_name: "Dahab, South Sinai, 45214, Egypt",
+ latitude: 28.4949,
+ longitude: 34.5136,
+ bbox_south: 28.45,
+ bbox_north: 28.54,
+ bbox_west: 34.47,
+ bbox_east: 34.55,
+ },
+ latitude: 28.5717,
+ longitude: 34.5372,
+ notes: "",
+ user_uuid: "user-1",
+ created_at: "2026-01-01T00:00:00Z",
+ };
+
+ const renderEdit = () =>
+ render(
+ {}}
+ diveSite={PICKED}
+ onSaved={() => {}}
+ />,
+ );
+
+ const save = () =>
+ userEvent.click(screen.getByRole("button", { name: /Save changes/ }));
+
+ beforeEach(() => {
+ updateDiveSite.mockReset();
+ updateDiveSite.mockResolvedValue({ message: "ok" });
+ });
+
+ it("sends the whole place back when only the site's name changed", async () => {
+ // The field shows "Dahab, Egypt" and nothing else, so a control bound to
+ // that string alone would post a place carrying only a name - silently
+ // dropping the full name, the locality's centre and its extent on every
+ // edit of every site anyone has ever picked a place for.
+ renderEdit();
+ const name = screen.getByLabelText("Name *") as HTMLInputElement;
+ await userEvent.clear(name);
+ await userEvent.type(name, "Blue Hole (north entry)");
+ await save();
+
+ await waitFor(() => expect(updateDiveSite).toHaveBeenCalled());
+ expect(updateDiveSite.mock.calls[0][1]).toMatchObject({
+ name: "Blue Hole (north entry)",
+ location: PICKED.location,
+ });
+ });
+
+ it("clears the place with an explicit null, not an empty name", async () => {
+ // How a site entered with the wrong locality is corrected back to "not
+ // recorded". An omitted member would leave the old one in place, and a
+ // place named "" is a place whose name is not one.
+ renderEdit();
+ await userEvent.clear(screen.getByLabelText("Location"));
+ await save();
+
+ await waitFor(() => expect(updateDiveSite).toHaveBeenCalled());
+ expect(updateDiveSite.mock.calls[0][1].location).toBeNull();
+ });
+
+ it("stops a typed name at the width the API stores", async () => {
+ // Not left to the resolver: the failure would land at `location.name`,
+ // where `FormMessage` reads `errors.location` and finds a container with
+ // no message - the word "undefined" in red, over a save that stopped.
+ renderEdit();
+ const location = screen.getByLabelText("Location") as HTMLInputElement;
+ expect(location.maxLength).toBe(255);
+
+ await userEvent.clear(location);
+ await userEvent.paste("a".repeat(300));
+ await save();
+
+ await waitFor(() => expect(updateDiveSite).toHaveBeenCalled());
+ expect(updateDiveSite.mock.calls[0][1].location).toEqual({
+ name: "a".repeat(255),
+ });
+ });
+
+ it("replaces the place outright when a new name is typed over it", async () => {
+ // A full name, a centre and an extent resolved for Dahab say nothing true
+ // about Moalboal, so they go with the name they belonged to rather than
+ // being carried over onto it.
+ renderEdit();
+ const location = screen.getByLabelText("Location");
+ await userEvent.clear(location);
+ await userEvent.type(location, "Moalboal, Philippines");
+ await save();
+
+ await waitFor(() => expect(updateDiveSite).toHaveBeenCalled());
+ expect(updateDiveSite.mock.calls[0][1].location).toEqual({
+ name: "Moalboal, Philippines",
+ });
+ });
+});
diff --git a/src/components/sites/dive-site-dialog.tsx b/src/components/sites/dive-site-dialog.tsx
index bf647ac6..d0ee895a 100644
--- a/src/components/sites/dive-site-dialog.tsx
+++ b/src/components/sites/dive-site-dialog.tsx
@@ -15,6 +15,11 @@ import {
} from "@/lib/validations/dive-site";
import { diveSitesAPI, DiveSite } from "@/lib/api/dive-sites";
import { GeocodeResult } from "@/lib/api/geocoding";
+import { geocodeResultToLocation } from "@/lib/locations";
+import {
+ MAX_LOCATION_NAME_LENGTH,
+ type LocationFormValue,
+} from "@/lib/validations/location";
import {
diveSitePlaceContext,
DiveSiteSuggestion,
@@ -77,7 +82,7 @@ export function DiveSiteDialog({
resolver: zodResolver(diveSiteFormSchema),
defaultValues: {
name: "",
- location: "",
+ location: null,
latitude: "",
longitude: "",
notes: "",
@@ -92,7 +97,9 @@ export function DiveSiteDialog({
if (!open) return;
reset({
name: diveSite?.name ?? "",
- location: diveSite?.location ?? "",
+ // The whole place, so that saving an edit that never touched this field
+ // sends back the full name, the centre and the box it was seeded with.
+ location: diveSite?.location ?? null,
latitude: formatCoordinateForForm(diveSite?.latitude),
longitude: formatCoordinateForForm(diveSite?.longitude),
notes: diveSite?.notes ?? "",
@@ -118,9 +125,9 @@ export function DiveSiteDialog({
// Owned here rather than by the map, because all three ways of placing a
// position have to reach the same lookup and only one of them comes through
- // the map. The geocoded place name is written straight into the Location
- // field; it stays an ordinary text input, so a diver who wants something else
- // types over it.
+ // the map. The place it answers with is written straight into the Location
+ // field; the field's visible half stays an ordinary text input, so a diver who
+ // wants something else types over it.
const geocoded = useGeocodedLocation({
open,
latitude: watchedLatitude,
@@ -130,6 +137,17 @@ export function DiveSiteDialog({
setValue("location", value, { shouldValidate: true, shouldDirty: true }),
});
+ // What the diver types is a place's name and nothing more, so it replaces
+ // whatever was there rather than renaming it: a full name, a centre and a box
+ // picked for "Dahab, Egypt" say nothing true about the "Moalboal" now in the
+ // box. Emptying the field clears the place outright, which is how a site
+ // entered with the wrong locality is corrected back to "not recorded".
+ const typeLocationName = (text: string) =>
+ setValue("location", text.trim() ? { name: text } : null, {
+ shouldValidate: true,
+ shouldDirty: true,
+ });
+
// The map, the search and the paste handler all write into the same two
// fields rather than holding a position of their own, so there is one source
// of truth and the pair stays typeable, pasteable and clearable exactly as
@@ -156,13 +174,21 @@ export function DiveSiteDialog({
// A geocoded place fills the coordinate pair and the Location beside it. It
// has nothing to say about what the *site* is called - the geocoder knows
// where Dahab is, not that there is a Blue Hole in it - so Name is left alone.
+ //
+ // The whole place goes into Location, centre and box included: a forward
+ // search answered about the place itself, so its coordinates are the
+ // locality's own and not the pin this pick is about to drop. The pin lands on
+ // the same point here only because there is nothing better to put it on yet.
const placeResult = (result: GeocodeResult) => {
const position = {
latitude: formatCoordinateForForm(result.latitude),
longitude: formatCoordinateForForm(result.longitude),
};
setPosition(position);
- geocoded.adopt(position, result);
+ geocoded.adopt(position, {
+ location: geocodeResultToLocation(result),
+ attribution: result.attribution,
+ });
};
// A catalog dive site fills Name as well, and always - a diver who wanted
@@ -177,6 +203,10 @@ export function DiveSiteDialog({
// untouched - a site with no place context is not an answer about where the
// site is, and clearing what a diver typed on the strength of one would be the
// mistake `useGeocodedLocation` already refuses for an `unknown` lookup.
+ //
+ // A name and nothing else, because that is all the record holds: its
+ // coordinates are the *site's*, and the catalog never resolved a centre or an
+ // extent for the region it names.
const placeCatalogSite = (site: DiveSiteSuggestion) => {
const position = {
latitude: formatCoordinateForForm(site.latitude),
@@ -184,10 +214,12 @@ export function DiveSiteDialog({
};
setValue("name", site.name, { shouldValidate: true, shouldDirty: true });
setPosition(position);
- const location = diveSitePlaceContext(site);
+ const place = diveSitePlaceContext(site);
geocoded.adopt(
position,
- location ? { location, attribution: site.attribution } : null,
+ place
+ ? { location: { name: place }, attribution: site.attribution }
+ : null,
);
};
@@ -226,12 +258,18 @@ export function DiveSiteDialog({
const latitude = parseFormCoordinate(data.latitude);
const longitude = parseFormCoordinate(data.longitude);
+ // The whole place, or an explicit null. Naming it replaces the stored one
+ // wholesale, which is why the field carries the object it was seeded with
+ // rather than a text box over its name; null is what clears a locality
+ // that was entered wrongly.
+ const location = data.location ?? null;
+
if (diveSite) {
// The API answers a PATCH with just a status message, so the updated
// dive site is assembled here for the caller.
const update = {
name: data.name,
- location: data.location,
+ location,
latitude,
longitude,
notes: data.notes,
@@ -241,7 +279,7 @@ export function DiveSiteDialog({
} else {
const created = await diveSitesAPI.createDiveSite({
name: data.name,
- location: data.location || undefined,
+ location,
latitude,
longitude,
notes: data.notes || undefined,
@@ -292,6 +330,11 @@ export function DiveSiteDialog({
)}
/>
+ {/* The field holds the whole place; the input is a view of its
+ name. What a pick brought with it - the fuller form the lookup
+ returned, the locality's own centre, its extent - rides along
+ unseen and is sent back on every save, so editing a site's name
+ does not quietly strip the place off it. */}
Location
-
+ typeLocationName(event.target.value)}
+ />
diff --git a/src/components/sites/place-search.tsx b/src/components/sites/place-search.tsx
index 2c9d354c..6ee2ce14 100644
--- a/src/components/sites/place-search.tsx
+++ b/src/components/sites/place-search.tsx
@@ -21,7 +21,7 @@ import {
MAX_SITE_QUERY_LENGTH,
MIN_SITE_QUERY_LENGTH,
} from "@/lib/api/dive-site-catalog";
-import { formatLocationContext } from "@/lib/trip-locations";
+import { formatLocationContext } from "@/lib/locations";
import { formatDistance, GeoPoint, haversineMeters } from "@/lib/geo-distance";
import { useUnits } from "@/hooks/useUnits";
import type { UnitSystem } from "@/lib/units";
@@ -234,7 +234,7 @@ export function PlaceSearch({ onPick, position, disabled }: PlaceSearchProps) {
// and the form say the same thing.
hint: formatLocationContext({
name,
- display_name: result.location,
+ full_name: result.location,
}),
},
);
diff --git a/src/components/trips/trip-locations-label.render.test.tsx b/src/components/trips/trip-locations-label.render.test.tsx
index abc4ddbe..49f2e8f3 100644
--- a/src/components/trips/trip-locations-label.render.test.tsx
+++ b/src/components/trips/trip-locations-label.render.test.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { TripLocationsLabel } from "./trip-locations-label";
-import type { TripLocation } from "@/lib/api/trips";
+import type { Location } from "@/lib/api/location";
// The joining and the "+N" are `lib/trip-locations.ts`'s and are tested there. What a
// render adds is the wiring the two surfaces share: that the hint lands on the element
@@ -9,7 +9,7 @@ import type { TripLocation } from "@/lib/api/trips";
// the "+N" - the drift this component exists to make impossible - and that a trip with
// nothing to say falls back rather than rendering an empty label.
-const at = (name: string) => ({ name }) as TripLocation;
+const at = (name: string) => ({ name }) as Location;
describe("TripLocationsLabel", () => {
it("hints every name when the label compacted some away", () => {
@@ -19,18 +19,20 @@ describe("TripLocationsLabel", () => {
/>,
);
- const hinted = screen.getByTitle("Moalboal, Panglao, Malapascua");
- expect(hinted).toHaveTextContent("Moalboal, Panglao +1");
+ const hinted = screen.getByTitle("Moalboal; Panglao; Malapascua");
+ expect(hinted).toHaveTextContent("Moalboal +2");
});
it("leaves a fully shown label untitled", () => {
// A tooltip repeating the text under the cursor is worse than no tooltip.
+ // One place is the whole of a fully shown label now: a place's own name
+ // carries its country, so two of them do not fit a table cell.
const { container } = render(
- ,
+ ,
);
expect(container.querySelector("[title]")).toBeNull();
- expect(screen.getByText("Moalboal, Panglao")).toBeInTheDocument();
+ expect(screen.getByText("Dahab, Egypt")).toBeInTheDocument();
});
it("renders the fallback for a trip with no usable locations", () => {
@@ -58,7 +60,7 @@ describe("TripLocationsLabel", () => {
/>,
);
- expect(screen.getByTitle("Moalboal, Panglao, Malapascua")).toHaveClass(
+ expect(screen.getByTitle("Moalboal; Panglao; Malapascua")).toHaveClass(
"block",
"text-sm",
);
diff --git a/src/components/trips/trip-locations-label.tsx b/src/components/trips/trip-locations-label.tsx
index 6b8b5eb3..c560214c 100644
--- a/src/components/trips/trip-locations-label.tsx
+++ b/src/components/trips/trip-locations-label.tsx
@@ -1,24 +1,28 @@
import { ReactNode } from "react";
-import { TripLocation } from "@/lib/api/trips";
+import { Location } from "@/lib/api/location";
import {
formatTripLocationNames,
formatTripLocationNamesHint,
} from "@/lib/trip-locations";
-// How many names a compact surface shows before the rest become "+N". Two fits a
-// table cell and still names more than one place, which is the whole reason a trip
-// carries a list.
-const SHOWN_LOCATIONS = 2;
+// How many names a compact surface shows before the rest become "+N". One,
+// because a place's own name carries its country - two of "Dahab, Egypt" do not
+// fit a table cell, and the hover hint is what keeps the count honest.
+//
+// Private, as the limit for every surface that joins a trip's places for a
+// reader: each of them renders this component rather than joining the names
+// itself, so there is one number and nothing for a second one to disagree with.
+const SHOWN_LOCATIONS = 1;
export interface TripLocationsLabelProps {
- locations?: TripLocation[] | null;
+ locations?: Location[] | null;
className?: string;
// What to render for a trip with no usable locations. A table wants "-"; a
// dashboard subtitle wants to disappear, which is the default.
fallback?: ReactNode;
}
-// Renders a trip's locations as "Moalboal, Bohol +2", with every name in full as a
+// Renders a trip's locations as "Dahab, Egypt +2", with every name in full as a
// hover hint whenever the "+N" is holding some of them back.
//
// One component rather than the same three lines at each call site, because the label
diff --git a/src/components/trips/trip-parts-field.render.test.tsx b/src/components/trips/trip-parts-field.render.test.tsx
index fe989e93..925f6f19 100644
--- a/src/components/trips/trip-parts-field.render.test.tsx
+++ b/src/components/trips/trip-parts-field.render.test.tsx
@@ -82,7 +82,10 @@ describe("TripPartsField", () => {
{
/>,
);
- expect(places()).toEqual(["Dahab"]);
+ expect(places()).toEqual(["Dahab, Egypt"]);
expect(screen.getByLabelText("From part 1 of 1")).toHaveValue("2026-04-18");
expect(screen.getByLabelText("To part 1 of 1")).toHaveValue("2026-04-22");
});
@@ -218,7 +221,7 @@ describe("TripPartsField", () => {
});
await userEvent.click(await screen.findByRole("option", { name: /Dahab/ }));
- await waitFor(() => expect(places()).toEqual(["Sharm", "Dahab"]));
+ await waitFor(() => expect(places()).toEqual(["Sharm", "Dahab, Egypt"]));
});
it("adds typed text on Enter, and does nothing at all on leaving the field", async () => {
@@ -289,8 +292,8 @@ describe("TripPartsField", () => {
initial={[
{
location: {
- name: "Dahab",
- display_name: "Dahab, Egypt",
+ name: "Dahab, Egypt",
+ full_name: "Dahab, South Sinai, Egypt",
latitude: 28.4954,
longitude: 34.5197,
},
@@ -303,7 +306,7 @@ describe("TripPartsField", () => {
await waitFor(() => expect(searchPlaces).toHaveBeenCalledWith(""));
await userEvent.keyboard("{Enter}");
- await waitFor(() => expect(places()).toEqual(["Dahab"]));
+ await waitFor(() => expect(places()).toEqual(["Dahab, Egypt"]));
expect(screen.queryByText("Not on the map")).not.toBeInTheDocument();
});
diff --git a/src/components/trips/trip-parts-field.test.ts b/src/components/trips/trip-parts-field.test.ts
index 3b3248c7..f7f3bf30 100644
--- a/src/components/trips/trip-parts-field.test.ts
+++ b/src/components/trips/trip-parts-field.test.ts
@@ -1,8 +1,8 @@
import { describe, it, expect } from "vitest";
import { GeocodeResult } from "@/lib/api/geocoding";
+import { geocodeResultToLocation } from "@/lib/locations";
import {
describeTripPart,
- geocodeResultToLocation,
locationKey,
mapSearchResults,
tripPartErrors,
@@ -31,18 +31,18 @@ const BOHOL: GeocodeResult = {
};
describe("locationKey", () => {
- it("identifies a picked place by position and label", () => {
+ it("identifies a picked place by position and fuller name", () => {
// Places are value objects with no id of their own, so a menu row's id has
// to come from the content.
expect(locationKey(geocodeResultToLocation(MOALBOAL))).toBe(
- "geo:9.9366:123.3986:Moalboal, Philippines",
+ "geo:9.9366:123.3986:Moalboal, Cebu, Central Visayas, Philippines",
);
});
it("separates two places of the same name", () => {
- // Both compose to "Moalboal, Philippines" now the short form is what a part
- // keeps, so the position is the whole of what separates them - which is the
- // thing that actually differs between two places of one name.
+ // Both compose to "Moalboal, Philippines" now the short form is what a
+ // place is called, so the position and the provider's own fuller label are
+ // what separate them.
const negros = geocodeResultToLocation({
...MOALBOAL,
latitude: 9.33,
@@ -74,62 +74,21 @@ describe("locationKey", () => {
});
});
-describe("geocodeResultToLocation", () => {
- it("carries the place's position and extent through", () => {
- // The bbox is what lets the confirmation map frame an island rather than
- // put one pin in the middle of it.
- expect(geocodeResultToLocation(BOHOL)).toEqual({
- name: "Bohol",
- display_name: "Bohol, Philippines",
- latitude: 9.85,
- longitude: 124.14,
- bbox_south: 9.48,
- bbox_north: 10.29,
- bbox_west: 123.7,
- bbox_east: 124.66,
- });
- });
-
- it("keeps the composed location as the label, not the provider's", () => {
- // Nominatim's label carries an administrative level and a postcode ("Dahab,
- // South Sinai, 45214, Egypt") that no diver writes down, and the API already
- // composes the place-plus-country form beside it. Chosen here rather than at
- // render because the short form cannot be recovered from the long one -
- // "Dahab" is the settlement in "Dahab, South Sinai, Egypt" and a dive site
- // in "Blue Hole, Dahab, South Sinai, Egypt", and nothing in the flat string
- // says which.
- expect(geocodeResultToLocation(MOALBOAL).display_name).toBe(
- "Moalboal, Philippines",
- );
- });
-
- it("falls back to the composed location when the result has no name", () => {
- // An address-only match ("12 Corniche Road") has no name of its own, and a
- // row has to say something.
- const address = { ...MOALBOAL, name: null };
-
- expect(geocodeResultToLocation(address).name).toBe("Moalboal, Philippines");
- });
-});
-
describe("mapSearchResults", () => {
it("builds menu rows that resolve back to what they set", () => {
const { items, locations } = mapSearchResults([MOALBOAL, BOHOL]);
- // The hint is the label minus the name the row already shows, so the menu
- // reads "Moalboal, Philippines" rather than naming Moalboal twice - and the
- // provider's "Moalboal, Cebu, Central Visayas, Philippines" never reaches a
- // menu row at all.
+ // No hint: a place's name carries its country now, so the row says what a
+ // second line would have said, and the only string left to put there is the
+ // provider's own chain - which nothing in this app renders.
expect(items).toEqual([
{
id: locationKey(geocodeResultToLocation(MOALBOAL)),
- name: "Moalboal",
- hint: "Philippines",
+ name: "Moalboal, Philippines",
},
{
id: locationKey(geocodeResultToLocation(BOHOL)),
- name: "Bohol",
- hint: "Philippines",
+ name: "Bohol, Philippines",
},
]);
expect(locations.get(items[1].id)).toEqual(geocodeResultToLocation(BOHOL));
@@ -138,7 +97,10 @@ describe("mapSearchResults", () => {
it("keeps the provider's ranking", () => {
const { items } = mapSearchResults([BOHOL, MOALBOAL]);
- expect(items.map((item) => item.name)).toEqual(["Bohol", "Moalboal"]);
+ expect(items.map((item) => item.name)).toEqual([
+ "Bohol, Philippines",
+ "Moalboal, Philippines",
+ ]);
});
it("collapses results that key identically", () => {
@@ -156,7 +118,13 @@ describe("mapSearchResults", () => {
const { attributions } = mapSearchResults([
MOALBOAL,
BOHOL,
- { ...MOALBOAL, name: "Panagsama", attribution: "Natural Earth" },
+ {
+ ...MOALBOAL,
+ latitude: 9.9,
+ location: "Panagsama, Philippines",
+ display_name: "Panagsama Beach, Cebu, Philippines",
+ attribution: "Natural Earth",
+ },
]);
expect(attributions).toEqual([
diff --git a/src/components/trips/trip-parts-field.tsx b/src/components/trips/trip-parts-field.tsx
index 9bd5266f..64eab4de 100644
--- a/src/components/trips/trip-parts-field.tsx
+++ b/src/components/trips/trip-parts-field.tsx
@@ -27,11 +27,10 @@ import {
import { moveItem, useDragSort } from "@/hooks/useDragSort";
import {
MAX_LOCATION_NAME_LENGTH,
- MAX_TRIP_PARTS,
- type TripLocationFormValue,
- type TripPartFormValue,
-} from "@/lib/validations/trip";
-import { formatLocationContext } from "@/lib/trip-locations";
+ type LocationFormValue,
+} from "@/lib/validations/location";
+import { MAX_TRIP_PARTS, type TripPartFormValue } from "@/lib/validations/trip";
+import { geocodeResultToLocation } from "@/lib/locations";
import { formatTripDateRange } from "@/lib/date-time";
import { Attribution } from "@/components/attribution";
import { cn } from "@/lib/utils";
@@ -61,51 +60,19 @@ const PLACE_SEARCH_DEBOUNCE_MS = 450;
* back to Dahab - so nothing compares these keys *across* parts. They identify a
* place within one part's menu, and rows are keyed and removed by position.
*/
-export function locationKey(location: TripLocationFormValue): string {
+export function locationKey(location: LocationFormValue): string {
const { latitude, longitude } = location;
if (latitude == null || longitude == null) {
return `txt:${location.name.trim().toLowerCase()}`;
}
- return `geo:${latitude}:${longitude}:${location.display_name ?? location.name}`;
-}
-
-/**
- * A geocoder result as the form holds it.
- *
- * `name` falls back to `location` (the short composed form, "Dahab, Egypt")
- * because a result that matched an address rather than a named place has no name
- * of its own, and a row has to say something.
- *
- * `display_name` is that same short form rather than the provider's own label,
- * which is the one place the two `display_name`s in this app diverge:
- * `GeocodeResult.display_name` is what the provider said, and a trip place's
- * `display_name` is what a diver reads under the name. Nominatim's is "Dahab,
- * South Sinai, 45214, Egypt" - a postcode and an administrative level nobody
- * writes in a dive log - while `location` is the place-plus-country the API
- * composes from the structured address for exactly this purpose. It cannot be
- * recovered from the label later, which is why the choice happens here rather
- * than at render (DECISIONS.md).
- */
-export function geocodeResultToLocation(
- result: GeocodeResult,
-): TripLocationFormValue {
- return {
- name: result.name ?? result.location,
- display_name: result.location,
- latitude: result.latitude,
- longitude: result.longitude,
- bbox_south: result.bbox_south,
- bbox_north: result.bbox_north,
- bbox_west: result.bbox_west,
- bbox_east: result.bbox_east,
- };
+ return `geo:${latitude}:${longitude}:${location.full_name ?? location.name}`;
}
export interface MappedPlaces {
// Menu rows, in the order the provider ranked them.
items: ComboboxItem[];
// What to set the part's place to when one of those rows is picked, by row id.
- locations: Map;
+ locations: Map;
// The licence notices carried by these results, deduplicated.
attributions: string[];
}
@@ -121,7 +88,7 @@ export interface MappedPlaces {
*/
export function mapSearchResults(results: GeocodeResult[]): MappedPlaces {
const items: ComboboxItem[] = [];
- const locations = new Map();
+ const locations = new Map();
const attributions: string[] = [];
for (const result of results) {
@@ -129,14 +96,10 @@ export function mapSearchResults(results: GeocodeResult[]): MappedPlaces {
const id = locationKey(location);
if (!locations.has(id)) {
locations.set(id, location);
- items.push({
- id,
- name: location.name,
- // The place-plus-country the row would be set to, minus the part of it
- // the row's own name already shows. What the diver reads in the menu is
- // then exactly what lands in the field a click later.
- hint: formatLocationContext(location),
- });
+ // No hint: a place's name carries its country now, so the row already
+ // says what a second line would have, and the only string left to put
+ // there is the provider's postal chain - which nothing renders.
+ items.push({ id, name: location.name });
}
if (result.attribution && !attributions.includes(result.attribution)) {
attributions.push(result.attribution);
@@ -483,7 +446,7 @@ function TripPartRow({
// What each menu row would set the place to. Held in a ref rather than state
// because nothing renders from it - the combobox hands back an id, and this is
// what turns that id back into the object it came from.
- const resultsRef = useRef