From 196dc12762d9bae67d9aa1cb712d03c9a47696e0 Mon Sep 17 00:00:00 2001 From: pipipiper Date: Tue, 22 Sep 2026 14:11:42 +0800 Subject: [PATCH] feat(mobile): place details block in the place editor --- .../Planner/PlaceDetailsColumn.test.tsx | 5 +- .../components/Planner/PlaceDetailsColumn.tsx | 74 ++++++++++++------- .../src/components/Planner/PlaceFormModal.tsx | 7 +- .../src/components/Planner/PlaceInspector.tsx | 4 +- .../screens/trip/sheets/MPlaceEditSheet.tsx | 64 ++++++++++++++++ .../screens/trip/sheets/PlPlaceSearch.tsx | 8 ++ .../unit/mobile/trip/MPlaceEditSheet.test.tsx | 23 +++++- .../unit/mobile/trip/PlPlaceSearch.test.tsx | 3 + 8 files changed, 156 insertions(+), 32 deletions(-) diff --git a/client/src/components/Planner/PlaceDetailsColumn.test.tsx b/client/src/components/Planner/PlaceDetailsColumn.test.tsx index 6f97452f2..5287136cc 100644 --- a/client/src/components/Planner/PlaceDetailsColumn.test.tsx +++ b/client/src/components/Planner/PlaceDetailsColumn.test.tsx @@ -277,7 +277,10 @@ describe('PlaceDetailsColumn', () => { placeEnrichment.mockRejectedValue(new Error('network')) renderColumn() - expect(await screen.findByText('places.details.error')).toBeInTheDocument() + // The load retries once after 1.5 s before showing the error, so the + // default 1 s findBy timeout is too short. + expect(await screen.findByText('places.details.error', undefined, { timeout: 4000 })).toBeInTheDocument() + expect(placeEnrichment).toHaveBeenCalledTimes(2) }) it('FE-PDC-014: serves a second look at the same place from cache', async () => { diff --git a/client/src/components/Planner/PlaceDetailsColumn.tsx b/client/src/components/Planner/PlaceDetailsColumn.tsx index c992a98ce..4f075ed89 100644 --- a/client/src/components/Planner/PlaceDetailsColumn.tsx +++ b/client/src/components/Planner/PlaceDetailsColumn.tsx @@ -51,6 +51,12 @@ interface PlaceDetailsColumnProps { timeFormat?: string /** For grouping the rating count's digits. */ locale?: string + /** + * Full width at every breakpoint. The desktop dialog sits the column beside + * the form (sm:w-80); the mobile sheet stacks it inside a scrolling column, + * where a 320 px cap would leave it hugging the left half of a wide screen. + */ + fluid?: boolean /** False on an instance with no Google key, which is most of them. */ t: TranslationFn } @@ -117,6 +123,7 @@ export default function PlaceDetailsColumn({ language, timeFormat = '24h', locale = 'en-US', + fluid = false, t, }: PlaceDetailsColumnProps): React.ReactElement { const [data, setData] = useState(null) @@ -150,31 +157,48 @@ export default function PlaceDetailsColumn({ setState('loading') setData(null) - mapsApi - .placeEnrichment( - { - placeId: selection.placeId, - lat: selection.lat, - lng: selection.lng, - name: selection.name, - lang: language, - details: selection.details, - }, - controller.signal, - ) - .then((result) => { - if (controller.signal.aborted) return - enrichmentCache.set(selectionKey, result) - writeSession(selectionKey, result) - setData(result) - setState('ready') - }) - .catch((err: unknown) => { - if (controller.signal.aborted || (err as { code?: string })?.code === 'ERR_CANCELED') return - setState('error') - }) + // One quiet retry before the error state: on a phone the first attempt + // regularly dies to a network blip rather than a real failure, and an + // error card the user can only clear by re-picking the place is worse than + // a second and a half of extra spinner. + let retried = false + let retryTimer: ReturnType | null = null + const run = () => { + mapsApi + .placeEnrichment( + { + placeId: selection.placeId, + lat: selection.lat, + lng: selection.lng, + name: selection.name, + lang: language, + details: selection.details, + }, + controller.signal, + ) + .then((result) => { + if (controller.signal.aborted) return + enrichmentCache.set(selectionKey, result) + writeSession(selectionKey, result) + setData(result) + setState('ready') + }) + .catch((err: unknown) => { + if (controller.signal.aborted || (err as { code?: string })?.code === 'ERR_CANCELED') return + if (!retried) { + retried = true + retryTimer = setTimeout(run, 1500) + return + } + setState('error') + }) + } + run() - return () => controller.abort() + return () => { + controller.abort() + if (retryTimer) clearTimeout(retryTimer) + } // selectionKey folds in the place id (or its coordinates) and the language, // which is everything the answer depends on. Depending on `selection` itself // would refetch whenever the object identity changes without the place @@ -198,7 +222,7 @@ export default function PlaceDetailsColumn({ // tiles, which is too small to tell a facade from a foyer. The column is // stretched to the form's height by the row it sits in, so the extra room // costs nothing that was being used. -