diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx index a40a5005b..9cd7f0d8a 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.test.tsx @@ -110,11 +110,23 @@ function corridor(over: Partial = {}, search: Partial 1), stopsAlongKm: [0, 100], + clear: vi.fn(), ...over, search: searchState, } } +/** + * The buttons of the "what to look for" card's last row, in order. + * + * By position rather than by label: which half is which is the point of the row, and a + * label is a translation that would make this a test about wording. + */ +const searchRow = (): HTMLButtonElement[] => { + const searchButton = screen.getByRole('button', { name: 'Search' }) + return [...(searchButton.parentElement?.querySelectorAll('button') ?? [])] as HTMLButtonElement[] +} + describe('RoadtripCorridorPanel', () => { it('FE-ROADTRIP-PANEL-001: searching is a button, never a side effect of opening the panel', () => { const c = corridor() @@ -356,6 +368,103 @@ describe('RoadtripCorridorPanel', () => { expect(screen.queryByText('Pick what you need and search.')).not.toBeInTheDocument() }) + it('FE-ROADTRIP-PANEL-024: what was found carries the way to throw it away', () => { + const nothing = corridor() + const { unmount } = wrap() + // Before a search there is nothing to count and nothing to clear, and a dead control + // is worse than none. + expect(screen.queryByText(/on the way/)).not.toBeInTheDocument() + unmount() + + // Two fuel hits and a campsite, so the total over the filters is a different + // sentence from any one group's count and can anchor the query on its own. + const c = corridor({ categories: ['fuel', 'campsite'] }, { + results: [ + poi({ osm_id: 'a', name: 'Aral' }), + poi({ osm_id: 'b', name: 'Shell' }), + poi({ osm_id: 'c', name: 'Camping Elbe', category: 'campsite' }), + ], + }) + wrap() + + const header = screen.getByText('3 on the way').closest('div')! + fireEvent.click(within(header).getByRole('button')) + expect(c.clear).toHaveBeenCalledTimes(1) + // Clearing asks the place search for nothing at all. + expect(c.search.search).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-PANEL-025: the search shares its row with a manual add, and only where one may add', () => { + const reader = corridor() + const { unmount } = wrap() + expect(searchRow()).toHaveLength(1) + unmount() + + const onAddManual = vi.fn() + const c = corridor() + wrap( + , + ) + + // One row, two halves of it, not a second button under the first. + const [searchButton, manual] = searchRow() + expect(manual).toBeDefined() + expect(searchButton.className).toContain('flex-1') + expect(manual.className).toContain('flex-1') + + // It opens the dialog. Nothing is added until a place and a position are picked + // there, and the kind of stop is still the stop popup's question. + fireEvent.click(manual) + expect(onAddManual).not.toHaveBeenCalled() + expect(screen.getByLabelText('Search places...')).toBeInTheDocument() + }) + + it('FE-ROADTRIP-PANEL-026: with no drive on the trip there is nowhere to add one by hand', () => { + const dayless = corridor({ day: undefined }) + wrap() + + expect(searchRow()).toHaveLength(1) + }) + + it('FE-ROADTRIP-PANEL-027: clicking a row brings that hit into view, and adding it does not', () => { + const onFocusPoint = vi.fn() + const onAddPoi = vi.fn() + const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', lat: 53.14, lng: 9.82 })] }) + wrap( + , + ) + + fireEvent.click(screen.getByText('Aral')) + expect(onFocusPoint).toHaveBeenCalledWith(53.14, 9.82) + expect(onAddPoi).not.toHaveBeenCalled() + + // The Add button is the row's sibling and not a button inside a button, so adding a + // stop does not also move the camera out from under the list. + onFocusPoint.mockClear() + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + expect(onAddPoi).toHaveBeenCalledTimes(1) + expect(onFocusPoint).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-PANEL-028: a row is inert where there is no map to move', () => { + const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] }) + wrap() + + // No handler, no button: a control that does nothing is one a keyboard user reaches + // for and finds empty. + expect(screen.getByText('Aral').closest('button')).toBeNull() + }) + it('FE-ROADTRIP-PANEL-022: without permission to add, no hit offers an Add button', () => { const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] }) wrap() diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index 8f8df5aeb..36f8f9588 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -10,9 +10,11 @@ import { useSettingsStore } from '../../store/settingsStore' import { formatDistance } from '../../utils/units' import CustomSelect from '../shared/CustomSelect' import RoadtripCategoryPicker from './RoadtripCategoryPicker' +import RoadtripManualStopModal from './RoadtripManualStopModal' import { serviceColor } from './roadtripModel' import { CORRIDOR_CATEGORY_BY_KEY } from './stopKinds' import { FS } from './typeScale' +import type { ManualStopPlace, ManualStopTarget } from './manualStop' import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_SECTION_KM, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor' import type { CorridorPoi } from './useCorridorPois' import type { RoadtripRoutes } from './useRoadtripRoutes' @@ -28,6 +30,18 @@ interface RoadtripCorridorPanelProps { dayId?: number | null, position?: number | null, ) => void + /** + * Brings a hit into view on the map. A row is where you decide a place is worth + * looking at, and looking at it means seeing which side of the road it is on. + */ + onFocusPoint?: (lat: number, lng: number) => void + /** + * Adds a place the search never found, at a position on the drive. Given only to + * somebody who may add places, which is what keeps the button off a reader's panel. + */ + onAddManual?: (place: ManualStopPlace, target: ManualStopTarget) => void + /** Where a freely chosen point belongs on the drive, from the planner's projection. */ + manualStopTargetFor?: (lat: number, lng: number) => ManualStopTarget | null } /** @@ -96,51 +110,69 @@ function ResultBadge({ category }: { category: string }): React.ReactElement { */ const POI_CHIP = 'inline-flex items-center rounded-md bg-surface-secondary px-1.5 py-0.5 leading-none tabular-nums text-content-muted' -function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): React.ReactElement { +function ResultRow({ poi, onAdd, onFocus }: { poi: CorridorPoi; onAdd?: () => void; onFocus?: () => void }): React.ReactElement { const { t } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + /** + * The badge and the text are one target, the Add button beside them another. + * + * Not the row itself and not a button around the lot: an Add button inside a row + * button is markup neither a browser nor a screen reader can make sense of, and the + * `
  • ` has to stay one for the list around it to still be a list. Same shape + * `AutomaticDayStop` uses: an element that is a button only when there is somewhere + * for it to go, so a row without the map behind it is not a dead control. + */ + const Block = onFocus ? 'button' : 'div' return (
  • - -
    -
    - {poi.name} -
    - {poi.pluginId &&

    {poi.pluginId}

    } - {/* Two facts, two chips. Where it is off the road and how far into the drive it - comes are separate answers, and as two phrases sharing a line they read as one - run-on sentence about the same thing. A chip each gives them an edge, and the - row still wraps cleanly at a narrow width because nothing has to break around - a separator. */} -
    - - {t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })} - - - {poi.alongKm < 0.5 - ? t('roadtrip.poi.atStart') - : t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })} - - {/* What the charger offers, where OSM says. Socket names are proper nouns and - stay as they are; the numbers around them are what decides whether a car can - use it at all. A station that says nothing shows nothing rather than a row - of dashes, because "not stated" is not "no". */} - {poi.charging?.sockets.length ? ( - - {poi.charging.sockets.slice(0, 3).map(s => ( - - {SOCKET_LABEL[s.type] ?? s.type} - {s.kw ? ` ${s.kw} kW` : ''} - {s.count && s.count > 1 ? ` ×${s.count}` : ''} - - ))} + + +
    +
    + {poi.name} +
    + {poi.pluginId &&

    {poi.pluginId}

    } + {/* Two facts, two chips. Where it is off the road and how far into the drive it + comes are separate answers, and as two phrases sharing a line they read as one + run-on sentence about the same thing. A chip each gives them an edge, and the + row still wraps cleanly at a narrow width because nothing has to break around + a separator. */} +
    + + {t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })} - ) : null} - {poi.charging?.fee === false ? ( - {t('roadtrip.poi.free')} - ) : null} + + {poi.alongKm < 0.5 + ? t('roadtrip.poi.atStart') + : t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })} + + {/* What the charger offers, where OSM says. Socket names are proper nouns and + stay as they are; the numbers around them are what decides whether a car can + use it at all. A station that says nothing shows nothing rather than a row + of dashes, because "not stated" is not "no". */} + {poi.charging?.sockets.length ? ( + + {poi.charging.sockets.slice(0, 3).map(s => ( + + {SOCKET_LABEL[s.type] ?? s.type} + {s.kw ? ` ${s.kw} kW` : ''} + {s.count && s.count > 1 ? ` ×${s.count}` : ''} + + ))} + + ) : null} + {poi.charging?.fee === false ? ( + {t('roadtrip.poi.free')} + ) : null} +
    -
    + {/* Always there, quiet until the row is under the pointer: a button that only exists on hover is one a keyboard user has to find by faith. */} {onAdd ? (() => { @@ -168,12 +200,13 @@ function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): Re } /** One category's hits, so a mixed search reads as several short lists instead of one long one. */ -function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: { +function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi, onFocusPoint }: { category: string pois: CorridorPoi[] dayId: number | null insertIndexFor: RoadtripCorridor['insertIndexFor'] onAddPoi: RoadtripCorridorPanelProps['onAddPoi'] + onFocusPoint: RoadtripCorridorPanelProps['onFocusPoint'] }): React.ReactElement { const { t } = useTranslation() const meta = CATEGORY_META[category] @@ -232,6 +265,7 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: { poi={poi} // Added where it will be driven past, not at the end of the day. onAdd={onAddPoi ? () => onAddPoi(poi, dayId, insertIndexFor(poi)) : undefined} + onFocus={onFocusPoint ? () => onFocusPoint(poi.lat, poi.lng) : undefined} /> ))} @@ -253,10 +287,20 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: { * header rather than among the filters — it is the question's subject, not one of its * conditions. */ -export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi, tripId, canImport }: RoadtripCorridorPanelProps): React.ReactElement { +export default function RoadtripCorridorPanel({ + corridor, routes, onAddPoi, onFocusPoint, onAddManual, manualStopTargetFor, tripId, canImport, +}: RoadtripCorridorPanelProps): React.ReactElement { const { t } = useTranslation() const distanceUnit = useSettingsStore(s => s.settings.distance_unit) const { search } = corridor + /** + * Whether the manual dialog is up. + * + * Local, and the dialog is mounted rather than merely hidden: it is a way of adding + * one stop, not a setting, and unmounting it on close is what stops a half-typed + * search from being there the next time somebody opens it. + */ + const [manualOpen, setManualOpen] = useState(false) const dayOptions = routes.days.map(d => ({ value: String(d.dayId), @@ -371,26 +415,51 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi, trip
    - {/* One word, so it can be read at the size a primary action deserves. The panel - it sits in is headed "along the route" and every control above it narrows the - same search; repeating that on the button only made it small. + {/* One word each, so both can be read at the size an action deserves. The panel + they sit in is headed "along the route" and every control above them narrows + the same search; repeating that on a button only made it small. - Its height is the controls' height, not a size of its own. At 38px it stood a - head above the segmented rows it follows and read as a second panel rather - than as the end of this one. */} - + Their height is the controls' height, not a size of their own. At 38px the + search stood a head above the segmented rows it follows and read as a second + panel rather than as the end of this one. + + Two halves of one row, because the search finds most of what is out there and + not all of it: a good share of the chargers standing at a junction are in + nobody's OpenStreetMap extract. The second button is the way to those, and it + sits beside the search rather than under it so neither is the afterthought. */} +
    + + {/* The day is part of the offer: with no drive on the trip at all there is + nowhere for a stop to go, and a dialog that can only be cancelled is worse + than a button that waited. */} + {onAddPoi && onAddManual && corridor.day ? ( + + ) : null} +
    {/* How the run is getting on — a row of its own while it lasts, with the share @@ -439,6 +508,25 @@ export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi, trip appears once there is something to narrow. */} {search.results.length > 0 ? (
    + {/* What was found, and the way back out of it. A search that turned up the + wrong thing left its hits here and its pins on the map with no way to + dismiss them: the only way out was switching to the day plan and back. + Here rather than beside the search button, because throwing an answer + away is only ever meaningful once there is one. */} +
    + + {t('roadtrip.poi.found', { count: search.results.length })} + + +
    ))} )}
    + + {manualOpen && onAddManual ? ( + setManualOpen(false)} + // Closed before the handover: what opens next is the stop popup, and two + // dialogs over each other is not a choice anybody made. + onSubmit={(place, target) => { setManualOpen(false); onAddManual(place, target) }} + /> + ) : null} ) } diff --git a/client/src/components/Roadtrip/RoadtripManualStopModal.test.tsx b/client/src/components/Roadtrip/RoadtripManualStopModal.test.tsx new file mode 100644 index 000000000..41c548c6a --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripManualStopModal.test.tsx @@ -0,0 +1,287 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, act, waitFor } from '@testing-library/react' +import { TranslationProvider } from '../../i18n' +import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes' + +const { search } = vi.hoisted(() => ({ search: vi.fn() })) +// Only the place search is replaced; the rest of the module is what the stores around +// this dialog import, and cutting it down breaks them rather than isolating anything. +vi.mock('../../api/client', async (importOriginal) => ({ + ...(await importOriginal()), + mapsApi: { search }, +})) +// The hint is the trip's own doing and has its own suite; here it only has to arrive. +vi.mock('../../hooks/useLocationBias', () => ({ + useLocationBias: () => ({ point: { lat: 53, lng: 10, radius: 5000 } }), +})) + +import RoadtripManualStopModal from './RoadtripManualStopModal' + +/** The debounce the dialog waits out before it asks anything. */ +const DEBOUNCE = 320 + +const stop = (id: number, name: string, ownerDayId: number, ownerIndex: number): RoadtripStop => ({ + assignmentId: id, + ownerDayId, + ownerIndex, + placeId: id * 10, + name, + lat: 53.5, + lng: 9.9, + time: null, + dwellMinutes: null, + legMode: null, + incomingLegMode: null, + stopType: null, +}) + +const day = (dayId: number, dayNumber: number, names: string[], routed = true): RoadtripDay => ({ + dayId, + dayNumber, + date: null, + title: null, + stops: names.map((name, i) => stop(dayId * 10 + i, name, dayId, i)), + legs: [], + schedule: { entries: [], warnings: [] }, + legVias: [], driveWarnings: [], dayWarning: null, + geometry: (routed ? [[53.55, 9.99], [52.52, 13.4]] : []) as [number, number][], + distance: 0, + duration: 0, +}) + +const routes = (days: RoadtripDay[]): RoadtripRoutes => ({ + days, + lines: [], lineDays: [], + accessLines: [], + vias: [], + segments: [], + totalDistance: 0, + totalDuration: 0, + totalStops: 0, + quietDays: [], + loading: false, +}) + +/** Two routed days, four stops, three legs: each leg named differently. */ +const threeLegs = () => routes([ + day(1, 1, ['Hamburg', 'Berlin', 'Dresden']), + day(2, 2, ['Munich', 'Salzburg']), +]) + +const hit = (over: Record = {}) => ({ + name: 'Supercharger Rasthof', + address: 'A1, Dammer Berge', + lat: 52.5, + lng: 8.2, + osm_id: 'node/9', + ...over, +}) + +function setup(over: Partial> = {}) { + const onSubmit = vi.fn() + const onClose = vi.fn() + const targetFor = vi.fn(() => ({ dayId: 1, position: 2, offRouteKm: 0.4 })) + render( + + + , + ) + return { onSubmit, onClose, targetFor } +} + +/** The place box, by the placeholder every place search in TREK carries. */ +const queryBox = () => screen.getByLabelText('Search places...') + +/** Type, then wait out the debounce and whatever the answer resolves into. */ +async function type(value: string, wait = DEBOUNCE): Promise { + fireEvent.change(queryBox(), { target: { value } }) + await act(async () => { vi.advanceTimersByTime(wait) }) +} + +beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + search.mockReset() + search.mockResolvedValue({ places: [hit()], source: 'openstreetmap' }) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('RoadtripManualStopModal', () => { + it('FE-ROADTRIP-MANUAL-001: two letters are not a question worth asking', async () => { + // A single common word without a region is refused upstream as too expensive to + // answer, and asking anyway spends a request to be told so. + setup() + await type('Su', DEBOUNCE * 2) + + expect(search).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-MANUAL-002: the search waits for a pause in the typing', async () => { + setup() + + fireEvent.change(queryBox(), { target: { value: 'Supercharger' } }) + await act(async () => { vi.advanceTimersByTime(DEBOUNCE - 120) }) + expect(search).not.toHaveBeenCalled() + + await act(async () => { vi.advanceTimersByTime(120) }) + expect(search).toHaveBeenCalledTimes(1) + // The query, the locale, and the hint that decides which of a thousand identically + // named places is meant. + expect(search).toHaveBeenCalledWith('Supercharger', expect.any(String), expect.objectContaining({ lat: 53, lng: 10 })) + }) + + it('FE-ROADTRIP-MANUAL-003: keystroke after keystroke is one question, not one each', async () => { + setup() + + fireEvent.change(queryBox(), { target: { value: 'Sup' } }) + await act(async () => { vi.advanceTimersByTime(100) }) + fireEvent.change(queryBox(), { target: { value: 'Super' } }) + await act(async () => { vi.advanceTimersByTime(100) }) + fireEvent.change(queryBox(), { target: { value: 'Supercharger' } }) + await act(async () => { vi.advanceTimersByTime(DEBOUNCE) }) + + expect(search).toHaveBeenCalledTimes(1) + expect(search).toHaveBeenCalledWith('Supercharger', expect.any(String), expect.anything()) + }) + + it('FE-ROADTRIP-MANUAL-004: a row the map could not place is never offered', async () => { + // It cannot become a stop, and offering it only to refuse the click is a dead end + // with nothing to explain it. + search.mockResolvedValue({ + places: [ + // Nothing at all where a coordinate should be. `Number(null)` is 0, and 0/0 is a + // real place off the coast of Africa. + hit({ name: 'Nowhere', lat: null, lng: null }), + hit({ name: 'Nonsense', lat: 'somewhere', lng: 'thereabouts' }), + // A row with neither a name nor an address has nothing to show in a list. + hit({ name: null, address: null }), + hit(), + ], + source: 'openstreetmap', + }) + setup() + await type('Supercharger') + + await waitFor(() => expect(screen.getByText('Supercharger Rasthof')).toBeInTheDocument()) + expect(screen.queryByText('Nowhere')).not.toBeInTheDocument() + expect(screen.queryByText('Nonsense')).not.toBeInTheDocument() + expect(screen.getAllByText('A1, Dammer Berge')).toHaveLength(1) + }) + + it('FE-ROADTRIP-MANUAL-005: picking one takes the leg the projection worked out', async () => { + const { onSubmit, targetFor } = setup() + await type('Supercharger') + await waitFor(() => expect(screen.getByText('Supercharger Rasthof')).toBeInTheDocument()) + + fireEvent.click(screen.getByText('Supercharger Rasthof')) + + // Measured by the planner's own projection, which is also what places a via. + expect(targetFor).toHaveBeenCalledWith(52.5, 8.2) + // Day 1, position 2: the leg between its second and third stop. + expect(screen.getByText('Halfway, Berlin to Dresden')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Supercharger Rasthof', lat: 52.5, lng: 8.2, osm_id: 'node/9' }), + { dayId: 1, position: 2, offRouteKm: 0.4 }, + ) + }) + + it('FE-ROADTRIP-MANUAL-006: the leg it offers can be overruled, and the override is what lands', async () => { + // A drive that passes the same junction twice can only be guessed at once, and the + // traveller is the one who knows which time they mean to stop. + const { onSubmit } = setup() + await type('Supercharger') + await waitFor(() => expect(screen.getByText('Supercharger Rasthof')).toBeInTheDocument()) + fireEvent.click(screen.getByText('Supercharger Rasthof')) + + // CustomSelect renders its options into a portal, so the trigger comes first. + fireEvent.click(screen.getByText('Halfway, Berlin to Dresden')) + fireEvent.click(screen.getByText('Halfway, Munich to Salzburg')) + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Supercharger Rasthof' }), + expect.objectContaining({ dayId: 2, position: 1 }), + ) + }) + + it('FE-ROADTRIP-MANUAL-007: a place well off the road is accepted, and said so rather than refused', async () => { + // The whole point of the dialog is the charger the corridor search missed, which is + // exactly the one sitting further off the line than a via would be allowed. + const targetFor = vi.fn(() => ({ dayId: 2, position: 1, offRouteKm: 12.4 })) + const { onSubmit } = setup({ targetFor }) + await type('Supercharger') + await waitFor(() => expect(screen.getByText('Supercharger Rasthof')).toBeInTheDocument()) + fireEvent.click(screen.getByText('Supercharger Rasthof')) + + // A note, not a refusal: found by its tone rather than its wording, which is a + // translation and not what this test is about. + expect(document.querySelector('p.text-warning')).toBeInTheDocument() + + const add = screen.getByRole('button', { name: 'Add' }) + expect(add).toBeEnabled() + fireEvent.click(add) + expect(onSubmit).toHaveBeenCalledWith( + expect.anything(), + { dayId: 2, position: 1, offRouteKm: 12.4 }, + ) + }) + + it('FE-ROADTRIP-MANUAL-008: a place on the road is not flagged for being there', async () => { + setup() + await type('Supercharger') + await waitFor(() => expect(screen.getByText('Supercharger Rasthof')).toBeInTheDocument()) + fireEvent.click(screen.getByText('Supercharger Rasthof')) + + expect(document.querySelector('p.text-warning')).toBeNull() + }) + + it('FE-ROADTRIP-MANUAL-009: with nothing routed it goes at the end of the day, and names no leg', async () => { + const unrouted = routes([day(5, 3, ['Hamburg', 'Berlin'], false)]) + const targetFor = vi.fn(() => null) + const { onSubmit } = setup({ routes: unrouted, dayId: 5, targetFor }) + await type('Supercharger') + await waitFor(() => expect(screen.getByText('Supercharger Rasthof')).toBeInTheDocument()) + fireEvent.click(screen.getByText('Supercharger Rasthof')) + + // No drive means no order, and a leg offered anyway would be an invention. + expect(screen.queryByText(/Halfway,/)).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Add' })) + expect(onSubmit).toHaveBeenCalledWith( + expect.anything(), + { dayId: 5, position: 2, offRouteKm: 0 }, + ) + }) + + it('FE-ROADTRIP-MANUAL-010: nothing can be added until a place has been picked', async () => { + const { onSubmit, onClose } = setup() + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled() + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onClose).toHaveBeenCalledTimes(1) + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('FE-ROADTRIP-MANUAL-011: a search that fails says nothing was found rather than nothing at all', async () => { + search.mockRejectedValue(new Error('offline')) + setup() + await type('Supercharger') + + // The rejection is swallowed on purpose (it is a typeahead, not a submit), but the + // list has to stop saying it is loading. + await waitFor(() => expect(screen.queryByText('Loading...')).not.toBeInTheDocument()) + expect(screen.getByRole('button', { name: 'Add' })).toBeDisabled() + }) +}) diff --git a/client/src/components/Roadtrip/RoadtripManualStopModal.tsx b/client/src/components/Roadtrip/RoadtripManualStopModal.tsx new file mode 100644 index 000000000..7e1365eeb --- /dev/null +++ b/client/src/components/Roadtrip/RoadtripManualStopModal.tsx @@ -0,0 +1,392 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { MapPin, Search, X } from 'lucide-react' +import Modal from '../shared/Modal' +import CustomSelect from '../shared/CustomSelect' +import { mapsApi } from '../../api/client' +import { useTranslation } from '../../i18n/TranslationContext' +import { useLocationBias } from '../../hooks/useLocationBias' +import { useSettingsStore } from '../../store/settingsStore' +import { formatDistance } from '../../utils/units' +import { FS } from './typeScale' +import type { ManualStopPlace, ManualStopTarget } from './manualStop' +import type { RoadtripRoutes } from './useRoadtripRoutes' + +interface RoadtripManualStopModalProps { + routes: RoadtripRoutes + /** The day the panel is looking at: where the stop goes while nothing has routed. */ + dayId: number | null + /** Where a freely chosen point belongs on the drive, from the planner's own projection. */ + targetFor?: (lat: number, lng: number) => ManualStopTarget | null + onClose: () => void + /** Hands the place and its position on, so the stop popup asks the kind and the dwell. */ + onSubmit: (place: ManualStopPlace, target: ManualStopTarget) => void +} + +/** How long after the last keystroke the search runs. */ +const DEBOUNCE_MS = 320 +/** Below this, a query matches half a country and the index refuses it as too broad. */ +const MIN_QUERY = 3 +/** + * Past this far from the road, the projected leg is worth a second look, in kilometres. + * + * The same figure `addRoadtripVia` refuses a click at, and here it only prompts, which + * is the whole difference between placing a via and adding a stop the search missed. + */ +const OFF_ROUTE_NOTE_KM = 2 + +/** + * One row of the place search. + * + * The endpoint answers with provider-shaped records (Google and the OSM layer carry + * different fields), so the contract keeps them as open records and each caller reads + * what it needs. Narrowed once in `toHit` below rather than carried around untyped. + */ +interface PlaceHit { + name: string + address: string | null + lat: number + lng: number + osm_id: string + website: string | null + phone: string | null + category: string | null +} + +const text = (value: unknown): string | null => + typeof value === 'string' && value.trim() ? value : null + +/** + * A search row, or nothing at all. + * + * A row without usable coordinates is dropped here instead of at the click: it cannot + * become a stop, and offering it only to refuse it is a dead end with no explanation. + */ +function toHit(row: Record): PlaceHit | null { + // `Number(null)` is 0, and 0/0 is a real coordinate in the Gulf of Guinea: a row with + // no location at all would otherwise become a stop off the coast of Africa. + if (row.lat == null || row.lng == null || row.lat === '' || row.lng === '') return null + const lat = Number(row.lat) + const lng = Number(row.lng) + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null + const address = text(row.address) + const name = text(row.name) ?? address + if (!name) return null + return { + name, + address, + lat, + lng, + // Both providers, in the order the rest of the client reads them. + osm_id: text(row.osm_id) ?? text(row.google_place_id) ?? '', + website: text(row.website), + phone: text(row.phone), + category: text(row.category), + } +} + +/** + * Adding a stop the corridor search never found. + * + * The search reads OpenStreetMap, and a good share of the chargers actually standing at + * a motorway junction are not in it, most Tesla Superchargers among them. The way round + * it was to leave road trip mode, add the place under Days, drag it onto the right day, + * come back and mark it a charging stop. + * + * So this asks the two questions that answer for those four steps (which place, and + * where on the drive) and then hands over to the same popup a corridor hit goes through. + * What kind of stop it is and how long it takes are decided there, once, for both paths. + */ +export default function RoadtripManualStopModal({ + routes, dayId, targetFor, onClose, onSubmit, +}: RoadtripManualStopModalProps): React.ReactElement { + const { t, locale } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + // Without a trip context the hint is empty and the search runs as it always has. + const { point: locationBias } = useLocationBias() + + const [query, setQuery] = useState('') + const [results, setResults] = useState([]) + const [listOpen, setListOpen] = useState(false) + const [highlight, setHighlight] = useState(-1) + const [loading, setLoading] = useState(false) + const [answered, setAnswered] = useState(false) + const [picked, setPicked] = useState(null) + const [chosenLeg, setChosenLeg] = useState('') + const [offRouteKm, setOffRouteKm] = useState(null) + + const wrapRef = useRef(null) + const debounceRef = useRef | null>(null) + + useEffect(() => { + if (!listOpen) return + const onPointer = (e: MouseEvent) => { + if (!wrapRef.current?.contains(e.target as Node)) setListOpen(false) + } + document.addEventListener('mousedown', onPointer) + return () => document.removeEventListener('mousedown', onPointer) + }, [listOpen]) + + // Debounced, and with a cancelled flag rather than a bare `.then(setState)`: the + // endpoint takes no signal, so the only way a stale answer cannot overwrite a newer + // one is for the run it belongs to to know it has been superseded. + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current) + const trimmed = query.trim() + if (trimmed.length < MIN_QUERY || trimmed === picked?.name) { + setResults([]) + setAnswered(false) + return + } + let cancelled = false + debounceRef.current = setTimeout(() => { + setLoading(true) + mapsApi.search(trimmed, locale, locationBias) + .then(data => { + if (cancelled) return + setResults(data.places.map(toHit).filter((hit): hit is PlaceHit => hit !== null)) + setHighlight(-1) + setAnswered(true) + }) + .catch(() => { + if (cancelled) return + setResults([]) + setAnswered(true) + }) + .finally(() => { if (!cancelled) setLoading(false) }) + }, DEBOUNCE_MS) + return () => { + cancelled = true + if (debounceRef.current) clearTimeout(debounceRef.current) + } + }, [query, picked, locale, locationBias]) + + /** + * Every leg of every routed day, flat. + * + * Flat rather than per day because a stop belongs to the drive and not to whichever + * card the panel happens to be showing: the charger the search missed is as likely to + * be on tomorrow's stretch as on today's. The labels are the section picker's own: + * the day carries the number, the leg names the two stops it sits between. + */ + const legs = useMemo(() => { + const out: (ManualStopTarget & { value: string; label: string; badge: string })[] = [] + for (const day of routes.days) { + // A day that has not routed has no order to place anything in. + if (day.geometry.length < 2) continue + for (let i = 0; i < day.stops.length - 1; i++) { + out.push({ + value: `${day.dayId}:${i}`, + label: t('roadtrip.poi.midLeg', { + from: day.stops[i]?.name ?? '', + to: day.stops[i + 1]?.name ?? '', + }), + badge: t('roadtrip.day', { number: day.dayNumber }), + dayId: day.dayId, + position: i + 1, + offRouteKm: 0, + }) + } + } + return out + }, [routes.days, t]) + + /** + * Where it goes while the trip has no drawn route yet: the end of the day the panel is + * on. No leg is named, because there is no order to name one in. + */ + const appendTarget = useMemo(() => { + if (legs.length > 0 || dayId == null) return null + const day = routes.days.find(d => d.dayId === dayId) + return { dayId, position: day?.stops.length ?? 0, offRouteKm: 0 } + }, [legs.length, dayId, routes.days]) + + const appendDayNumber = routes.days.find(d => d.dayId === dayId)?.dayNumber ?? 0 + + const pick = useCallback((hit: PlaceHit) => { + setPicked(hit) + setQuery(hit.name) + setListOpen(false) + setResults([]) + const found = targetFor?.(hit.lat, hit.lng) ?? null + setOffRouteKm(found?.offRouteKm ?? null) + // The projection is the DEFAULT and never a gate. The charger this dialog exists for + // is exactly the one sitting a little too far off the line, so a distance that would + // refuse a via is accepted here and only preselects the leg it landed nearest. + const match = found && legs.find(l => l.dayId === found.dayId && l.position === found.position) + // Nothing matching falls back to the first leg, the way an unchosen one already does. + setChosenLeg(match ? match.value : '') + }, [targetFor, legs]) + + const clearPick = useCallback(() => { + setPicked(null) + setQuery('') + setResults([]) + setAnswered(false) + setOffRouteKm(null) + setChosenLeg('') + }, []) + + /** + * The leg in force: the one chosen, or the first on offer. + * + * Derived rather than seeded into state, so the control always shows a real answer. + * A dropdown sitting empty under a heading that says "add between" asks a question it + * has already been given the answer to. + */ + const leg = legs.some(l => l.value === chosenLeg) ? chosenLeg : (legs[0]?.value ?? '') + const target = legs.find(l => l.value === leg) ?? appendTarget + + const submit = (): void => { + if (!picked || !target) return + onSubmit({ + name: picked.name, + lat: picked.lat, + lng: picked.lng, + address: picked.address, + website: picked.website, + phone: picked.phone, + osm_id: picked.osm_id, + category: picked.category, + }, { dayId: target.dayId, position: target.position, offRouteKm: offRouteKm ?? 0 }) + } + + const onKey = (e: React.KeyboardEvent): void => { + if (e.key === 'Escape' && listOpen) { + // Kept from closing the dialog underneath: the list is what Escape was aimed at. + e.stopPropagation() + setListOpen(false) + return + } + if (!listOpen || results.length === 0) return + if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(h + 1, results.length - 1)) } + else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(h - 1, 0)) } + else if (e.key === 'Enter' && highlight >= 0) { + e.preventDefault() + const hit = results[highlight] + if (hit) pick(hit) + } + } + + return ( + + + + + )} + > +
    +

    {t('roadtrip.poi.manualHint')}

    + +
    + + { + setQuery(e.target.value) + setListOpen(true) + if (picked) { setPicked(null); setOffRouteKm(null) } + }} + onFocus={() => setListOpen(true)} + onKeyDown={onKey} + // The dialog exists to be typed into; anything else here is a second step. + autoFocus + placeholder={t('places.search')} + aria-label={t('places.search')} + className="h-[36px] w-full rounded-[10px] bg-surface-tertiary pe-8 ps-9 text-body text-content placeholder:text-content-faint focus:outline-none focus:ring-1 focus:ring-accent" + /> + {query ? ( + + ) : null} + + {listOpen && (loading || results.length > 0 || answered) ? ( +
    + {loading && results.length === 0 ? ( +

    {t('common.loading')}

    + ) : null} + {!loading && results.length === 0 && answered ? ( +

    {t('roadtrip.poi.manualNoResults')}

    + ) : null} + {results.map((hit, i) => ( + + ))} +
    + ) : null} +
    + + {/* Where it goes. Preselected from the projection, and changeable: a drive that + passes the same junction twice can only be guessed at once, and the traveller + is the one who knows which time they mean to stop. + + Absent altogether when there is nowhere to put anything, rather than an empty + control: a trip whose days hold fewer than two stops has no drive yet. */} + {legs.length > 0 || appendTarget ? ( +
    + + {t('roadtrip.poi.addBetween')} + + {legs.length > 0 ? ( + setChosenLeg(String(value))} + options={legs.map(l => ({ value: l.value, label: l.label, badge: l.badge }))} + size="sm" + menuFit="content" + /> + ) : ( +

    + {t('roadtrip.poi.manualAppend', { number: appendDayNumber })} +

    + )} + {/* Said quietly rather than refused. It is the reason the leg above may be the + wrong one, and the only way the reader can know to change it. */} + {picked && offRouteKm !== null && offRouteKm > OFF_ROUTE_NOTE_KM ? ( +

    + {t('roadtrip.poi.manualOffRoute', { distance: formatDistance(offRouteKm, distanceUnit) })} +

    + ) : null} +
    + ) : null} +
    +
    + ) +} diff --git a/client/src/components/Roadtrip/manualStop.ts b/client/src/components/Roadtrip/manualStop.ts new file mode 100644 index 000000000..b3b2d8bd1 --- /dev/null +++ b/client/src/components/Roadtrip/manualStop.ts @@ -0,0 +1,30 @@ +/** + * What the manual-add dialog and the planner agree on. + * + * Their own module rather than the dialog's, so the planner hook can take the shapes + * without importing a component: the file stays free of React and of the api client, the + * way `roadtripModel` and `corridor` do. + */ + +/** Where a stop added by hand goes: a card of the rail, and the position in its chain. */ +export interface ManualStopTarget { + dayId: number + /** Counted along the card's stops, the same index space a corridor hit is placed at. */ + position: number + /** How far the place sits from the road being driven; zero when nothing has routed. */ + offRouteKm: number +} + +/** A place chosen by hand, in the shape the stop popup and the place write want. */ +export interface ManualStopPlace { + name: string + lat: number + lng: number + address: string | null + website: string | null + phone: string | null + /** Empty for a provider with no id of its own, which is what the rest of TREK does. */ + osm_id: string + /** The kind the popup starts on, when the provider named one. */ + category: string | null +} diff --git a/client/src/components/Roadtrip/useRoadtripCorridor.test.ts b/client/src/components/Roadtrip/useRoadtripCorridor.test.ts index 6ddf8dcd4..85b4fd522 100644 --- a/client/src/components/Roadtrip/useRoadtripCorridor.test.ts +++ b/client/src/components/Roadtrip/useRoadtripCorridor.test.ts @@ -3,8 +3,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { CorridorPoi, CorridorSearch } from './useCorridorPois' import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes' -const { useCorridorPois } = vi.hoisted(() => ({ useCorridorPois: vi.fn() })) +const { useCorridorPois, stored } = vi.hoisted(() => ({ + useCorridorPois: vi.fn(), + // The stored road trip preferences, read through the same selector the app uses so + // `useVehicleRange` does its own folding of the four vehicle states. + stored: { settings: {} as { roadtrip_vehicle?: string } }, +})) vi.mock('./useCorridorPois', () => ({ useCorridorPois })) +vi.mock('../../hooks/useRoadtripSettings', () => ({ + useRoadtripSettings: (select: (settings: typeof stored.settings) => unknown) => select(stored.settings), +})) import { useRoadtripCorridor } from './useRoadtripCorridor' @@ -54,6 +62,7 @@ function searchWith(results: CorridorPoi[]): CorridorSearch { beforeEach(() => { useCorridorPois.mockReset() + stored.settings = {} }) describe('useRoadtripCorridor', () => { @@ -160,6 +169,85 @@ describe('useRoadtripCorridor', () => { expect(result.current.visible.map(p => p.osm_id)).toEqual(['near']) }) + it('FE-ROADTRIP-CORRIDORSTATE-030: clearing drops the hits and every narrowing set on them', () => { + // Nothing could do this before: a search that turned up the wrong thing kept its + // list and its pins, and the way out was switching to the day plan and back. + const state = searchWith([poi({ osm_id: 'a', name: 'Aral' })]) + useCorridorPois.mockReturnValue(state) + const { result } = renderHook(() => useRoadtripCorridor(routes([day()]))) + + act(() => { + result.current.setNameFilter('aral') + result.current.setSection({ dayId: 1, kind: 'stop', index: 0 }) + result.current.setSocketFilter('ccs') + result.current.setMinKw(150) + }) + act(() => { result.current.clear() }) + + expect(state.clear).toHaveBeenCalledTimes(1) + expect(result.current.nameFilter).toBe('') + expect(result.current.section).toBeNull() + expect(result.current.socketFilter).toBe('') + expect(result.current.minKw).toBe(0) + // The question stays: clearing the categories too would make asking the same thing + // again a handful of clicks. + expect(result.current.categories).toEqual(['fuel']) + }) + + it('FE-ROADTRIP-CORRIDORSTATE-031: an electric car looks for chargers instead of pumps', () => { + useCorridorPois.mockReturnValue(searchWith([])) + stored.settings = { roadtrip_vehicle: 'electric' } + + const { result } = renderHook(() => useRoadtripCorridor(routes([day()]), 42)) + + expect(result.current.categories).toEqual(['charging']) + }) + + it('FE-ROADTRIP-CORRIDORSTATE-032: every other vehicle state keeps the fuel it always had', () => { + // Four states are stored: electric, combustion, an empty string written by the + // dialog, and no answer at all. `useVehicleRange` folds the last two into null, and + // moving the unset case would change the panel for every traveller who never opened + // the driving settings. + useCorridorPois.mockReturnValue(searchWith([])) + for (const roadtrip_vehicle of ['combustion', '', undefined]) { + stored.settings = roadtrip_vehicle === undefined ? {} : { roadtrip_vehicle } + const { result, unmount } = renderHook(() => useRoadtripCorridor(routes([day()]), 42)) + expect(result.current.categories).toEqual(['fuel']) + unmount() + } + }) + + it('FE-ROADTRIP-CORRIDORSTATE-033: a kind the traveller picked is never overruled by the preference', () => { + // The preference is loaded per trip and lands a moment after the panel does. A + // category somebody switched off coming back by itself makes the picker unusable. + useCorridorPois.mockReturnValue(searchWith([])) + const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()]), 42)) + + act(() => { result.current.toggleCategory('rest_area') }) + stored.settings = { roadtrip_vehicle: 'electric' } + rerender() + + expect(result.current.categories).toEqual(['fuel', 'rest_area']) + }) + + it('FE-ROADTRIP-CORRIDORSTATE-034: a preference arriving over a search does not empty it', () => { + // `useCorridorPois` drops its results whenever the categories change, so a late + // preference must not reach either a run in flight or a list already answered. + useCorridorPois.mockReturnValue({ ...searchWith([]), loading: true }) + const running = renderHook(() => useRoadtripCorridor(routes([day()]), 42)) + stored.settings = { roadtrip_vehicle: 'electric' } + running.rerender() + expect(running.result.current.categories).toEqual(['fuel']) + running.unmount() + + stored.settings = {} + useCorridorPois.mockReturnValue(searchWith([poi({ osm_id: 'a', name: 'Aral' })])) + const answered = renderHook(() => useRoadtripCorridor(routes([day()]), 42)) + stored.settings = { roadtrip_vehicle: 'electric' } + answered.rerender() + expect(answered.result.current.categories).toEqual(['fuel']) + }) + it('FE-ROADTRIP-CORRIDORSTATE-021: an anchor belonging to another day narrows nothing', () => { // The section names a day as well as an index, so switching days cannot silently // apply yesterday's break to today's drive. diff --git a/client/src/components/Roadtrip/useRoadtripCorridor.ts b/client/src/components/Roadtrip/useRoadtripCorridor.ts index 2ae2290f9..31e6549c9 100644 --- a/client/src/components/Roadtrip/useRoadtripCorridor.ts +++ b/client/src/components/Roadtrip/useRoadtripCorridor.ts @@ -3,6 +3,7 @@ import { useCorridorPois, type CorridorPoi, type CorridorSearch } from './useCor import { sectionAnchors, insertIndexForAlong } from './roadtripModel' import type { SectionAnchor } from './roadtripModel' import { projectOntoRoute, type LatLng } from './corridor' +import { useVehicleRange } from './useVehicleRange' import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes' // The categories come from the one table now, which is also the only place that knows @@ -54,6 +55,16 @@ export interface RoadtripCorridor { insertIndexFor: (poi: CorridorPoi) => number /** How far along the drive each of the day's stops sits, in the same units as a hit. */ stopsAlongKm: number[] + /** + * Back to "nothing asked yet": the hits go, and with them the pins on the map. + * + * Both halves, deliberately. `search.clear()` alone leaves the four narrowing states + * standing, so a plug type or a minimum power set for the last question would quietly + * shrink the answer to the next one. The categories and the corridor width stay put: + * they are the question rather than the answer, and clearing them would turn asking + * the same thing again into a handful of clicks. + */ + clear: () => void } /** @@ -63,7 +74,7 @@ export interface RoadtripCorridor { * answer if you cannot see which one is on your side of the road — so the results have to * live above both the panel and the map rather than inside the panel. */ -export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor { +export function useRoadtripCorridor(routes: RoadtripRoutes, tripId?: number | string | null): RoadtripCorridor { const [dayId, setDayId] = useState('') const [categories, setCategories] = useState(['fuel']) const [widthKm, setWidthKm] = useState(5) @@ -100,10 +111,42 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor { const search = useCorridorPois(line, categories, widthKm) + /** + * Whether what the panel looks for has been decided yet. + * + * The vehicle is a stored, trip-scoped preference, so it is not known at first render; + * the default below therefore cannot be a `useState` initialiser. Once it has been + * applied (or once the traveller has picked the kinds themselves) it is never + * applied again: a category somebody switched off on purpose coming back by itself + * would make the picker unusable. + */ + const categoriesSeeded = useRef(false) + const toggleCategory = useCallback((key: string) => { + categoriesSeeded.current = true setCategories(prev => (prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key])) }, []) + /** + * An electric car looks for chargers, not for pumps. + * + * Only for `electric`. The preference has four stored states and `useVehicleRange` + * folds three of them into null (a combustion sibling written as an empty string, no + * answer at all, and the moment before the preference has loaded), and none of those + * is a reason to change what a panel looks for. Every traveller who never opened the + * driving settings keeps the fuel they have always had. + */ + const { vehicleKind } = useVehicleRange(tripId) + useEffect(() => { + if (categoriesSeeded.current || vehicleKind !== 'electric') return + categoriesSeeded.current = true + // Never over a running or finished search. `useCorridorPois` drops its results + // whenever the categories change, so a preference that lands a second after the + // panel did would empty a list somebody is already reading. + if (search.loading || search.results.length > 0) return + setCategories(['charging']) + }, [vehicleKind, search.loading, search.results.length]) + // A filter kept across a new search would hide the fresh answer behind the old question. // Cleared when a search STARTS, not whenever results change: hits arrive box by box and // publish as they land, so resetting on every batch wiped out anything typed while the @@ -188,6 +231,29 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor { [stopsAlongKm], ) + /** + * Throwing the answer away, which nothing could do before. + * + * A search that turned up the wrong thing left its hits in the list and its pins on + * the map, and the only way out was switching to the day plan and back. The search's + * own `clear` aborts what is in flight and empties the results (`visible` collapses + * with them and the map re-merges, so no layer needs telling), and the four narrowing + * states go too, because they were set for a question that no longer has an answer. + * + * Pulled out of `search` into its own name rather than depended on as `search.clear`: + * the object is rebuilt on every publish of the hook below, so a dependency on it + * would rebuild this callback on every box that answers, while the function itself + * never changes. + */ + const clearResults = search.clear + const clear = useCallback(() => { + clearResults() + setNameFilter('') + setSection(null) + setSocketFilter('') + setMinKw(0) + }, [clearResults]) + return { dayId: day ? String(day.dayId) : '', setDayId, @@ -211,5 +277,6 @@ export function useRoadtripCorridor(routes: RoadtripRoutes): RoadtripCorridor { visible, insertIndexFor, stopsAlongKm, + clear, } } diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index 769cdef12..b6434fd53 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -277,6 +277,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { setRoadtripStopFill, saveRoadtripLimit, roadtripSettingsLoading, storedAssignments, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, resetDayBoundaries, + manualStopTargetFor, addManualRoadtripStop, routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint, dayBoundaryControls, stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay, highlightedAlternative, setHighlightedAlternative, @@ -730,6 +731,9 @@ function TripPlannerPageDesktop(): React.ReactElement | null { corridor={roadtripCorridor} routes={roadtripRoutes} onAddPoi={can('place_edit', trip) ? handlePoiClick : undefined} + onAddManual={can('place_edit', trip) ? addManualRoadtripStop : undefined} + manualStopTargetFor={manualStopTargetFor} + onFocusPoint={focusRoadtripPoint} /> {/* Under the search, because the limits are read while looking at what the drive is doing rather than set up front. */} diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx index 833343af6..f1ed10d0f 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -17,6 +17,7 @@ import { } from '../../api/client' import { accommodationRepo } from '../../repo/accommodationRepo' import { dayColor } from '../../components/Roadtrip/dayColors' +import type { ManualStopPlace } from '../../components/Roadtrip/manualStop' /** * The road trip half of the planner hook. @@ -134,6 +135,7 @@ const rt = vi.hoisted(() => { visible: [] as Array>, insertIndexFor: vi.fn(() => 1), stopsAlongKm: [] as number[], + clear: vi.fn(), } const alt = { open: null as null | Record, @@ -1262,6 +1264,108 @@ describe('useTripPlanner road trip: dropping a hit where it belongs', () => { }) }) +describe('useTripPlanner road trip: a stop added by hand', () => { + /** One routed day of two stops, and a second day beside it. */ + const routedDay = () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.routes.days = [{ + dayId: 5, + dayNumber: 1, + stops: [{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }], + geometry: [[53.55, 9.99], [53.0, 11.5], [52.52, 13.4]], + }] + } + + const place = (over: Partial = {}): ManualStopPlace => ({ + name: 'Supercharger Dammer Berge', + lat: 52.9, + lng: 11.4, + address: 'A1', + website: null, + phone: null, + osm_id: 'node/9', + category: null, + ...over, + }) + + it('FE-TP-ROAD-075: a hand-picked place is measured onto the drive it is nearest', async () => { + routedDay() + const { result } = await renderRoadtrip() + + // Named by the card the stops are DRAWN on and the position in it, which is the + // index space a stop is placed at, not the one a via is stored in. + expect(result.current.manualStopTargetFor(53.0, 11.5)).toMatchObject({ dayId: 5, position: 1 }) + }) + + it('FE-TP-ROAD-076: a place well off the road is placed all the same', async () => { + // A via is refused past two kilometres and stays refused. The stop this answers for + // is the charger the corridor search missed, which is exactly the one sitting + // further off the drawn line than that: refusing it would refuse the request. + routedDay() + const { result } = await renderRoadtrip() + + const target = result.current.manualStopTargetFor(41.9, 12.5) + expect(target?.offRouteKm).toBeGreaterThan(2) + expect(target).toMatchObject({ dayId: 5 }) + + await act(async () => { await result.current.addRoadtripVia(41.9, 12.5) }) + expect(rt.vias.add).not.toHaveBeenCalled() + }) + + it('FE-TP-ROAD-077: a day with no drawn line is nowhere to measure against', async () => { + routedDay() + rt.routes.days = [{ dayId: 5, dayNumber: 1, stops: [], geometry: [] }] + const { result } = await renderRoadtrip() + + expect(result.current.manualStopTargetFor(53.0, 11.5)).toBeNull() + }) + + it('FE-TP-ROAD-078: adding one by hand opens the popup a corridor hit goes through', async () => { + routedDay() + const { result } = await renderRoadtrip() + + act(() => { + result.current.addManualRoadtripStop(place(), { dayId: 5, position: 1, offRouteKm: 3.2 }) + }) + + expect(result.current.stopDraft).toMatchObject({ dayId: 5, position: 1, dayNumber: 1 }) + expect(result.current.stopDraft?.poi).toMatchObject({ + name: 'Supercharger Dammer Berge', osm_id: 'node/9', offRouteKm: 3.2, alongKm: 0, + }) + // Neither the full form nor a write: the kind of stop and the dwell are still to be + // chosen, in the one dialog that asks them. + expect(result.current.showPlaceForm).toBe(false) + expect(actions.addPlace).not.toHaveBeenCalled() + // A charger is not somewhere to sleep. + expect(result.current.stopDraft?.overnight).toBeUndefined() + }) + + it('FE-TP-ROAD-079: somewhere to sleep opens in overnight mode, the same as a corridor hit', async () => { + routedDay() + const { result } = await renderRoadtrip() + + act(() => { + result.current.addManualRoadtripStop(place({ category: 'hotel' }), { dayId: 5, position: 1, offRouteKm: 0.2 }) + }) + + // The night ends on the next day of the trip, which is what a night usually means. + expect(result.current.stopDraft?.overnight?.defaultEndDayId).toBe(6) + }) + + it('FE-TP-ROAD-080: a reader may not add one by hand either', async () => { + asReader('place_edit') + routedDay() + const { result } = await renderRoadtrip() + + act(() => { + result.current.addManualRoadtripStop(place(), { dayId: 5, position: 1, offRouteKm: 0 }) + }) + + expect(result.current.stopDraft).toBeNull() + }) +}) + /** A stop as the rail DRAWS it: a point on a card, naming the day it is STORED on. */ const drawn = (placeId: number, lat: number, lng: number, ownerDayId: number, ownerIndex: number) => ({ placeId, name: `Stop ${placeId}`, lat, lng, ownerDayId, ownerIndex, diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index fd8c498d5..f5254e560 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -48,6 +48,7 @@ import { reanchorAfterRemove, reanchorByStopOrder, isServiceStopType, refuelStopTypeFor, reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel' +import type { ManualStopPlace, ManualStopTarget } from '../../components/Roadtrip/manualStop' import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup' import type { RoadtripStopType } from '@trek/shared' import { usePlaceSelection } from '../../hooks/usePlaceSelection' @@ -646,7 +647,9 @@ export function useTripPlanner() { tripAccommodations, ) // Lives here rather than in the panel because the map draws what it finds. - const roadtripCorridor = useRoadtripCorridor(roadtripRoutes) + // The trip comes with it for the vehicle: an electric car looks for chargers rather + // than for pumps, and that preference is stored per trip. + const roadtripCorridor = useRoadtripCorridor(roadtripRoutes, tripId) // Applying a track is a long job — a routing round trip per refinement — so it lives // above the dialog: a component that unmounted halfway would leave the day holding // half a chain of vias. @@ -1493,6 +1496,80 @@ export function useTripPlanner() { return best }, [roadtripRoutes.days]) + /** + * Where a place chosen by hand belongs in the drive, as a card and a position in it. + * + * `anchorFor` answers in the space a via is STORED in: the day the anchor stop belongs + * to, and its index there. A stop is placed at a position counted along the card it is + * drawn on, which is the same thing on every day that does not drive past midnight and + * a different one on the days that do (`nightSpill.ts`). Translating between the two + * happens here, once, rather than at whichever surface asked. + * + * Deliberately with no distance limit, unlike `addRoadtripVia`: the place this answers + * for is the charger the corridor search did not find, which is exactly the one sitting + * further off the drawn line than a via is allowed to be. The projection is the default + * the dialog offers, never a gate it applies. + */ + const manualStopTargetFor = useCallback((lat: number, lng: number): ManualStopTarget | null => { + const anchor = anchorFor(lat, lng) + if (!anchor) return null + for (const day of roadtripRoutes.days) { + const at = day.stops.findIndex(stop => stop.ownerDayId === anchor.dayId && stop.ownerIndex === anchor.afterIndex) + if (at >= 0) return { dayId: day.dayId, position: at + 1, offRouteKm: anchor.offRouteKm } + } + // No card draws that stop, which happens while the rail is between rebuilds. Its own + // numbers are the best answer there is, and `roadtripInsertion` measures them against + // the card again when the stop actually lands. + return { dayId: anchor.dayId, position: anchor.afterIndex + 1, offRouteKm: anchor.offRouteKm } + }, [anchorFor, roadtripRoutes.days]) + + /** + * A place looked up by hand, on its way to the popup a corridor hit goes through. + * + * Not a write of its own. The popup is where the kind of stop and the dwell are + * decided, it defaults both from the category, and everything after it (the place, the + * assignment at the right position, the via re-anchoring, the re-route) is already + * right there and pinned by tests. The search finding a charger and somebody typing its + * name must not become two ways of adding one. + */ + const addManualRoadtripStop = useCallback((place: ManualStopPlace, target: ManualStopTarget) => { + if (!can('place_edit', trip)) return + const card = roadtripRoutes.days.find(day => day.dayId === target.dayId) + // Through `roadtripInsertion` like every other path into this popup, so a night drive + // files the stop under the day it is stored on rather than under the card it is drawn + // on. A day with nothing drawn yet has no card to measure against and keeps the + // position it was given. + const insert = (card && roadtripInsertion(card, target.position)) || { dayId: target.dayId, position: target.position } + const category = place.category ?? '' + setStopDraft({ + poi: { + osm_id: place.osm_id, + name: place.name, + lat: place.lat, + lng: place.lng, + category, + poi_type: category, + address: place.address, + website: place.website, + phone: place.phone, + opening_hours: null, + cuisine: null, + source: 'manual', + offRouteKm: target.offRouteKm, + // Nothing measured this one along the drive: its position came from the leg it + // was put on rather than from a distance. No arrival time is offered either, for + // the same reason: a guess printed as a clock reading is worse than a blank. + alongKm: 0, + }, + ...insert, + dayNumber: card?.dayNumber ?? days.find(day => day.id === insert.dayId)?.day_number ?? 0, + // Only for somewhere you could sleep, and the same branch the corridor path takes. + // A charger never is one; diverging here would be a second answer to a question + // that already has one. + ...(isOvernightCategory(category) ? { overnight: overnightOptions(insert.dayId) } : {}), + }) + }, [roadtripRoutes.days, days, overnightOptions, can, trip]) + const addRoadtripVia = useCallback(async (lat: number, lng: number) => { if (!can('day_edit', trip)) return const best = anchorFor(lat, lng) @@ -2267,6 +2344,7 @@ export function useTripPlanner() { roadtripSettingsLoading: !roadtripPreferencesState.ready && !roadtripPreferencesState.failed, saveRoadtripLimit: roadtripPreferencesState.ready && can('day_edit', trip) ? saveRoadtripLimit : undefined, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, dayBoundaryControls, resetDayBoundaries, + manualStopTargetFor, addManualRoadtripStop, refuel, askRefuel, acceptRefuel, routeAlternatives, askRouteAlternatives, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint, stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay, diff --git a/shared/src/i18n/ar/roadtrip.ts b/shared/src/i18n/ar/roadtrip.ts index 1f3cdf9e2..fff52aed7 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -193,6 +193,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} عن المسار', 'roadtrip.poi.atStart': 'قرب نقطة البداية', 'roadtrip.poi.alongRoute': 'بعد {distance}', + 'roadtrip.poi.clearResults': 'مسح النتائج', + 'roadtrip.poi.addManual': 'إضافة يدوية', + 'roadtrip.poi.manualHint': + 'لا يجد البحث عن الأماكن كل محطة شحن أو وقود. ابحث عن واحدة بالاسم وحدّد موضعها في الرحلة.', + 'roadtrip.poi.addBetween': 'الإضافة بين محطتين', + 'roadtrip.poi.manualOffRoute': '{distance} عن المسار، لذا تحقق من المرحلة التي تناسبها.', + 'roadtrip.poi.manualAppend': 'لا يوجد مسار بعد، لذا تُضاف في نهاية اليوم {number}.', + 'roadtrip.poi.manualNoResults': 'لم يُعثر على أي مكان.', 'roadtrip.summary.distance': 'المسافة', 'roadtrip.summary.driving': 'مدة القيادة', 'roadtrip.alt.ask': 'مسارات أخرى', diff --git a/shared/src/i18n/br/roadtrip.ts b/shared/src/i18n/br/roadtrip.ts index dc2e04542..d5ec484f9 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -198,6 +198,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} fora da rota', 'roadtrip.poi.atStart': 'perto do início', 'roadtrip.poi.alongRoute': 'após {distance}', + 'roadtrip.poi.clearResults': 'Limpar resultados', + 'roadtrip.poi.addManual': 'Adicionar manualmente', + 'roadtrip.poi.manualHint': + 'A busca de locais não encontra todos os pontos de recarga nem todos os postos. Procure um pelo nome e diga em que ponto do trajeto ele entra.', + 'roadtrip.poi.addBetween': 'Adicionar entre', + 'roadtrip.poi.manualOffRoute': '{distance} fora da rota, então confira a que trecho ele pertence.', + 'roadtrip.poi.manualAppend': 'Nenhuma rota foi calculada ainda, então ele entra no fim do dia {number}.', + 'roadtrip.poi.manualNoResults': 'Nenhum local encontrado.', 'roadtrip.summary.distance': 'Distância', 'roadtrip.summary.driving': 'Tempo de direção', 'roadtrip.alt.ask': 'Outras rotas', diff --git a/shared/src/i18n/ca/roadtrip.ts b/shared/src/i18n/ca/roadtrip.ts index d97841fba..a8dab1538 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -200,6 +200,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} fora de la ruta', 'roadtrip.poi.atStart': 'a prop de l’inici', 'roadtrip.poi.alongRoute': 'després de {distance}', + 'roadtrip.poi.clearResults': 'Esborra resultats', + 'roadtrip.poi.addManual': 'Afegeix manualment', + 'roadtrip.poi.manualHint': + 'La cerca de llocs no troba tots els punts de recàrrega ni totes les benzineres. Busca’n un pel nom i digues en quin punt del trajecte va.', + 'roadtrip.poi.addBetween': 'Afegeix entre', + 'roadtrip.poi.manualOffRoute': '{distance} fora de la ruta: comprova a quin trajecte pertany.', + 'roadtrip.poi.manualAppend': 'Encara no hi ha cap ruta calculada, així que va al final del dia {number}.', + 'roadtrip.poi.manualNoResults': 'No s’ha trobat cap lloc.', 'roadtrip.summary.distance': 'Distància', 'roadtrip.summary.driving': 'Temps de conducció', 'roadtrip.alt.ask': 'Altres rutes', diff --git a/shared/src/i18n/cs/roadtrip.ts b/shared/src/i18n/cs/roadtrip.ts index 12f597426..b6ff5b5e0 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -196,6 +196,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} od trasy', 'roadtrip.poi.atStart': 'blízko startu', 'roadtrip.poi.alongRoute': 'po {distance}', + 'roadtrip.poi.clearResults': 'Vymazat výsledky', + 'roadtrip.poi.addManual': 'Přidat ručně', + 'roadtrip.poi.manualHint': + 'Vyhledávání míst nenajde každou nabíječku nebo pumpu. Najděte ji podle názvu a určete, kam na cestě patří.', + 'roadtrip.poi.addBetween': 'Přidat mezi', + 'roadtrip.poi.manualOffRoute': '{distance} od trasy, ověřte proto, do kterého úseku patří.', + 'roadtrip.poi.manualAppend': 'Zatím není spočítaná žádná trasa, přidá se proto na konec dne {number}.', + 'roadtrip.poi.manualNoResults': 'Žádná místa nenalezena.', 'roadtrip.summary.distance': 'Vzdálenost', 'roadtrip.summary.driving': 'Doba jízdy', 'roadtrip.alt.ask': 'Jiné trasy', diff --git a/shared/src/i18n/de/roadtrip.ts b/shared/src/i18n/de/roadtrip.ts index d19f2fc89..9f8af3219 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -195,6 +195,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} abseits der Route', 'roadtrip.poi.atStart': 'am Startpunkt', 'roadtrip.poi.alongRoute': 'nach {distance}', + 'roadtrip.poi.clearResults': 'Ergebnisse löschen', + 'roadtrip.poi.addManual': 'Selbst hinzufügen', + 'roadtrip.poi.manualHint': + 'Die Ortssuche findet nicht jede Ladesäule und nicht jede Tankstelle. Suche einen Ort per Name und lege fest, wo unterwegs er hingehört.', + 'roadtrip.poi.addBetween': 'Einfügen zwischen', + 'roadtrip.poi.manualOffRoute': '{distance} abseits der Route, prüfe also, zu welcher Etappe er gehört.', + 'roadtrip.poi.manualAppend': 'Noch ist keine Route berechnet, also kommt der Stopp ans Ende von Tag {number}.', + 'roadtrip.poi.manualNoResults': 'Keine Orte gefunden.', 'roadtrip.summary.distance': 'Strecke', 'roadtrip.summary.driving': 'Fahrzeit', 'roadtrip.alt.ask': 'Andere Wege', diff --git a/shared/src/i18n/en/roadtrip.ts b/shared/src/i18n/en/roadtrip.ts index 607f13b0d..754c158bf 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -192,6 +192,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} off the route', 'roadtrip.poi.atStart': 'near the start', 'roadtrip.poi.alongRoute': 'after {distance}', + 'roadtrip.poi.clearResults': 'Clear results', + 'roadtrip.poi.addManual': 'Add manually', + 'roadtrip.poi.manualHint': + 'The place search does not find every charger or pump. Look one up by name and say where on the drive it goes.', + 'roadtrip.poi.addBetween': 'Add between', + 'roadtrip.poi.manualOffRoute': '{distance} from the route, so check which leg it belongs on.', + 'roadtrip.poi.manualAppend': 'Nothing has routed yet, so it goes at the end of day {number}.', + 'roadtrip.poi.manualNoResults': 'No places found.', 'roadtrip.summary.distance': 'Distance', 'roadtrip.summary.driving': 'Driving time', 'roadtrip.alt.ask': 'Other ways', diff --git a/shared/src/i18n/es/roadtrip.ts b/shared/src/i18n/es/roadtrip.ts index f9f7353a6..92b1e29c4 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -199,6 +199,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} fuera de la ruta', 'roadtrip.poi.atStart': 'cerca del inicio', 'roadtrip.poi.alongRoute': 'tras {distance}', + 'roadtrip.poi.clearResults': 'Borrar resultados', + 'roadtrip.poi.addManual': 'Añadir a mano', + 'roadtrip.poi.manualHint': + 'La búsqueda de lugares no encuentra todos los puntos de recarga ni todas las gasolineras. Busca uno por su nombre e indica en qué punto del recorrido va.', + 'roadtrip.poi.addBetween': 'Añadir entre', + 'roadtrip.poi.manualOffRoute': '{distance} fuera de la ruta: comprueba a qué tramo pertenece.', + 'roadtrip.poi.manualAppend': 'Aún no hay ninguna ruta calculada, así que va al final del día {number}.', + 'roadtrip.poi.manualNoResults': 'No se encontró ningún lugar.', 'roadtrip.summary.distance': 'Distancia', 'roadtrip.summary.driving': 'Tiempo de conducción', 'roadtrip.alt.ask': 'Otras rutas', diff --git a/shared/src/i18n/fr/roadtrip.ts b/shared/src/i18n/fr/roadtrip.ts index 1c9a81abf..4d066d88c 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -199,6 +199,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} hors itinéraire', 'roadtrip.poi.atStart': 'près du départ', 'roadtrip.poi.alongRoute': 'après {distance}', + 'roadtrip.poi.clearResults': 'Effacer les résultats', + 'roadtrip.poi.addManual': 'Ajouter manuellement', + 'roadtrip.poi.manualHint': + 'La recherche de lieux ne trouve pas toutes les bornes ni toutes les stations-service. Cherchez-en une par son nom et indiquez où elle se place sur le trajet.', + 'roadtrip.poi.addBetween': 'Ajouter entre', + 'roadtrip.poi.manualOffRoute': '{distance} de l’itinéraire, vérifiez donc sur quel trajet il se place.', + 'roadtrip.poi.manualAppend': 'Aucun itinéraire n’est encore calculé, il ira donc à la fin du jour {number}.', + 'roadtrip.poi.manualNoResults': 'Aucun lieu trouvé.', 'roadtrip.summary.distance': 'Distance', 'roadtrip.summary.driving': 'Temps de conduite', 'roadtrip.alt.ask': 'Autres itinéraires', diff --git a/shared/src/i18n/gr/roadtrip.ts b/shared/src/i18n/gr/roadtrip.ts index 1fc7dc20d..d3f61ca3e 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -201,6 +201,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} από τη διαδρομή', 'roadtrip.poi.atStart': 'κοντά στην αφετηρία', 'roadtrip.poi.alongRoute': 'μετά από {distance}', + 'roadtrip.poi.clearResults': 'Καθαρισμός αποτελεσμάτων', + 'roadtrip.poi.addManual': 'Χειροκίνητη προσθήκη', + 'roadtrip.poi.manualHint': + 'Η αναζήτηση τοποθεσιών δεν βρίσκει κάθε φορτιστή ή πρατήριο καυσίμων. Βρείτε ένα με το όνομά του και δηλώστε πού μπαίνει στη διαδρομή.', + 'roadtrip.poi.addBetween': 'Προσθήκη ανάμεσα', + 'roadtrip.poi.manualOffRoute': '{distance} από τη διαδρομή, οπότε ελέγξτε σε ποιο σκέλος ανήκει.', + 'roadtrip.poi.manualAppend': 'Δεν έχει υπολογιστεί ακόμη διαδρομή, οπότε μπαίνει στο τέλος της ημέρας {number}.', + 'roadtrip.poi.manualNoResults': 'Δεν βρέθηκαν τοποθεσίες.', 'roadtrip.summary.distance': 'Απόσταση', 'roadtrip.summary.driving': 'Χρόνος οδήγησης', 'roadtrip.alt.ask': 'Άλλες διαδρομές', diff --git a/shared/src/i18n/hu/roadtrip.ts b/shared/src/i18n/hu/roadtrip.ts index d38b07667..916aa0e1a 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -197,6 +197,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} az útvonaltól', 'roadtrip.poi.atStart': 'a kiindulás közelében', 'roadtrip.poi.alongRoute': '{distance} után', + 'roadtrip.poi.clearResults': 'Találatok törlése', + 'roadtrip.poi.addManual': 'Hozzáadás kézzel', + 'roadtrip.poi.manualHint': + 'A helykeresés nem talál meg minden töltőt vagy benzinkutat. Keresd meg név szerint, és add meg, hová kerüljön az úton.', + 'roadtrip.poi.addBetween': 'Beszúrás két megálló közé', + 'roadtrip.poi.manualOffRoute': '{distance} az útvonaltól, ezért nézd meg, melyik szakaszhoz tartozik.', + 'roadtrip.poi.manualAppend': 'Még nincs kiszámolt útvonal, ezért a(z) {number}. nap végére kerül.', + 'roadtrip.poi.manualNoResults': 'Nincs találat.', 'roadtrip.summary.distance': 'Távolság', 'roadtrip.summary.driving': 'Vezetési idő', 'roadtrip.alt.ask': 'Más útvonalak', diff --git a/shared/src/i18n/id/roadtrip.ts b/shared/src/i18n/id/roadtrip.ts index 1fff5806b..37c9d557b 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -199,6 +199,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} dari rute', 'roadtrip.poi.atStart': 'dekat titik awal', 'roadtrip.poi.alongRoute': 'setelah {distance}', + 'roadtrip.poi.clearResults': 'Hapus hasil', + 'roadtrip.poi.addManual': 'Tambah manual', + 'roadtrip.poi.manualHint': + 'Pencarian tempat tidak menemukan setiap pengisian daya atau pompa bahan bakar. Cari satu menurut nama dan tentukan di bagian perjalanan mana ia ditempatkan.', + 'roadtrip.poi.addBetween': 'Tambahkan di antara', + 'roadtrip.poi.manualOffRoute': '{distance} dari rute, jadi periksa ia masuk ke ruas yang mana.', + 'roadtrip.poi.manualAppend': 'Belum ada rute yang dihitung, jadi ia masuk di akhir hari {number}.', + 'roadtrip.poi.manualNoResults': 'Tidak ada tempat yang ditemukan.', 'roadtrip.summary.distance': 'Jarak', 'roadtrip.summary.driving': 'Waktu berkendara', 'roadtrip.alt.ask': 'Rute lain', diff --git a/shared/src/i18n/it/roadtrip.ts b/shared/src/i18n/it/roadtrip.ts index 0fb7ff695..20917f1c5 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -199,6 +199,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} fuori percorso', 'roadtrip.poi.atStart': 'vicino alla partenza', 'roadtrip.poi.alongRoute': 'dopo {distance}', + 'roadtrip.poi.clearResults': 'Svuota risultati', + 'roadtrip.poi.addManual': 'Aggiungi a mano', + 'roadtrip.poi.manualHint': + 'La ricerca dei luoghi non trova ogni colonnina o distributore. Cercane uno per nome e indica dove si inserisce nel viaggio.', + 'roadtrip.poi.addBetween': 'Inserisci tra', + 'roadtrip.poi.manualOffRoute': '{distance} fuori percorso: controlla a quale tratto appartiene.', + 'roadtrip.poi.manualAppend': 'Nessun percorso è ancora calcolato, quindi va alla fine del giorno {number}.', + 'roadtrip.poi.manualNoResults': 'Nessun luogo trovato.', 'roadtrip.summary.distance': 'Distanza', 'roadtrip.summary.driving': 'Tempo di guida', 'roadtrip.alt.ask': 'Altri percorsi', diff --git a/shared/src/i18n/ja/roadtrip.ts b/shared/src/i18n/ja/roadtrip.ts index e94f710df..dd0b3707b 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -196,6 +196,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': 'ルートから {distance}', 'roadtrip.poi.atStart': '出発地の近く', 'roadtrip.poi.alongRoute': '{distance} 地点', + 'roadtrip.poi.clearResults': '結果を消去', + 'roadtrip.poi.addManual': '手動で追加', + 'roadtrip.poi.manualHint': + '場所の検索では、すべての充電スポットや給油所は見つかりません。名前で探して、道中のどこに入れるかを指定してください。', + 'roadtrip.poi.addBetween': '追加する区間', + 'roadtrip.poi.manualOffRoute': 'ルートから {distance} 離れています。どの区間に入れるか確認してください。', + 'roadtrip.poi.manualAppend': 'まだルートがないため、{number} 日目の最後に追加します。', + 'roadtrip.poi.manualNoResults': '場所が見つかりませんでした。', 'roadtrip.summary.distance': '距離', 'roadtrip.summary.driving': '運転時間', 'roadtrip.alt.ask': '別のルート', diff --git a/shared/src/i18n/ko/roadtrip.ts b/shared/src/i18n/ko/roadtrip.ts index e6699fd73..d10f31031 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -195,6 +195,13 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '경로에서 {distance}', 'roadtrip.poi.atStart': '출발지 근처', 'roadtrip.poi.alongRoute': '{distance} 지점', + 'roadtrip.poi.clearResults': '결과 지우기', + 'roadtrip.poi.addManual': '직접 추가', + 'roadtrip.poi.manualHint': '장소 검색이 모든 충전소나 주유소를 찾아내지는 못합니다. 이름으로 직접 찾아 주행 중 어디에 들어갈지 지정하세요.', + 'roadtrip.poi.addBetween': '사이에 추가', + 'roadtrip.poi.manualOffRoute': '경로에서 {distance} 떨어져 있으니 어느 구간에 속하는지 확인하세요.', + 'roadtrip.poi.manualAppend': '아직 계산된 경로가 없어 {number}일차 마지막에 추가됩니다.', + 'roadtrip.poi.manualNoResults': '장소를 찾지 못했습니다.', 'roadtrip.summary.distance': '거리', 'roadtrip.summary.driving': '운전 시간', 'roadtrip.alt.ask': '다른 경로', diff --git a/shared/src/i18n/nl/roadtrip.ts b/shared/src/i18n/nl/roadtrip.ts index c3bc29768..82ceb0c08 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -199,6 +199,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} van de route', 'roadtrip.poi.atStart': 'bij het startpunt', 'roadtrip.poi.alongRoute': 'na {distance}', + 'roadtrip.poi.clearResults': 'Resultaten wissen', + 'roadtrip.poi.addManual': 'Handmatig toevoegen', + 'roadtrip.poi.manualHint': + 'De plaatszoekfunctie vindt niet elk laadpunt of tankstation. Zoek er zelf een op naam en geef aan waar in de rit hij hoort.', + 'roadtrip.poi.addBetween': 'Toevoegen tussen', + 'roadtrip.poi.manualOffRoute': '{distance} van de route, controleer dus bij welke rit hij hoort.', + 'roadtrip.poi.manualAppend': 'Er is nog geen route berekend, dus hij komt aan het einde van dag {number}.', + 'roadtrip.poi.manualNoResults': 'Geen plaatsen gevonden.', 'roadtrip.summary.distance': 'Afstand', 'roadtrip.summary.driving': 'Rijtijd', 'roadtrip.alt.ask': 'Andere routes', diff --git a/shared/src/i18n/pl/roadtrip.ts b/shared/src/i18n/pl/roadtrip.ts index 53b796e0d..a507cdbc0 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -197,6 +197,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} od trasy', 'roadtrip.poi.atStart': 'blisko startu', 'roadtrip.poi.alongRoute': 'po {distance}', + 'roadtrip.poi.clearResults': 'Wyczyść wyniki', + 'roadtrip.poi.addManual': 'Dodaj ręcznie', + 'roadtrip.poi.manualHint': + 'Wyszukiwanie miejsc nie znajdzie każdej ładowarki ani stacji paliw. Wyszukaj ją po nazwie i wskaż, gdzie na trasie ma się znaleźć.', + 'roadtrip.poi.addBetween': 'Dodaj między', + 'roadtrip.poi.manualOffRoute': '{distance} od trasy, więc sprawdź, do którego odcinka należy.', + 'roadtrip.poi.manualAppend': 'Nie wyznaczono jeszcze żadnej trasy, więc trafi na koniec dnia {number}.', + 'roadtrip.poi.manualNoResults': 'Nie znaleziono miejsc.', 'roadtrip.summary.distance': 'Dystans', 'roadtrip.summary.driving': 'Czas jazdy', 'roadtrip.alt.ask': 'Inne trasy', diff --git a/shared/src/i18n/ru/roadtrip.ts b/shared/src/i18n/ru/roadtrip.ts index a8a7faf1d..c2d7f39ef 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -199,6 +199,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} от маршрута', 'roadtrip.poi.atStart': 'у начала маршрута', 'roadtrip.poi.alongRoute': 'через {distance}', + 'roadtrip.poi.clearResults': 'Очистить результаты', + 'roadtrip.poi.addManual': 'Добавить вручную', + 'roadtrip.poi.manualHint': + 'Поиск мест находит не каждую зарядку или заправку. Найдите место по названию и укажите, куда в поездке его поставить.', + 'roadtrip.poi.addBetween': 'Добавить между', + 'roadtrip.poi.manualOffRoute': '{distance} от маршрута, проверьте, к какому участку оно относится.', + 'roadtrip.poi.manualAppend': 'Маршрутов пока нет, поэтому место встанет в конце дня {number}.', + 'roadtrip.poi.manualNoResults': 'Места не найдены.', 'roadtrip.summary.distance': 'Расстояние', 'roadtrip.summary.driving': 'Время в пути', 'roadtrip.alt.ask': 'Другие маршруты', diff --git a/shared/src/i18n/sv/roadtrip.ts b/shared/src/i18n/sv/roadtrip.ts index 97cab4a5b..030a7036b 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -196,6 +196,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} från rutten', 'roadtrip.poi.atStart': 'nära starten', 'roadtrip.poi.alongRoute': 'efter {distance}', + 'roadtrip.poi.clearResults': 'Rensa träffar', + 'roadtrip.poi.addManual': 'Lägg till manuellt', + 'roadtrip.poi.manualHint': + 'Platssökningen hittar inte alla laddare eller bensinstationer. Sök upp en på namn och ange var i körningen den ska ligga.', + 'roadtrip.poi.addBetween': 'Lägg till mellan', + 'roadtrip.poi.manualOffRoute': '{distance} från rutten, så kontrollera vilken sträcka den hör till.', + 'roadtrip.poi.manualAppend': 'Ingen rutt är beräknad än, så den läggs sist på dag {number}.', + 'roadtrip.poi.manualNoResults': 'Inga platser hittades.', 'roadtrip.summary.distance': 'Sträcka', 'roadtrip.summary.driving': 'Körtid', 'roadtrip.alt.ask': 'Andra vägar', diff --git a/shared/src/i18n/tr/roadtrip.ts b/shared/src/i18n/tr/roadtrip.ts index 2d7d95985..6468f0e9c 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -198,6 +198,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': 'rotadan {distance}', 'roadtrip.poi.atStart': 'başlangıca yakın', 'roadtrip.poi.alongRoute': '{distance} sonra', + 'roadtrip.poi.clearResults': 'Sonuçları temizle', + 'roadtrip.poi.addManual': 'Elle ekle', + 'roadtrip.poi.manualHint': + 'Yer araması her şarj noktasını veya benzin istasyonunu bulamaz. Adıyla arayın ve sürüşün neresine gireceğini söyleyin.', + 'roadtrip.poi.addBetween': 'Araya ekle', + 'roadtrip.poi.manualOffRoute': 'Rotadan {distance}, bu yüzden hangi etaba ait olduğunu kontrol edin.', + 'roadtrip.poi.manualAppend': 'Henüz rota hesaplanmadı, bu yüzden {number}. günün sonuna eklenir.', + 'roadtrip.poi.manualNoResults': 'Yer bulunamadı.', 'roadtrip.summary.distance': 'Mesafe', 'roadtrip.summary.driving': 'Sürüş süresi', 'roadtrip.alt.ask': 'Başka yollar', diff --git a/shared/src/i18n/uk/roadtrip.ts b/shared/src/i18n/uk/roadtrip.ts index 4a47124e8..fae085096 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -198,6 +198,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '{distance} від маршруту', 'roadtrip.poi.atStart': 'біля початку', 'roadtrip.poi.alongRoute': 'через {distance}', + 'roadtrip.poi.clearResults': 'Очистити результати', + 'roadtrip.poi.addManual': 'Додати вручну', + 'roadtrip.poi.manualHint': + 'Пошук місць знаходить не кожну зарядку чи заправку. Знайдіть потрібну за назвою та вкажіть, де в дорозі вона має бути.', + 'roadtrip.poi.addBetween': 'Додати між', + 'roadtrip.poi.manualOffRoute': '{distance} від маршруту, тож перевірте, до якого відрізка це місце належить.', + 'roadtrip.poi.manualAppend': 'Маршруту ще немає, тож це місце потрапить у кінець дня {number}.', + 'roadtrip.poi.manualNoResults': 'Місць не знайдено.', 'roadtrip.summary.distance': 'Відстань', 'roadtrip.summary.driving': 'Час у дорозі', 'roadtrip.alt.ask': 'Інші маршрути', diff --git a/shared/src/i18n/vi/roadtrip.ts b/shared/src/i18n/vi/roadtrip.ts index 939000bf0..f6762630d 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -197,6 +197,14 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': 'cách lộ trình {distance}', 'roadtrip.poi.atStart': 'gần điểm xuất phát', 'roadtrip.poi.alongRoute': 'sau {distance}', + 'roadtrip.poi.clearResults': 'Xóa kết quả', + 'roadtrip.poi.addManual': 'Thêm thủ công', + 'roadtrip.poi.manualHint': + 'Tìm kiếm địa điểm không tìm ra mọi trạm sạc hay trạm xăng. Hãy tra theo tên và cho biết nó nằm ở đâu trên hành trình.', + 'roadtrip.poi.addBetween': 'Thêm vào giữa', + 'roadtrip.poi.manualOffRoute': 'Cách lộ trình {distance}, hãy kiểm tra xem nó thuộc chặng nào.', + 'roadtrip.poi.manualAppend': 'Chưa có lộ trình nào được tính, nên nó sẽ nằm ở cuối ngày {number}.', + 'roadtrip.poi.manualNoResults': 'Không tìm thấy địa điểm nào.', 'roadtrip.summary.distance': 'Quãng đường', 'roadtrip.summary.driving': 'Thời gian lái xe', 'roadtrip.alt.ask': 'Lộ trình khác', diff --git a/shared/src/i18n/zh-TW/roadtrip.ts b/shared/src/i18n/zh-TW/roadtrip.ts index 1b127b71a..48b444273 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -186,6 +186,13 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '偏離路線 {distance}', 'roadtrip.poi.atStart': '靠近起點', 'roadtrip.poi.alongRoute': '{distance} 處', + 'roadtrip.poi.clearResults': '清除結果', + 'roadtrip.poi.addManual': '手動新增', + 'roadtrip.poi.manualHint': '地點搜尋不一定能找到每個充電站或加油站。請自行用名稱搜尋,並指定它在行程中的位置。', + 'roadtrip.poi.addBetween': '新增於兩站之間', + 'roadtrip.poi.manualOffRoute': '偏離路線 {distance},請確認它屬於哪一段。', + 'roadtrip.poi.manualAppend': '目前尚無路線,因此會加在第 {number} 天的最後。', + 'roadtrip.poi.manualNoResults': '找不到地點。', 'roadtrip.summary.distance': '距離', 'roadtrip.summary.driving': '駕駛時間', 'roadtrip.alt.ask': '其他路線', diff --git a/shared/src/i18n/zh/roadtrip.ts b/shared/src/i18n/zh/roadtrip.ts index f13eccc88..2b7347034 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -186,6 +186,13 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.offRoute': '偏离路线 {distance}', 'roadtrip.poi.atStart': '靠近起点', 'roadtrip.poi.alongRoute': '{distance} 处', + 'roadtrip.poi.clearResults': '清除结果', + 'roadtrip.poi.addManual': '手动添加', + 'roadtrip.poi.manualHint': '地点搜索不会找到所有充电站或加油站。按名称自行搜索,并指定它在行程中的位置。', + 'roadtrip.poi.addBetween': '添加到两站之间', + 'roadtrip.poi.manualOffRoute': '偏离路线 {distance},请确认它属于哪个路段。', + 'roadtrip.poi.manualAppend': '还没有路线,因此它将添加到第 {number} 天的末尾。', + 'roadtrip.poi.manualNoResults': '未找到地点。', 'roadtrip.summary.distance': '距离', 'roadtrip.summary.driving': '驾驶时间', 'roadtrip.alt.ask': '其他路线',