From 2851eb7afe756372963f53fb763c0d4af78ca89e Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 8 Nov 2026 02:57:43 +0000 Subject: [PATCH] feat(mobile): a Google Maps route is imported into a road trip day on an iPad and an unfolded Duo The desktop's import dialog keeps its markup and moves its logic into useGoogleRouteImport, unchanged: the link previewed, its stops in order with the unresolved ones named, and the resolved ones appended to the day picked, at least two, under one request id per opening and day. The large screens get the same flow as a sheet, offered on the stage card under the track offer to a member who may add places to days. The phone has no entry to it. --- client/scripts/feature-pairs.json | 6 + .../components/Roadtrip/GoogleRouteImport.tsx | 53 +------ .../Roadtrip/useGoogleRouteImport.ts | 92 ++++++++++++ .../screens/trip/roadtrip/MRoadtripTab.tsx | 6 + .../trip/roadtrip/MRtRouteImportSheet.tsx | 131 ++++++++++++++++++ .../mobile/trip/MRtRouteImportSheet.test.tsx | 126 +++++++++++++++++ 6 files changed, 366 insertions(+), 48 deletions(-) create mode 100644 client/src/components/Roadtrip/useGoogleRouteImport.ts create mode 100644 client/src/mobile/screens/trip/roadtrip/MRtRouteImportSheet.tsx create mode 100644 client/tests/unit/mobile/trip/MRtRouteImportSheet.test.tsx diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index 72e148c37..52ee10ab7 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -669,6 +669,12 @@ "desktop": ["src/components/Roadtrip/RoadtripStopPopup.tsx"], "mobile": ["src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx"] }, + { + "feature": "Road-trip Google Maps route import", + "hooks": ["src/components/Roadtrip/useGoogleRouteImport.ts"], + "desktop": ["src/components/Roadtrip/GoogleRouteImport.tsx"], + "mobile": ["src/mobile/screens/trip/roadtrip/MRtRouteImportSheet.tsx"] + }, { "feature": "Road-trip stay length draft", "hooks": ["src/components/Roadtrip/useStayDraft.ts"], diff --git a/client/src/components/Roadtrip/GoogleRouteImport.tsx b/client/src/components/Roadtrip/GoogleRouteImport.tsx index 2841a454e..adbf52a2d 100644 --- a/client/src/components/Roadtrip/GoogleRouteImport.tsx +++ b/client/src/components/Roadtrip/GoogleRouteImport.tsx @@ -1,60 +1,17 @@ -import { useEffect, useId, useRef, useState } from 'react' +import { useId } from 'react' import { MoreHorizontal, Link2 } from 'lucide-react' import { useTranslation } from '../../i18n/TranslationContext' -import { useTripStore } from '../../store/tripStore' -import { googleRouteRepo } from '../../repo/googleRouteRepo' -import { generateUUID } from '../../sync/mutationQueue' -import { getApiErrorMessage } from '../../types' -import type { GoogleRoutePreview } from '@trek/shared' import { DialogButton, DialogFooter, DialogHeader, DialogSection, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT, fs } from '../shared/DialogShell' import CustomSelect from '../shared/CustomSelect' import { ContextMenu, useContextMenu } from '../shared/ContextMenu' import { Tooltip } from '../shared/Tooltip' +import { useGoogleRouteImport } from './useGoogleRouteImport' export default function GoogleRouteImport({ tripId, dayId }: { tripId: number; dayId?: number }) { const { t } = useTranslation() - const days = useTripStore(s => s.days) - const [open, setOpen] = useState(false) - const [url, setUrl] = useState('') - const [preview, setPreview] = useState(null) - const [target, setTarget] = useState('') - const [busy, setBusy] = useState(false) - const [failure, setFailure] = useState('') - const [saved, setSaved] = useState(false) - const controller = useRef(null) - const requestId = useRef('') + const { days, open, url, setUrl, preview, target, pickDay, busy, failure, saved, show, close, canSubmit, submit } = useGoogleRouteImport(tripId, dayId) const menu = useContextMenu() const labelId = useId() - useEffect(() => () => controller.current?.abort(), []) - const inspect = async () => { - controller.current?.abort() - const abort = new AbortController() - controller.current = abort - setBusy(true); setFailure(''); setPreview(null) - try { const parsed = await googleRouteRepo.preview(url, abort.signal); if (!abort.signal.aborted) setPreview(parsed) } - catch (error) { if (!abort.signal.aborted) setFailure(getApiErrorMessage(error, t('common.error'))) } - finally { if (!abort.signal.aborted) setBusy(false) } - } - const save = async () => { - if (!preview || !target) return - setBusy(true); setFailure('') - try { - const stops = preview.stops.filter(s => s.lat != null && s.lng != null).map(s => ({ name: s.name, lat: s.lat!, lng: s.lng! })) - await googleRouteRepo.append(tripId, { dayId: Number(target), stops }, requestId.current) - setSaved(true) - await useTripStore.getState().loadTrip(tripId) - setOpen(false) - } catch (error) { setFailure(getApiErrorMessage(error, t('common.error'))) } - finally { setBusy(false) } - } - const show = () => { - setUrl(''); setPreview(null); setFailure(''); setSaved(false) - setTarget(String(dayId ?? days[0]?.id ?? '')) - requestId.current = generateUUID(); setOpen(true) - } - // Every way out is held while a preview or an import is in flight, as before. - const close = () => { if (!busy) setOpen(false) } - const resolved = preview?.stops.filter(s => s.lat != null && s.lng != null) ?? [] return <> + + +
+ } + title={{t('roadtrip.import.title')}} + sub={t('roadtrip.import.note')} + subWrap + onClose={close} + closeLabel={t('common.close')} + /> +
+ +
+ {!preview ? ( + setUrl(e.target.value)} + aria-label="Google Maps URL" + placeholder="https://maps.app.goo.gl/…" + className={`${FIELD_CLS} disabled:opacity-50`} + /> + ) : ( + <> + {t('roadtrip.import.day')} + +
    + {preview.stops.map((stop, index) => { + const unresolved = stop.lat == null || stop.lng == null; + return ( +
  1. + + {index + 1} + + + {stop.name} + {unresolved && ( + {t('roadtrip.import.unresolved')} + )} + +
  2. + ); + })} +
+ + )} + {failure && ( +

+ {saved ? `${t('common.saved')}: ` : ''} + {failure} +

+ )} +
+ +
+ + +
+
+ + ); +} diff --git a/client/tests/unit/mobile/trip/MRtRouteImportSheet.test.tsx b/client/tests/unit/mobile/trip/MRtRouteImportSheet.test.tsx new file mode 100644 index 000000000..430dd6e5a --- /dev/null +++ b/client/tests/unit/mobile/trip/MRtRouteImportSheet.test.tsx @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { _pinSizeClass, type SizeClass } from '../../../../src/hooks/sizeClass'; +import MRtRouteImport from '../../../../src/mobile/screens/trip/roadtrip/MRtRouteImportSheet'; +import { googleRouteRepo } from '../../../../src/repo/googleRouteRepo'; +import { useTripStore } from '../../../../src/store/tripStore'; +import { fireEvent, render, screen, waitFor } from '../../../helpers/render'; +import { resetAllStores, seedStore } from '../../../helpers/store'; + +// FE-MOB-RTIMPORT-001 to FE-MOB-RTIMPORT-005 +// +// A Google Maps route appended to a day, as the stage card's offer on an iPad or +// an unfolded Duo, over the same useGoogleRouteImport as the desktop dialog. + +vi.mock('../../../../src/repo/googleRouteRepo', () => ({ googleRouteRepo: { preview: vi.fn(), append: vi.fn() } })); + +const loadTrip = vi.fn(async () => {}); + +function renderImport(size: SizeClass = 'wide', canEdit = true) { + _pinSizeClass(size); + return render(); +} + +async function previewRoute() { + fireEvent.click(screen.getByRole('button', { name: 'Import Google Maps route' })); + fireEvent.change(screen.getByRole('textbox', { name: 'Google Maps URL' }), { + target: { value: 'https://google.com/maps/dir/A/B' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Preview' })); + await screen.findByText('Munich'); +} + +describe('MRtRouteImport', () => { + beforeEach(() => { + resetAllStores(); + vi.clearAllMocks(); + seedStore(useTripStore, { + days: [ + { id: 3, day_number: 1, title: 'First day' }, + { id: 4, day_number: 2, title: null }, + ] as never, + loadTrip, + }); + }); + + afterEach(() => { + _pinSizeClass(null); + }); + + it('FE-MOB-RTIMPORT-001: is absent on the phone and for a reader', () => { + const phone = renderImport('compact'); + expect(phone.container).toBeEmptyDOMElement(); + phone.unmount(); + const reader = renderImport('split', false); + expect(reader.container).toBeEmptyDOMElement(); + }); + + it('FE-MOB-RTIMPORT-002: previews the stops in order and says which did not resolve', async () => { + vi.mocked(googleRouteRepo.preview).mockResolvedValue({ + stops: [ + { name: 'Munich', lat: 48, lng: 11 }, + { name: 'Unknown', lat: null, lng: null }, + { name: 'Rome', lat: 41, lng: 12 }, + ], + }); + renderImport(); + await previewRoute(); + const items = screen.getAllByRole('listitem').map((item) => item.textContent); + expect(items).toEqual([ + expect.stringContaining('Munich'), + expect.stringContaining('Unknown'), + expect.stringContaining('Rome'), + ]); + expect(screen.getByText('Location unresolved; this stop will be skipped.')).toBeInTheDocument(); + expect(googleRouteRepo.append).not.toHaveBeenCalled(); + }); + + it('FE-MOB-RTIMPORT-003: appends only the resolved stops to the day picked', async () => { + vi.mocked(googleRouteRepo.preview).mockResolvedValue({ + stops: [ + { name: 'Munich', lat: 48, lng: 11 }, + { name: 'Unknown', lat: null, lng: null }, + { name: 'Rome', lat: 41, lng: 12 }, + ], + }); + vi.mocked(googleRouteRepo.append).mockResolvedValue(undefined); + renderImport(); + await previewRoute(); + fireEvent.change(screen.getByRole('combobox', { name: 'Day' }), { target: { value: '4' } }); + expect(screen.getByRole('option', { name: 'Day 2' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Import' })); + await waitFor(() => expect(loadTrip).toHaveBeenCalledWith(1)); + expect(googleRouteRepo.append).toHaveBeenCalledWith( + 1, + { + dayId: 4, + stops: [ + { name: 'Munich', lat: 48, lng: 11 }, + { name: 'Rome', lat: 41, lng: 12 }, + ], + }, + expect.any(String) + ); + }); + + it('FE-MOB-RTIMPORT-004: holds the import while fewer than two stops resolve', async () => { + vi.mocked(googleRouteRepo.preview).mockResolvedValue({ + stops: [ + { name: 'Munich', lat: 48, lng: 11 }, + { name: 'Unknown', lat: null, lng: null }, + ], + }); + renderImport(); + await previewRoute(); + expect(screen.getByRole('button', { name: 'Import' })).toBeDisabled(); + }); + + it('FE-MOB-RTIMPORT-005: a link that cannot be read is an alert, and the preview waits for a link', async () => { + vi.mocked(googleRouteRepo.preview).mockRejectedValue(new Error('Not a Google Maps route')); + renderImport(); + fireEvent.click(screen.getByRole('button', { name: 'Import Google Maps route' })); + expect(screen.getByRole('button', { name: 'Preview' })).toBeDisabled(); + fireEvent.change(screen.getByRole('textbox', { name: 'Google Maps URL' }), { target: { value: 'nonsense' } }); + fireEvent.click(screen.getByRole('button', { name: 'Preview' })); + expect(await screen.findByRole('alert')).toBeInTheDocument(); + }); +});