diff --git a/client/src/components/Planner/PlaceFormModal.test.tsx b/client/src/components/Planner/PlaceFormModal.test.tsx index 462903053..acf47eb97 100644 --- a/client/src/components/Planner/PlaceFormModal.test.tsx +++ b/client/src/components/Planner/PlaceFormModal.test.tsx @@ -1,4 +1,4 @@ -// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -072 +// FE-COMP-PLACEFORM-001 to FE-COMP-PLACEFORM-036, FE-PLANNER-PLACEFORM-016 to FE-PLANNER-PLACEFORM-067, plus FE-PLANNER-PLACEFORM-068 to -080 import { render, screen, waitFor, fireEvent, within } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -1695,3 +1695,251 @@ describe('PlaceFormModal remaining branches', () => { }); }); }); + +// ── The road trip's own use of this form: a service stop added by hand ──────── + +/** + * FE-PLANNER-PLACEFORM-073 to -080. + * + * The corridor search reads OpenStreetMap and misses a good share of the chargers + * actually standing at a junction, so "add manually" opens THIS form rather than a + * bespoke one: its typed-ahead search is the one the rest of TREK uses. Three things + * change and nothing else does, which is what the first two tests here pin. + * + * Where the stop goes cannot be decided when the dialog opens, because nothing has been + * chosen yet. It is worked out at the save, from the coordinates being saved. + */ +describe('PlaceFormModal as a road trip service stop', () => { + type Mode = NonNullable['serviceStop']>; + + const serviceStop = (over: Partial = {}): Mode => ({ + days: [ + { dayId: 5, dayNumber: 1, stops: ['Hamburg', 'Bremen', 'Berlin'] }, + { dayId: 6, dayNumber: 2, stops: ['Berlin', 'Dresden'] }, + ], + appendDay: { dayId: 5, dayNumber: 1, position: 3 }, + // Everything east of the twelfth meridian falls on the second day's leg here; the + // real projection is the planner's and has its own suite. + targetFor: (_lat: number, lng: number) => + lng > 12 ? { dayId: 6, position: 1, offRouteKm: 0.4 } : { dayId: 5, position: 1, offRouteKm: 0.2 }, + ...over, + }); + + const withBudgetAddon = () => seedStore(useAddonStore, { + addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], + loaded: true, + }); + + const typeCoords = (lat: string, lng: string) => { + fireEvent.change(screen.getByPlaceholderText(/Latitude/i), { target: { value: lat } }); + fireEvent.change(screen.getByPlaceholderText(/Longitude/i), { target: { value: lng } }); + }; + + const named = (name: string) => { + fireEvent.change(screen.getByPlaceholderText(/e\.g\. Eiffel Tower/i), { target: { value: name } }); + }; + + it('FE-PLANNER-PLACEFORM-073: the ordinary add place keeps its category and its costs', () => { + withBudgetAddon(); + render(); + + expect(screen.getByText('Category')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument(); + expect(screen.queryByText('Kind of stop')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-PLACEFORM-074: a service stop swaps the category for the kind and drops the costs', () => { + withBudgetAddon(); + render(); + + expect(screen.getByText('Kind of stop')).toBeInTheDocument(); + expect(screen.getByText('Time at this stop')).toBeInTheDocument(); + // Nothing has been chosen yet, so the row that asks where on the drive it goes says + // what it needs first. It turns into the list of legs the moment a place arrives. + expect(screen.getByTestId('service-stop-needs-point')).toBeInTheDocument(); + // A petrol stop is not an activity with a budget line, and it is not a taste either. + expect(screen.queryByText('Category')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-PLACEFORM-075: it opens on a kind rather than on none, and says which dialog it is', () => { + render(); + + // A stop left without a kind is a numbered destination that counts in every total, + // which is the very thing this path exists to avoid. + expect(screen.getByRole('button', { name: 'Fuel' })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByRole('button', { name: /10 min/ })).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getAllByText('Add as a stop').length).toBeGreaterThan(0); + }); + + it('FE-PLANNER-PLACEFORM-076: picking a kind also picks how long that kind takes', async () => { + const onSave = vi.fn().mockResolvedValue({ id: 9 }); + render(); + + named('Supercharger'); + fireEvent.click(screen.getByRole('button', { name: 'Charging' })); + fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + stop_type: 'charging', + duration_minutes: 30, + category_id: null, + })); + }); + + it('FE-PLANNER-PLACEFORM-077: where it goes is worked out from the coordinates that are saved', async () => { + const onSave = vi.fn().mockResolvedValue({ id: 9 }); + render(); + + // The form opened on nothing at all. These coordinates arrive afterwards, and they + // are what the position has to follow. + named('Supercharger'); + typeCoords('51.05', '13.73'); + fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + _serviceStop: { dayId: 6, position: 1, offRouteKm: 0.4 }, + })); + }); + + it('FE-PLANNER-PLACEFORM-078: the leg the traveller overrules is the one that lands', async () => { + const onSave = vi.fn().mockResolvedValue({ id: 9 }); + render(); + + named('Supercharger'); + typeCoords('51.05', '13.73'); + fireEvent.click(screen.getByRole('button', { name: /Berlin · Dresden/ })); + fireEvent.click(screen.getByText(/Bremen · Berlin/)); + fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + // Overruled onto another stretch, so the projection's distance does not come + // along: it was measured against the leg the drive guessed, not this one. + _serviceStop: { dayId: 5, position: 2, offRouteKm: null }, + })); + }); + + it('FE-PLANNER-PLACEFORM-079: a place far off the drawn line is noted, never refused', async () => { + const onSave = vi.fn().mockResolvedValue({ id: 9 }); + const far = serviceStop({ targetFor: () => ({ dayId: 5, position: 2, offRouteKm: 18.3 }) }); + render(); + + named('Supercharger'); + typeCoords('41.9', '12.5'); + expect(screen.getByText(/check which leg it belongs on/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + _serviceStop: { dayId: 5, position: 2, offRouteKm: 18.3 }, + })); + }); + + it('FE-PLANNER-PLACEFORM-081: a stop with no coordinates is not filed onto a leg by guesswork', async () => { + const onSave = vi.fn().mockResolvedValue({ id: 9 }); + render(); + + // The premise case: the charger is in no index, so the traveller types the name. It + // cannot be measured onto a road, and picking a leg for it anyway used to file it as + // the first stop of the trip's first day without a word. + named('Supercharger Dammer Berge'); + expect(screen.getByTestId('service-stop-needs-point')).toBeInTheDocument(); + expect(screen.queryByText('Add between')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ _serviceStop: null })); + }); + + it('FE-PLANNER-PLACEFORM-082: a second stop of the same brand saves on the first press', async () => { + // Four hundred kilometres apart and both called Aral, which on a motorway is the + // normal case and is exactly what this path is for. The name check used to refuse + // the first press and demand a second. + seedStore(useTripStore, { + trip: buildTrip({ id: 1 }), + places: [buildPlace({ id: 3, name: 'Aral', lat: 53.1, lng: 8.8 })], + }); + const onSave = vi.fn().mockResolvedValue({ id: 9 }); + render(); + + named('Aral'); + typeCoords('51.05', '13.73'); + fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(screen.queryByRole('button', { name: /Add anyway/i })).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-PLACEFORM-083: the same pump twice over is noted beside the name, not refused', async () => { + seedStore(useTripStore, { + trip: buildTrip({ id: 1 }), + places: [buildPlace({ id: 3, name: 'Aral Dammer Berge', lat: 52.5, lng: 8.2 })], + }); + const onSave = vi.fn().mockResolvedValue({ id: 9 }); + render(); + + named('Tankstelle'); + typeCoords('52.5', '8.2'); + + // The same place standing at the same coordinates is worth saying. It is a note + // while the form is being filled, and the press that saves still saves. + expect(screen.getByText(/is already on this trip/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + }); + + it('FE-PLANNER-PLACEFORM-084: an expense intent left over from a refused save never reaches this form', async () => { + withBudgetAddon(); + const refused = vi.fn().mockRejectedValue(new Error('Server error')); + const onOpenExpense = vi.fn(); + const { rerender } = render( + , + ); + + // An ordinary add place, asked for an expense, that the server refuses. + named('Louvre'); + fireEvent.click(screen.getByRole('button', { name: /Create expense/i })); + await waitFor(() => expect(refused).toHaveBeenCalled()); + expect(onOpenExpense).not.toHaveBeenCalled(); + + const saved = vi.fn().mockResolvedValue({ id: 9 }); + rerender(); + rerender( + , + ); + + named('Supercharger'); + fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + // The one mode whose contract is that costs are gone: an editor opening here would + // file a petrol stop as an activity nobody asked to pay for. + await waitFor(() => expect(saved).toHaveBeenCalled()); + expect(onOpenExpense).not.toHaveBeenCalled(); + }); + + it('FE-PLANNER-PLACEFORM-080: with nothing routed it appends to the day the panel is on', async () => { + const onSave = vi.fn().mockResolvedValue({ id: 9 }); + const unrouted = serviceStop({ days: [], appendDay: { dayId: 5, dayNumber: 1, position: 2 } }); + render(); + + named('Supercharger'); + typeCoords('53.55', '9.99'); + expect(screen.getByText(/it goes at the end of day 1/)).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /^Add$/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalled()); + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + _serviceStop: { dayId: 5, position: 2, offRouteKm: 0 }, + })); + }); +}); diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx index 6d03d90b1..901547203 100644 --- a/client/src/components/Planner/PlaceFormModal.tsx +++ b/client/src/components/Planner/PlaceFormModal.tsx @@ -25,6 +25,9 @@ import type { BookingExpenseRequest } from './BookingCostsSection.types' import type { Place, Category, Assignment, BudgetItem } from '../../types' import { NumericInput } from '../shared/NumericInput' import { PlacesSession } from '../../utils/placesSession' +import ServiceStopSection from '../Roadtrip/ServiceStopSection' +import { DEFAULT_SERVICE_KIND, serviceStopChoice, type ServiceStopMode } from '../Roadtrip/manualStop' +import { STOP_KIND_BY_KEY } from '../Roadtrip/stopKinds' // The submit payload mirrors the form, but lat/lng are parsed to numbers and // category_id is normalised, plus any files chosen before the place existed. @@ -33,6 +36,12 @@ export interface PlaceSubmitData extends Omit void + /** + * Turns this into the form a road trip's service stop is added on: the category + * control becomes the kind of stop, the costs section goes, and a row asks which leg + * of the drive it belongs on. Absent, nothing about the form changes. + */ + serviceStop?: ServiceStopMode | null } @@ -90,17 +105,32 @@ function SourceBadge({ label }: { label: string | null }) { // trip place if it shares the Google Place ID, the (case-insensitive) name, or // near-identical coordinates (~11 m). Mirrors the server-side import dedup. const DUP_COORD_TOLERANCE = 0.0001 +/** + * Which resemblances count as evidence. + * + * The defaults are the ordinary add place and are not to be changed. A stop on a drive + * asks a different question: brand names repeat along a motorway and the map record does + * not, so it turns the name off and the OSM object on. + */ +interface DuplicateRules { + byName?: boolean + byOsmId?: boolean +} function findDuplicatePlace( form: PlaceFormData, - places: { name?: string | null; lat?: number | null; lng?: number | null; google_place_id?: string | null }[], + places: { name?: string | null; lat?: number | null; lng?: number | null; google_place_id?: string | null; osm_id?: string | null }[], + rules: DuplicateRules = {}, ): { name?: string | null } | null { + const { byName = true, byOsmId = false } = rules const name = (form.name || '').trim().toLowerCase() const gid = (form.google_place_id || '').trim() + const osmId = (form.osm_id || '').trim() const lat = form.lat ? Number.parseFloat(form.lat) : null const lng = form.lng ? Number.parseFloat(form.lng) : null for (const p of places || []) { if (gid && p.google_place_id && p.google_place_id === gid) return p - if (name && p.name && p.name.trim().toLowerCase() === name) return p + if (byOsmId && osmId && p.osm_id && p.osm_id === osmId) return p + if (byName && name && p.name && p.name.trim().toLowerCase() === name) return p if ( lat != null && lng != null && p.lat != null && p.lng != null && Math.abs(Number(p.lat) - lat) <= DUP_COORD_TOLERANCE && @@ -114,7 +144,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) { const { isOpen, onClose, onSave, place, prefillCoords, tripId, categories, onCategoryCreated, assignmentId, dayAssignments = [], isMobile = false, - onOpenExpense, + onOpenExpense, serviceStop = null, } = props // Hidden while the addon is off, because the kinds only mean anything to the road trip // rail: on an instance without it they would be six labels that change nothing. @@ -143,6 +173,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) { // user's and survives. See mergeResult. const autoFilledRef = useRef>(new Set()) const [pendingFiles, setPendingFiles] = useState([]) + /** + * The leg of the drive the traveller picked, or empty while the projection's own + * answer stands. Empty rather than seeded, because there is nothing to project onto + * until a place has been chosen and the answer has to follow the coordinates. + */ + const [serviceStopLeg, setServiceStopLeg] = useState('') const fileRef = useRef(null) const searchInputRef = useRef(null) const [acSuggestions, setAcSuggestions] = useState([]) @@ -211,6 +247,18 @@ function usePlaceFormModal(props: PlaceFormModalProps) { stop_type: prefillCoords.stop_type ?? null, duration_minutes: prefillCoords.duration_minutes, }) + } else if (serviceStop) { + // A stop on a drive is a kind and a dwell before it is anything else, so the form + // opens on one rather than on nothing: a service stop left without a kind is a + // numbered destination that counts in every total, which is the very thing this + // path exists to avoid. Read out of the closure rather than watched, because the + // mode is fixed for the life of one opening and a rebuilt drive must not reset a + // half-filled form. + setForm({ + ...DEFAULT_FORM, + stop_type: DEFAULT_SERVICE_KIND, + duration_minutes: STOP_KIND_BY_KEY[DEFAULT_SERVICE_KIND].defaultMinutes, + }) } else { setForm(DEFAULT_FORM) } @@ -227,7 +275,14 @@ function usePlaceFormModal(props: PlaceFormModalProps) { : [], ) setPendingFiles([]) + setServiceStopLeg('') setDuplicateWarning(null) + // A fresh dialog owns no intention either. The ref is armed by a click on the Costs + // section and spent by the save that follows it; a save that never happened leaves it + // armed, and the next opening would consume it for a place nobody linked an expense + // to. In service-stop mode that opening has no Costs section at all, so the editor + // would arrive out of nowhere, on a petrol stop, filed as an activity. + expenseIntentRef.current = null // The column follows whatever the dialog was opened with, not only a search // pick: a POI tapped on the map and a right-click place arrive as // prefillCoords, and editing an existing place arrives as `place`. Without @@ -272,6 +327,19 @@ function usePlaceFormModal(props: PlaceFormModalProps) { // search wants a point; useLocationBias derives both from the same places. const { box: locationBias, point: locationBiasPoint } = useLocationBias() + /** + * What a stop on a drive might be a second copy of, said while the form is being filled. + * + * By the map record and by where it stands, never by its name: two Arals on one + * motorway are two petrol stations, and a check that called them one refused the save + * the first time it was pressed. This one refuses nothing: it is a note beside the + * name, and the reader decides. + */ + const serviceStopDuplicate = useMemo(() => { + if (!serviceStop || place) return null + return findDuplicatePlace(form, places, { byName: false, byOsmId: true })?.name ?? null + }, [serviceStop, place, form, places]) + // Autocomplete fetch — aborts any in-flight request before starting a new one const fetchSuggestions = useCallback(async (query: string) => { if (query.length < 2 || isMapUrl(query)) { @@ -494,6 +562,21 @@ function usePlaceFormModal(props: PlaceFormModalProps) { } } + /** + * The kind of stop, and with it how long that kind usually takes. + * + * Picking a kind is also picking a dwell, until the user says otherwise: a charge is + * not a fuel stop. Only ever called for a kind that is not already on, so a dwell set + * by hand survives a second click on the same pill. + */ + const handleStopKind = useCallback((kind: RoadtripStopType) => { + setForm(prev => ({ ...prev, stop_type: kind, duration_minutes: STOP_KIND_BY_KEY[kind].defaultMinutes })) + }, []) + + const handleStopMinutes = useCallback((minutes: number) => { + setForm(prev => ({ ...prev, duration_minutes: minutes })) + }, []) + const handleCreateCategory = async () => { if (!newCategoryName.trim()) return try { @@ -544,21 +627,34 @@ function usePlaceFormModal(props: PlaceFormModalProps) { } // #1152: only for new places, and only on the first attempt — a second click // (with the warning already showing) is the explicit "add anyway" confirmation. - if (!place && !duplicateWarning) { + // + // Never for a stop on a drive. Most of what that check catches is a repeated name, + // and on a motorway a repeated name is the normal case: the second Aral is four + // hundred kilometres from the first and is a different petrol station. The popup this + // path replaced compared the OSM object for exactly that reason and never gated the + // save on it, only noted it. So the note is shown beside the name while the form is + // being filled (see serviceStopDuplicate), which is the earlier word anyway, and the + // press that saves is the press that saves. + if (!place && !serviceStop && !duplicateWarning) { const dup = findDuplicatePlace(form, places) if (dup) { const dupName = dup.name || form.name setDuplicateWarning(dupName) toast.warning(t('places.duplicateExists', { name: dupName })) + // Nothing was saved, so an expense intent from a previous click is stale, and + // the next plain Save would otherwise open a Costs editor out of nowhere. + expenseIntentRef.current = null return } } setIsSaving(true) try { + const lat = form.lat ? Number.parseFloat(form.lat) : null + const lng = form.lng ? Number.parseFloat(form.lng) : null const payload = { ...form, - lat: form.lat ? Number.parseFloat(form.lat) : null, - lng: form.lng ? Number.parseFloat(form.lng) : null, + lat, + lng, category_id: form.category_id || null, // An explicit null is how a stop stops being a fuel stop; the service reads it // that way rather than as "leave alone", which is what a missing key means. @@ -569,6 +665,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) { ...(!place && form.stop_type && form.duration_minutes ? { duration_minutes: form.duration_minutes } : {}), + // Where on the drive it goes, worked out HERE and not when the dialog opened: a + // stop added by hand has no coordinates at all until a place has been chosen in + // it, so the answer has to follow what is actually being saved. + ...(serviceStop + ? { _serviceStop: serviceStopChoice(serviceStop, lat, lng, serviceStopLeg).placement } + : {}), _pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined, } // #2163: the per-assignment note only travels when an assignment is in @@ -592,6 +694,10 @@ function usePlaceFormModal(props: PlaceFormModalProps) { } onClose() } catch (err: unknown) { + // The save did not happen, so the intent behind it cannot be honoured: the place + // the expense would point at does not exist. Left armed it would ride along to + // whatever the next press saves. + expenseIntentRef.current = null toast.error(err instanceof Error ? err.message : t('places.saveError')) } finally { setIsSaving(false) @@ -673,6 +779,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) { handleCreateExpense, handleEditExpense, handleRemoveExpense, + serviceStop, + serviceStopDuplicate, + serviceStopLeg, + setServiceStopLeg, + handleStopKind, + handleStopMinutes, } } @@ -745,6 +857,12 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { handleCreateExpense, handleEditExpense, handleRemoveExpense, + serviceStop, + serviceStopDuplicate, + serviceStopLeg, + setServiceStopLeg, + handleStopKind, + handleStopMinutes, } = S // Desktop + Collections addon → the saved-place picker on the right. Mobile // always keeps the original single-column form untouched. @@ -760,7 +878,9 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { @@ -895,6 +1015,14 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { )} + {/* A stop on a drive that looks like one already on the trip. Said here, while + the form is being filled, and never as a condition of saving: the press that + saves is the press that saves. */} + {serviceStopDuplicate && ( +

+ {t('roadtrip.stop.duplicate', { name: serviceStopDuplicate })} +

+ )} {/* Description */} @@ -968,7 +1096,24 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { - {/* Category */} + {/* Category, or for a stop on a drive the kind of stop and where it belongs. + + One or the other, never both: refuelling is not a taste, it is a fact about + the place, so it lives in `places.stop_type` and not in the trip's own + editable category list. */} + {serviceStop ? ( + + ) : (
{!showNewCategory ? ( @@ -1018,6 +1163,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
)} + )} {/* Time is per day-assignment: only shown when a single assignment is in context (itinerary edit, or a single-assignment pool edit). Hidden when @@ -1089,8 +1235,11 @@ export default function PlaceFormModal(props: PlaceFormModalProps) { )} {/* Costs — create / view the expense linked to this place (#1298). - Same block, same flow as a booking: save first, then the editor. */} - {isBudgetEnabled && ( + Same block, same flow as a booking: save first, then the editor. + + Never for a stop on a drive: a petrol stop is not an activity with a budget + line, and the fuel it buys is an expense of the trip rather than of a place. */} + {isBudgetEnabled && !serviceStop && ( ({ rowWidth: { value: 0 } })) +// jsdom reports every element as zero by side, so the panel width that decides whether the +// heading and the two actions keep their words can only be driven from here. Zero is what +// the component reads as "not measured yet", which is also the default below. +const { panelWidth } = vi.hoisted(() => ({ panelWidth: { value: 0 } })) vi.mock('../../hooks/useElementSize', () => ({ - useElementSize: () => ({ ref: () => {}, width: rowWidth.value, height: 0 }), + useElementSize: () => ({ ref: () => {}, width: panelWidth.value, height: 0 }), })) -afterEach(() => { rowWidth.value = 0 }) +afterEach(() => { panelWidth.value = 0 }) import RoadtripCorridorPanel from './RoadtripCorridorPanel' import type { CorridorPoi } from './useCorridorPois' @@ -285,7 +285,7 @@ describe('RoadtripCorridorPanel', () => { }) it('FE-ROADTRIP-PANEL-013: while searching it reports progress rather than sitting still', () => { - rowWidth.value = 320 + panelWidth.value = 320 const c = corridor({}, { loading: true, progress: { done: 3, total: 12 } }) wrap() @@ -437,11 +437,10 @@ describe('RoadtripCorridorPanel', () => { 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. + // It opens the place form, which is the planner's to open: the panel asks, and + // nothing is added until a place has been found and saved in there. fireEvent.click(manual) - expect(onAddManual).not.toHaveBeenCalled() - expect(screen.getByLabelText('Search places...')).toBeInTheDocument() + expect(onAddManual).toHaveBeenCalledTimes(1) }) it('FE-ROADTRIP-PANEL-026: with no drive on the trip there is nowhere to add one by hand', () => { @@ -452,7 +451,7 @@ describe('RoadtripCorridorPanel', () => { }) it('FE-ROADTRIP-PANEL-029: a roomy row keeps both words, and the manual one is the short form', () => { - rowWidth.value = 320 + panelWidth.value = 320 wrap( , ) @@ -468,7 +467,7 @@ describe('RoadtripCorridorPanel', () => { it('FE-ROADTRIP-PANEL-030: a narrow row drops the words rather than cutting them short', () => { // "Add manu…" is neither the label nor a shape anybody recognises. Below the // threshold both actions are their icon, and both keep their full names. - rowWidth.value = 150 + panelWidth.value = 150 wrap( , ) @@ -483,9 +482,38 @@ describe('RoadtripCorridorPanel', () => { expect(manual.className).toContain('flex-1') }) + it('FE-ROADTRIP-PANEL-032: a narrow panel gives the heading up before it gives up a control', () => { + // The heading repeats what the column already is, and the day picker beside it is + // the part somebody came here to change. + panelWidth.value = 320 + const { unmount } = wrap() + expect(screen.getByRole('heading', { name: 'Along the route' })).toBeInTheDocument() + unmount() + + panelWidth.value = 150 + wrap() + expect(screen.queryByRole('heading', { name: 'Along the route' })).toBeNull() + // The day is still pickable, which is the whole point of freeing the room. + expect(screen.getByText('Day 1')).toBeInTheDocument() + }) + + it('FE-ROADTRIP-PANEL-033: a narrow panel drops the prompt but keeps every real answer', () => { + panelWidth.value = 150 + const { unmount } = wrap() + // Telling somebody to press the button they are looking at is not worth three + // wrapped lines; the mascot says the same thing. + expect(screen.queryByText('Pick what you need and search.')).toBeNull() + unmount() + + // An answer to something the reader did stays, however narrow the column is. + const filtered = corridor({ nameFilter: 'Shell', visible: [] }, { results: [poi({ osm_id: 'a', name: 'Aral' })] }) + wrap() + expect(screen.getByText('Nothing on the way matches “Shell”.')).toBeInTheDocument() + }) + it('FE-ROADTRIP-PANEL-031: a narrow row loses the words even while a search is running', () => { // The spinner replaces the magnifier, and there is still no text to cut off. - rowWidth.value = 150 + panelWidth.value = 150 const c = corridor({}, { loading: true, progress: { done: 3, total: 12 } }) wrap() diff --git a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx index 57a23936f..dabd0fca5 100644 --- a/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx +++ b/client/src/components/Roadtrip/RoadtripCorridorPanel.tsx @@ -11,11 +11,9 @@ 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' @@ -37,12 +35,10 @@ interface RoadtripCorridorPanelProps { */ 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. + * Opens the place form for a stop the search never found. 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 + onAddManual?: () => void } /** @@ -57,14 +53,17 @@ interface RoadtripCorridorPanelProps { const KW_STEPS = [11, 22, 50, 150] /** - * Below this, the two actions at the foot of the search card are icons alone, in pixels. + * Below this the panel is cramped and stops spending width on words, in pixels. * - * Measured from what they need rather than picked round: two buttons, an eight pixel gap - * and their own padding leave each of them about ninety pixels for a fifteen pixel icon, - * a gap and a word. "Search" and the shortest sensible word beside it fit that; a hair - * under it and the first thing to go is the end of the longer word. + * Measured from what the tightest row needs rather than picked round. The two actions at + * the foot of the search card sit inside two levels of padding, which leaves each of them + * about ninety pixels for a fifteen pixel icon, a gap and a word: "Search" and the + * shortest sensible word beside it fit that, and a hair under it the first thing to go is + * the end of the longer word. The panel's own heading goes at the same point, for the + * same reason and to the same end: what it says is already the name of the column, and + * the room it takes is the room the day picker beside it wants. */ -const ICON_ONLY_BELOW_PX = 200 +const NARROW_PANEL_PX = 260 const SOCKET_LABEL: Record = { type2: 'Type 2', @@ -299,29 +298,23 @@ function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi, onFocusP * conditions. */ export default function RoadtripCorridorPanel({ - corridor, routes, onAddPoi, onFocusPoint, onAddManual, manualStopTargetFor, tripId, canImport, + corridor, routes, onAddPoi, onFocusPoint, onAddManual, 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. + * The column itself, measured, because it is resizable. * - * 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. + * A media query would ask about the window, which is the wrong question: this panel + * can be narrow on a wide screen, so what it may spend on words depends on what it + * actually got. One measurement rather than one per row, so the heading and the two + * actions give way at the same width instead of at two widths a few pixels apart. + * Zero means it has not been measured yet, and that reads as roomy rather than + * cramped so nothing flashes away on the first paint. */ - const [manualOpen, setManualOpen] = useState(false) - /** - * The row the two actions share, measured, because this column is resizable. - * - * A media query would ask about the window, which is the wrong question: the panel - * can be narrow on a wide screen and the labels have to go by what the row itself - * got. Zero means the row has not been measured yet, and that reads as roomy rather - * than cramped so the labels do not flash away on the first paint. - */ - const actionRow = useElementSize() - const iconOnly = actionRow.width > 0 && actionRow.width < ICON_ONLY_BELOW_PX + const panel = useElementSize() + const narrow = panel.width > 0 && panel.width < NARROW_PANEL_PX const dayOptions = routes.days.map(d => ({ value: String(d.dayId), @@ -381,12 +374,18 @@ export default function RoadtripCorridorPanel({ // The two warnings that remain are about a search that did not happen. return ( -
- {/* Header — the panel's name, and the day the question is about. */} +
+ {/* Header: the panel's name, and the day the question is about. + + Pulled at, the name is the first thing to go. It repeats what the column + already is, the day picker beside it is the part somebody came here to change, + and a heading that shortens to an ellipsis says less than no heading at all. */}
-

- {t('roadtrip.poi.title')} -

+ {narrow ? null : ( +

+ {t('roadtrip.poi.title')} +

+ )} {dayOptions.length > 1 ? (
+
{/* 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 @@ -477,7 +476,7 @@ export default function RoadtripCorridorPanel({ {onAddPoi && onAddManual && corridor.day ? ( ) : null}
@@ -689,7 +688,12 @@ export default function RoadtripCorridorPanel({ ? corridor.nameFilter.trim() ? t('roadtrip.poi.noMatch', { name: corridor.nameFilter.trim() }) : t('roadtrip.poi.noneMatchFilters') - : t('roadtrip.poi.empty') + // The one sentence here that nothing has happened yet to explain. + // In a narrow column it wraps over three lines to tell somebody to + // press the button they are already looking at, so the mascot makes + // the point on its own. The other three are answers to something + // the reader did, and those are worth the room at any width. + : narrow ? '' : t('roadtrip.poi.empty') } /> ) : ( @@ -717,18 +721,6 @@ export default function RoadtripCorridorPanel({ )}
- - {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 deleted file mode 100644 index 95a81dc3c..000000000 --- a/client/src/components/Roadtrip/RoadtripManualStopModal.test.tsx +++ /dev/null @@ -1,304 +0,0 @@ -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. -// Hoisted to one object rather than built per call, the way the real hook's useMemo -// hands back the same one until the trip's places move. -const BIAS = { point: { lat: 53, lng: 10, radius: 5000 } } -vi.mock('../../hooks/useLocationBias', () => ({ useLocationBias: () => BIAS })) - -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...') - -/** - * Typing, on the real clock. - * - * Deliberately not on a fake one. A faked timer here has to be installed before the - * dialog mounts, and React's own scheduler and Testing Library's waiting helpers both - * run on the same timers, so the pair deadlocks the worker before a single test - * reports. Every assertion below is reached through `waitFor` instead, and the two - * that have to prove something did NOT happen wait out a real pause. - */ -async function type(value: string): Promise { - await act(async () => { fireEvent.change(queryBox(), { target: { value } }) }) -} - -/** A real pause, long enough for the debounce to have fired if it were going to. */ -const settle = (ms = DEBOUNCE + 200) => act(async () => { await new Promise(resolve => setTimeout(resolve, ms)) }) - -/** The rows of the suggestion list, once the dialog has asked and been answered. */ -const answered = () => waitFor(() => expect(search).toHaveBeenCalled()) - -beforeEach(() => { - search.mockReset() - search.mockResolvedValue({ places: [hit()], source: 'openstreetmap' }) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -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') - await settle() - - expect(search).not.toHaveBeenCalled() - }) - - it('FE-ROADTRIP-MANUAL-002: the search waits for a pause in the typing', async () => { - setup() - await type('Supercharger') - - // Nothing goes out on the keystroke itself. The pause after it is measured on the - // real clock, so the test asks whether the request is debounced at all rather than - // whether the gap is exactly 320 ms: a busy runner can stretch any wall-clock - // window, and a boundary asserted against one is a flake waiting to happen. - expect(search).not.toHaveBeenCalled() - - await answered() - 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() - - await type('Sup') - await type('Super') - await type('Supercharger') - - await answered() - await settle() - 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') - await answered() - - // 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 deleted file mode 100644 index fa6f7f21f..000000000 --- a/client/src/components/Roadtrip/RoadtripManualStopModal.tsx +++ /dev/null @@ -1,407 +0,0 @@ -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]) - - /** - * The search hint, held by content rather than by identity. - * - * `useLocationBias` rebuilds its answer whenever the trip's places change, and the - * effect below has to re-ask when the hint really moves. Depending on the object - * itself makes that a question about identity instead: any caller whose hook hands - * back a fresh literal per render turns the effect into a render loop, because the - * effect sets state on a short query and the next render brings a new object. The - * hint is three numbers, so the key is those three numbers, and the value itself is - * read out of a ref at the moment the request actually goes out. - */ - const biasKey = locationBias ? `${locationBias.lat},${locationBias.lng},${locationBias.radius ?? ''}` : '' - const biasRef = useRef(locationBias) - biasRef.current = locationBias - - // 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, biasRef.current) - .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, biasKey]) - - /** - * 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/RoadtripStopPopup.tsx b/client/src/components/Roadtrip/RoadtripStopPopup.tsx index 230ce3fb6..a48585354 100644 --- a/client/src/components/Roadtrip/RoadtripStopPopup.tsx +++ b/client/src/components/Roadtrip/RoadtripStopPopup.tsx @@ -6,8 +6,8 @@ import StayPortals from './StayPortals' import ChargingInfo from './ChargingInfo' import { useTranslation } from '../../i18n/TranslationContext' import { safeExternalHref } from '../../utils/safeUrl' -import { formatDurationShort } from './roadtripModel' import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds' +import { StopKindChips, StopStayChips } from './StopChips' import type { CorridorPoi } from './useCorridorPois' import type { RoadtripStopType } from '@trek/shared' @@ -25,9 +25,6 @@ import type { RoadtripStopType } from '@trek/shared' // The kinds, their icons, their colours and how long each one usually takes all come // from the one table in stopKinds.ts. -/** How long to stand still, offered as the few answers anyone actually gives. */ -const DWELL_CHOICES = [5, 10, 20, 30, 45, 60] - export interface RoadtripStopDraft { editing?: { placeId: number; dwellMinutes: number; stopType: RoadtripStopType | null; accommodationId?: number; checkIn?: string; checkOut?: string } arrivalTime?: string | null @@ -216,56 +213,23 @@ export default function RoadtripStopPopup({ {t('roadtrip.stop.kind')} -
- {STOP_KINDS.map(({ key, labelKey, Icon }) => { - const on = stopType === key - return ( - - ) - })} -
+ { + setStopType(key) + // Picking a kind is also picking how long it takes, until the user says + // otherwise: a charge is not a fuel stop. Clicking the kind already on + // changes nothing, so a dwell set by hand survives it. + if (!wasChosen) setDwell(STOP_KIND_BY_KEY[key].defaultMinutes) + }} + />
{t('roadtrip.stop.stay')} -
- {DWELL_CHOICES.map(minutes => ( - - ))} -
+
{/* How busy the charging area is and what it charges, while the stop is still diff --git a/client/src/components/Roadtrip/ServiceStopSection.test.tsx b/client/src/components/Roadtrip/ServiceStopSection.test.tsx new file mode 100644 index 000000000..801fc1f9a --- /dev/null +++ b/client/src/components/Roadtrip/ServiceStopSection.test.tsx @@ -0,0 +1,169 @@ +import React from 'react' +import { describe, it, expect, vi } from 'vitest' +import { fireEvent, render, screen } from '../../../tests/helpers/render' +import ServiceStopSection from './ServiceStopSection' +import type { ServiceStopMode } from './manualStop' + +/** + * FE-SERVICESTOP-011..016, 022..024: the place form, asked for a stop on a drive. + * + * What the section itself owes the reader: the two questions a service stop answers, + * the leg it will land on preselected from where the place is, how far off that leg the + * place lies, and a word about a place sitting well off the drawn line, which is noted + * and never refused. The arithmetic underneath has its own suite in `manualStop.test.ts`. + */ + +const mode = (over: Partial = {}): ServiceStopMode => ({ + days: [ + { dayId: 5, dayNumber: 1, stops: ['Hamburg', 'Bremen', 'Berlin'] }, + { dayId: 6, dayNumber: 2, stops: ['Berlin', 'Dresden'] }, + ], + appendDay: { dayId: 5, dayNumber: 1, position: 3 }, + targetFor: () => null, + ...over, +}) + +/** A drive with places on it but no line drawn between them yet. */ +const unrouted = () => mode({ days: [], appendDay: { dayId: 5, dayNumber: 1, position: 2 } }) + +function draw(over: Partial> = {}) { + const onStopType = vi.fn() + const onMinutes = vi.fn() + const onLeg = vi.fn() + render( + , + ) + return { onStopType, onMinutes, onLeg } +} + +describe('ServiceStopSection', () => { + it('FE-SERVICESTOP-011: it asks the two questions a stop on a drive answers', () => { + draw() + + expect(screen.getByText('Kind of stop')).toBeInTheDocument() + expect(screen.getByText('Time at this stop')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Fuel' })).toHaveAttribute('aria-pressed', 'true') + expect(screen.getByRole('button', { name: 'Charging' })).toHaveAttribute('aria-pressed', 'false') + // The kinds are the road trip's own closed set, never the trip's category list. + expect(screen.queryByText('Category')).toBeNull() + }) + + it('FE-SERVICESTOP-012: picking another kind reports it, and picking the same one does not', () => { + const props = draw() + + fireEvent.click(screen.getByRole('button', { name: 'Charging' })) + expect(props.onStopType).toHaveBeenCalledWith('charging') + + // Clicking the kind already on changes nothing, so a dwell set by hand survives it. + props.onStopType.mockClear() + fireEvent.click(screen.getByRole('button', { name: 'Fuel' })) + expect(props.onStopType).not.toHaveBeenCalled() + }) + + it('FE-SERVICESTOP-013: how long it stands is offered, because the schedule chains it', () => { + const props = draw() + + fireEvent.click(screen.getByRole('button', { name: /45 min/ })) + expect(props.onMinutes).toHaveBeenCalledWith(45) + }) + + it('FE-SERVICESTOP-014: the leg on offer is the projected one, and it can be overruled', () => { + const props = draw({ + lat: 51.05, + lng: 13.7, + mode: mode({ targetFor: () => ({ dayId: 6, position: 1, offRouteKm: 0.4 }) }), + }) + + expect(screen.getByText('Add between')).toBeInTheDocument() + // Each row names only the two stops it sits between: the word the caption carries + // does not need repeating down the list. + const trigger = screen.getByRole('button', { name: /Berlin · Dresden/ }) + fireEvent.click(trigger) + fireEvent.click(screen.getByText(/Bremen · Berlin/)) + + expect(props.onLeg).toHaveBeenCalledWith('5:1') + }) + + it('FE-SERVICESTOP-015: a place far from the road is noted rather than refused', () => { + draw({ lat: 41.9, lng: 12.5, mode: mode({ targetFor: () => ({ dayId: 5, position: 2, offRouteKm: 14.8 }) }) }) + + expect(screen.getByText(/check which leg it belongs on/)).toBeInTheDocument() + // Still a leg to land on: the note is the reason to change it, not a refusal. + expect(screen.getByRole('button', { name: /Bremen · Berlin/ })).toBeInTheDocument() + }) + + it('FE-SERVICESTOP-016: with nothing routed it says which day it will be added to', () => { + draw({ lat: 53.55, lng: 9.99, mode: unrouted() }) + + expect(screen.getByText(/it goes at the end of day 1/)).toBeInTheDocument() + }) + + it('FE-SERVICESTOP-022: with no place chosen yet it says so instead of picking a leg', () => { + // The form opens on nothing at all, and a name on its own cannot be put on a drive. + // Silently filing it onto the first leg of the trip is the one thing not to do. + draw() + + expect(screen.getByTestId('service-stop-needs-point')).toBeInTheDocument() + expect(screen.queryByText('Add between')).toBeNull() + expect(screen.queryByRole('button', { name: /Hamburg · Bremen/ })).toBeNull() + }) + + it('FE-SERVICESTOP-023: every leg says how far off it the place lies, nearest first', () => { + draw({ + lat: 50.9, + lng: 10.5, + mode: mode({ + days: [{ + dayId: 5, + dayNumber: 1, + stops: ['Hamburg', 'Bremen', 'Berlin'], + legLines: [ + [{ lat: 50, lng: 10 }, { lat: 50, lng: 11 }], + [{ lat: 51, lng: 10 }, { lat: 51, lng: 11 }], + ], + }], + appendDay: null, + }), + }) + + // The nearest stretch is the one on offer, and it says why it is: the trigger shows + // the preselected row, distance and all. + expect(screen.getByRole('button', { name: /Bremen · Berlin · 11\.1 km off the route/ })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Bremen · Berlin/ })) + // The trigger repeats the preselected row, so the menu itself starts one along. + const rows = screen.getAllByRole('button') + .map(b => b.textContent ?? '') + .filter(text => text.includes('off the route')) + .slice(1) + // Nearest first, so the preselected one is visibly the nearest and an override is an + // informed choice rather than a guess. + expect(rows[0]).toMatch(/Bremen · Berlin · 11\.1 km/) + expect(rows[1]).toMatch(/Hamburg · Bremen · 100\.2 km/) + }) + + it('FE-SERVICESTOP-024: the day the panel is on is offered even once the others have routed', () => { + const props = draw({ + lat: 51.05, + lng: 13.7, + // Days 1 and 2 are drawn; the panel is on a third that is still calculating. + mode: mode({ appendDay: { dayId: 7, dayNumber: 3, position: 2 } }), + }) + + fireEvent.click(screen.getByRole('button', { name: /Hamburg · Bremen/ })) + fireEvent.click(screen.getByText(/Day 3, as stop 3/)) + + expect(props.onLeg).toHaveBeenCalledWith('7:end') + }) +}) diff --git a/client/src/components/Roadtrip/ServiceStopSection.tsx b/client/src/components/Roadtrip/ServiceStopSection.tsx new file mode 100644 index 000000000..c3dfa5d9b --- /dev/null +++ b/client/src/components/Roadtrip/ServiceStopSection.tsx @@ -0,0 +1,150 @@ +import React, { useMemo } from 'react' +import CustomSelect from '../shared/CustomSelect' +import { useTranslation } from '../../i18n/TranslationContext' +import { useSettingsStore } from '../../store/settingsStore' +import { formatDistance } from '../../utils/units' +import { StopKindChips, StopStayChips } from './StopChips' +import { serviceStopChoice, OFF_ROUTE_NOTE_KM, type ServiceStopMode } from './manualStop' +import type { RoadtripStopType } from '@trek/shared' + +/** + * Adding a stop the corridor search never found, inside the place form itself. + * + * 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 the form the rest of TREK adds places with does the job, with three changes: the + * category becomes the kind of stop (a pump is not a taste, it is a fact about the + * place), the costs go (a petrol stop is not an activity with a budget line), and this + * row asks where on the drive it belongs. Everything else about the form is the reason + * to use it, its typed-ahead search above all: that search is the one the whole app + * uses, and it is legible and scrollable. + * + * Which leg the stop lands on is worked out from where the place IS, and only once it + * has been chosen: unlike a corridor hit, nothing here has coordinates until then. + */ + +/** Matches the form's own field labels, so the section reads as part of it. */ +const LABEL = 'block text-sm font-medium text-content-secondary mb-1' +/** The label above already spaces the row, so the pills bring no margin of their own. */ +const ROW = 'flex flex-wrap gap-1.5' +/** + * What holds the pieces of one option apart. + * + * A separator rather than a sentence, and deliberately not a word: the caption above + * already says these are the stretches to add between, so a row only has to name the two + * stops and how far off them the place lies. It also reads the same way in a language + * written right to left, which an arrow would not. + */ +const SEP = ' · ' + +interface ServiceStopSectionProps { + mode: ServiceStopMode + /** The kind chosen, from the form's own state: it is saved as `places.stop_type`. */ + stopType: RoadtripStopType | null + /** Called only for a kind that is not already on, so a dwell set by hand survives. */ + onStopType: (kind: RoadtripStopType) => void + minutes: number + onMinutes: (value: number) => void + /** The leg the traveller picked, or empty while the projection's answer stands. */ + leg: string + onLeg: (value: string) => void + /** Where the place is, as the form has it. Null until one has been chosen. */ + lat: number | null + lng: number | null +} + +export default function ServiceStopSection({ + mode, stopType, onStopType, minutes, onMinutes, leg, onLeg, lat, lng, +}: ServiceStopSectionProps): React.ReactElement { + const { t } = useTranslation() + const distanceUnit = useSettingsStore(s => s.settings.distance_unit) + const choice = useMemo(() => serviceStopChoice(mode, lat, lng, leg), [mode, lat, lng, leg]) + + /** + * The stretches on offer, each naming what it is and how far the place lies from it. + * + * The distance is the whole reason the preselected one is preselected, and until it + * was written down nothing on screen said so: the list looked like a guess to be + * second-guessed rather than a measurement to be overruled. + */ + const options = useMemo(() => [ + ...choice.legs.map(item => ({ + value: item.value, + label: item.offRouteKm === null + ? `${item.from}${SEP}${item.to}` + : `${item.from}${SEP}${item.to}${SEP}${t('roadtrip.poi.offRoute', { + distance: formatDistance(item.offRouteKm, distanceUnit), + })}`, + badge: t('roadtrip.day', { number: item.dayNumber }), + })), + ...(choice.end + ? [{ + // The end of the day the panel is on, which stays reachable however the rest of + // the trip has routed. It names its own day, so it carries no badge repeating it. + value: choice.end.value, + label: t('roadtrip.stop.landsOn', { day: choice.end.dayNumber, position: choice.end.position + 1 }), + }] + : []), + ], [choice, t, distanceUnit]) + + return ( +
+
+ + { if (!wasChosen) onStopType(kind) }} + className={ROW} + /> +
+ +
+ + +
+ + {/* Where it goes. Preselected from the projection, and changeable. + + Nothing to preselect from without a place: a name cannot be measured onto a + road, so the stop is added to the trip's places and the line below says so. */} + {!choice.located ? ( +

+ {t('roadtrip.poi.manualNoCoords')} +

+ ) : choice.legs.length > 0 ? ( +
+ + onLeg(String(value))} + options={options} + size="sm" + menuFit="content" + /> + {/* 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. Measured + against the stretch actually chosen, so overruling the projection moves the + note with it, and a stretch that could not be measured says nothing at all + rather than borrowing a figure from the one the projection landed on. */} + {choice.placement && choice.placement.offRouteKm !== null && choice.placement.offRouteKm > OFF_ROUTE_NOTE_KM ? ( +

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

+ ) : null} +
+ ) : choice.end ? ( +
+ +

+ {t('roadtrip.poi.manualAppend', { number: choice.end.dayNumber })} +

+
+ ) : null} +
+ ) +} diff --git a/client/src/components/Roadtrip/StopChips.tsx b/client/src/components/Roadtrip/StopChips.tsx new file mode 100644 index 000000000..a44e0907b --- /dev/null +++ b/client/src/components/Roadtrip/StopChips.tsx @@ -0,0 +1,81 @@ +import React from 'react' +import { Hourglass } from 'lucide-react' +import { useTranslation } from '../../i18n/TranslationContext' +import { formatDurationShort } from './roadtripModel' +import { STOP_KINDS } from './stopKinds' +import type { RoadtripStopType } from '@trek/shared' + +/** + * The two questions every stop on a drive answers: what kind it is, and how long it + * takes. One row of pills each, wherever they are asked. + * + * The corridor popup asked them first, and the place form asks the same two for a stop + * added by hand. Two copies of the markup would drift exactly the way the six stop kinds + * drifted before `stopKinds.ts` gathered them into one table: a pill in one dialog and a + * disc in the other is the reader's problem, not a detail. So the table is read once and + * the pills are drawn once. + */ + +/** How long to stand still, offered as the few answers anyone actually gives. */ +const DWELL_CHOICES = [5, 10, 20, 30, 45, 60] + +const CHOSEN = 'border-transparent bg-accent font-semibold text-accent-text' +const UNCHOSEN = 'border-edge text-content-secondary hover:border-content-faint hover:text-content' + +/** The row itself. Overridable so a caller whose own label already spaces it can say so. */ +const ROW = 'mt-1.5 flex flex-wrap gap-1.5' + +export function StopKindChips({ value, onPick, className = ROW }: { + value: RoadtripStopType | null + /** Called with the kind clicked, including the one already chosen. */ + onPick: (kind: RoadtripStopType, wasChosen: boolean) => void + className?: string +}): React.ReactElement { + const { t } = useTranslation() + return ( +
+ {STOP_KINDS.map(({ key, labelKey, Icon }) => { + const on = value === key + return ( + + ) + })} +
+ ) +} + +export function StopStayChips({ value, onPick, className = ROW }: { + value: number + onPick: (minutes: number) => void + className?: string +}): React.ReactElement { + return ( +
+ {DWELL_CHOICES.map(minutes => ( + + ))} +
+ ) +} diff --git a/client/src/components/Roadtrip/manualStop.test.ts b/client/src/components/Roadtrip/manualStop.test.ts new file mode 100644 index 000000000..483c106ac --- /dev/null +++ b/client/src/components/Roadtrip/manualStop.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi } from 'vitest' +import { serviceStopChoice, serviceStopLegs, type ServiceStopMode } from './manualStop' + +/** + * FE-SERVICESTOP-001..010, 017..021: where a stop added by hand lands on the drive. + * + * The arithmetic on its own, free of React: which leg the form offers and where the stop + * actually goes, from coordinates that only exist once a place has been chosen. One + * function answers both, so what the select shows and what the save writes cannot + * disagree, and a dropdown naming one leg while the stop lands on another is a lie told + * quietly. + */ + +const mode = (over: Partial = {}): ServiceStopMode => ({ + days: [ + { dayId: 5, dayNumber: 1, stops: ['Hamburg', 'Bremen', 'Berlin'] }, + { dayId: 6, dayNumber: 2, stops: ['Berlin', 'Dresden'] }, + ], + appendDay: { dayId: 5, dayNumber: 1, position: 3 }, + targetFor: () => null, + ...over, +}) + +/** A drive with places on it but no line drawn between them yet. */ +const unrouted = () => mode({ days: [], appendDay: { dayId: 5, dayNumber: 1, position: 2 } }) + +/** Two stretches of one day, an even degree apart, so "nearer" is arithmetic and not luck. */ +const measured = () => mode({ + days: [{ + dayId: 5, + dayNumber: 1, + stops: ['Hamburg', 'Bremen', 'Berlin'], + legLines: [ + [{ lat: 50, lng: 10 }, { lat: 50, lng: 11 }], + [{ lat: 51, lng: 10 }, { lat: 51, lng: 11 }], + ], + }], + appendDay: null, +}) + +describe('serviceStopLegs', () => { + it('FE-SERVICESTOP-001: every leg of every routed day, flat and in driving order', () => { + expect(serviceStopLegs(mode().days).map(l => [l.value, l.position, l.from, l.to])).toEqual([ + ['5:0', 1, 'Hamburg', 'Bremen'], + ['5:1', 2, 'Bremen', 'Berlin'], + ['6:0', 1, 'Berlin', 'Dresden'], + ]) + }) + + it('FE-SERVICESTOP-002: a day with one stop has no leg to sit between', () => { + expect(serviceStopLegs([{ dayId: 5, dayNumber: 1, stops: ['Hamburg'] }])).toEqual([]) + }) + + it('FE-SERVICESTOP-020: a leg with no drawn road reports no distance and keeps its place', () => { + // Null is not zero: zero would say the place stands on that road, and a leg nobody + // could measure would then be offered ahead of every leg somebody could. + const days = [ + { dayId: 5, dayNumber: 1, stops: ['Hamburg', 'Bremen'] }, + { dayId: 6, dayNumber: 2, stops: ['Berlin', 'Dresden'], legLines: [[{ lat: 51, lng: 10 }, { lat: 51, lng: 11 }]] }, + ] + const legs = serviceStopLegs(days, { lat: 50.9, lng: 10.5 }) + + expect(legs.map(l => l.value)).toEqual(['6:0', '5:0']) + expect(legs[1]?.offRouteKm).toBeNull() + }) +}) + +describe('serviceStopChoice', () => { + it('FE-SERVICESTOP-003: with no coordinates nothing is offered and nothing is placed', () => { + // A name on its own cannot be measured onto a road. Picking a leg for it anyway + // filed a petrol stop into the middle of the trip's first day without a word. + const targetFor = vi.fn(() => ({ dayId: 6, position: 1, offRouteKm: 0 })) + const choice = serviceStopChoice(mode({ targetFor }), null, null, '') + + expect(targetFor).not.toHaveBeenCalled() + expect(choice.located).toBe(false) + expect(choice.legs).toEqual([]) + expect(choice.end).toBeNull() + expect(choice.placement).toBeNull() + }) + + it('FE-SERVICESTOP-004: a place is measured onto the drive, and that leg is preselected', () => { + const targetFor = vi.fn(() => ({ dayId: 6, position: 1, offRouteKm: 0.4 })) + const choice = serviceStopChoice(mode({ targetFor }), 51.05, 13.7, '') + + expect(targetFor).toHaveBeenCalledWith(51.05, 13.7) + expect(choice.legValue).toBe('6:0') + expect(choice.placement).toEqual({ dayId: 6, position: 1, offRouteKm: 0.4 }) + }) + + it('FE-SERVICESTOP-005: the leg the traveller picked beats the one the drive guessed', () => { + // A drive that passes the same junction twice can only be guessed at once, and they + // are the one who knows which time they mean to stop. + const targetFor = () => ({ dayId: 6, position: 1, offRouteKm: 0.4 }) + const choice = serviceStopChoice(mode({ targetFor }), 51.05, 13.7, '5:1') + + // No distance: the 0.4 km was measured against the stretch the projection named, + // and this is a different one. A leg with no drawn road of its own says nothing + // rather than borrowing a figure that is about somewhere else. + expect(choice.placement).toEqual({ dayId: 5, position: 2, offRouteKm: null }) + }) + + it('FE-SERVICESTOP-006: a place well off the road is placed all the same', () => { + // The charger this whole path exists for is exactly the one sitting further off the + // drawn line than a via is allowed to be. Refusing it would refuse the request. + const targetFor = () => ({ dayId: 5, position: 2, offRouteKm: 14.8 }) + const choice = serviceStopChoice(mode({ targetFor }), 41.9, 12.5, '') + + expect(choice.placement).toEqual({ dayId: 5, position: 2, offRouteKm: 14.8 }) + }) + + it('FE-SERVICESTOP-007: a leg the drive no longer draws falls back to the first offered', () => { + const choice = serviceStopChoice(mode(), 53.5, 9.9, '9:4') + expect(choice.legValue).toBe('5:0') + }) + + it('FE-SERVICESTOP-008: with nothing routed it goes at the end of the panel day', () => { + const choice = serviceStopChoice(unrouted(), 53.5, 9.9, '') + expect(choice.legs).toEqual([]) + expect(choice.end).toEqual({ value: '5:end', dayId: 5, dayNumber: 1, position: 2 }) + expect(choice.placement).toEqual({ dayId: 5, position: 2, offRouteKm: 0 }) + }) + + it('FE-SERVICESTOP-009: with no drive and no day there is nowhere to put anything', () => { + const choice = serviceStopChoice(mode({ days: [], appendDay: null }), 53.5, 9.9, '') + expect(choice.placement).toBeNull() + }) + + it('FE-SERVICESTOP-010: half a coordinate is no coordinate, so nothing is placed', () => { + const targetFor = vi.fn(() => ({ dayId: 6, position: 1, offRouteKm: 0 })) + const choice = serviceStopChoice(mode({ targetFor }), Number.NaN, 13.7, '') + + expect(targetFor).not.toHaveBeenCalled() + expect(choice.located).toBe(false) + expect(choice.placement).toBeNull() + }) + + it('FE-SERVICESTOP-017: the day the panel is on stays reachable once the others have routed', () => { + // Days 1 and 2 are drawn and the panel is on a third that is still calculating. Left + // out, a stop meant for it was filed on one of the two without a word. + const panelStillCalculating = mode({ appendDay: { dayId: 7, dayNumber: 3, position: 2 } }) + + const offered = serviceStopChoice(panelStillCalculating, 53.5, 9.9, '') + expect(offered.end).toEqual({ value: '7:end', dayId: 7, dayNumber: 3, position: 2 }) + + // And it is a choice, not a label: picking it lands the stop on that day. + const chosen = serviceStopChoice(panelStillCalculating, 53.5, 9.9, '7:end') + expect(chosen.placement).toEqual({ dayId: 7, position: 2, offRouteKm: 0 }) + }) + + it('FE-SERVICESTOP-018: a projection past the last stop of a day stays on that day', () => { + // Legs carry positions 1..stops-1, and the night-drive branch can answer with + // stops.length. No leg matches, and the first leg of the whole trip, which is what an + // unfiltered fallback reached for, is a day and several hundred kilometres away. + const targetFor = () => ({ dayId: 6, position: 2, offRouteKm: 1.1 }) + const choice = serviceStopChoice(mode({ targetFor }), 51.05, 13.7, '') + + expect(choice.legValue).toBe('6:0') + // The 1.1 km belonged to the position the projection named, which is not this one, + // so the fallback keeps the day and drops the figure rather than reusing it. + expect(choice.placement).toEqual({ dayId: 6, position: 1, offRouteKm: null }) + }) + + it('FE-SERVICESTOP-019: the legs are measured one by one and offered nearest first', () => { + const choice = serviceStopChoice(measured(), 50.9, 10.5, '') + + // A tenth of a degree from the second stretch, nine tenths from the first. + expect(choice.legs.map(l => l.value)).toEqual(['5:1', '5:0']) + expect(choice.legs[0]?.offRouteKm).toBeCloseTo(11.1, 0) + expect(choice.legs[1]?.offRouteKm).toBeCloseTo(100, 0) + // Nothing projected, so the nearest measured stretch is the one on offer, and the + // distance it carries is its own rather than the whole day's. + expect(choice.legValue).toBe('5:1') + expect(choice.placement?.position).toBe(2) + expect(choice.placement?.offRouteKm).toBeCloseTo(11.1, 0) + }) + + it('FE-SERVICESTOP-021: overruling onto a far stretch moves the distance with it', () => { + const choice = serviceStopChoice(measured(), 50.9, 10.5, '5:0') + + expect(choice.placement?.position).toBe(1) + expect(choice.placement?.offRouteKm).toBeCloseTo(100, 0) + }) +}) diff --git a/client/src/components/Roadtrip/manualStop.ts b/client/src/components/Roadtrip/manualStop.ts index b3b2d8bd1..2652d7d50 100644 --- a/client/src/components/Roadtrip/manualStop.ts +++ b/client/src/components/Roadtrip/manualStop.ts @@ -1,9 +1,12 @@ +import type { RoadtripStopType } from '@trek/shared' +import { projectOntoRoute, type LatLng } from './corridor' + /** - * What the manual-add dialog and the planner agree on. + * What the place form and the planner agree on when a stop is added to a drive by hand. * - * 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. + * Its own module rather than the section's, so the planner hook can take the shapes and + * the arithmetic 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. */ @@ -15,16 +18,220 @@ export interface ManualStopTarget { 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 +/** What the drive looks like from the form: one routed day, by the stops it drives between. */ +export interface ServiceStopDay { + dayId: number + dayNumber: number + /** Stop names in driving order. Fewer than two and the day has no leg to sit on. */ + stops: string[] + /** + * The road each leg is actually driven on, cut out of the day's line at the two stops + * it runs between, index-aligned with the leg that leaves `stops[i]`. + * + * Absent while a day is between rebuilds, and then no leg of it can say how far the + * place lies from it. That is a missing measurement, never a distance of zero. + */ + legLines?: LatLng[][] +} + +/** Everything the form needs to ask where a service stop belongs, and nothing else. */ +export interface ServiceStopMode { + days: ServiceStopDay[] + /** Where a stop goes at the end of the day the panel is on, routed or not. */ + appendDay: { dayId: number; dayNumber: number; position: number } | null + /** Where a point falls on the drive, from the planner's own projection. */ + targetFor: (lat: number, lng: number) => ManualStopTarget | null +} + +/** One choice in "Add between": a stretch of one day, and the position at its far end. */ +export interface ServiceStopLeg { + /** `:`, the value the select round-trips. */ + value: string + dayId: number + dayNumber: number + position: number + from: string + to: string + /** + * How far the place lies from this stretch of road, or null when nothing measured it. + * + * Null and zero are different answers: zero means the place stands on this leg, null + * means this leg has no drawn road to measure against yet. + */ + offRouteKm: number | null +} + +/** The one choice that is not a stretch of road: the end of the day the panel is on. */ +export interface ServiceStopEnd { + /** `:end`, which no leg value can collide with. */ + value: string + dayId: number + dayNumber: number + position: number +} + +/** Where the stop lands, in the shape the planner assigns at. */ +export interface ServiceStopPlacement { + dayId: number + position: number + /** + * How far the place sits from the road being driven, on THIS leg. + * + * Null where that could not be measured, which is a leg whose drawn road came back + * empty. Null rather than zero, because zero is a real answer that means the place is + * on the road, and the note under the select is the one thing that would print it. + */ + offRouteKm: number | null +} + +export interface ServiceStopChoice { + legs: ServiceStopLeg[] + /** The end of the panel's own day, always on offer while it has one. */ + end: ServiceStopEnd | null + /** Whether the form has a point at all. Without one nothing can be put on a drive. */ + located: boolean + /** What the drive makes of the coordinates, before anybody overruled it. */ + projected: ManualStopTarget | null + /** The leg the select shows: the one chosen, the one projected, or the nearest. */ + legValue: string + /** Where it actually goes. Null while there is nowhere at all to put it. */ + placement: ServiceStopPlacement | null +} + +/** + * The kind the form opens on. + * + * The panel it is reached from is headed by pumps and chargers, and one click is the + * whole distance to any of the others. Opening on nothing would be worse than a guess: a + * stop left without a kind is a numbered destination that counts in every total, which + * is the very thing this path exists to avoid. + */ +export const DEFAULT_SERVICE_KIND: RoadtripStopType = 'fuel' + +/** + * 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. + */ +export const OFF_ROUTE_NOTE_KM = 2 + +/** Sorting key: a leg nothing could measure goes behind every leg that could. */ +function nearness(leg: ServiceStopLeg): number { + return leg.offRouteKm ?? Number.POSITIVE_INFINITY +} + +/** + * Every leg of every routed day, nearest to the place first. + * + * 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. + * + * Ordered by how far the place lies from each stretch rather than by trip order, so the + * preselected leg visibly IS the nearest one and overruling it is an informed choice + * instead of a guess. With no point to measure from, driving order is the only order + * there is and the list keeps it. + */ +export function serviceStopLegs(days: ServiceStopDay[], at?: LatLng | null): ServiceStopLeg[] { + const out: ServiceStopLeg[] = [] + for (const day of days) { + for (let i = 0; i < day.stops.length - 1; i++) { + const line = day.legLines?.[i] + out.push({ + value: `${day.dayId}:${i}`, + dayId: day.dayId, + dayNumber: day.dayNumber, + position: i + 1, + from: day.stops[i] ?? '', + to: day.stops[i + 1] ?? '', + offRouteKm: at && line && line.length > 1 ? projectOntoRoute(at, line)?.offRouteKm ?? null : null, + }) + } + } + // A stable sort, so legs nothing could measure, and legs exactly as far apart, keep the + // order they are driven in. + return out.sort((a, b) => (nearness(a) === nearness(b) ? 0 : nearness(a) - nearness(b))) +} + +/** The last stretch of one day, for a projection that landed past its final stop. */ +function lastLegOf(legs: ServiceStopLeg[], dayId: number): ServiceStopLeg | undefined { + let last: ServiceStopLeg | undefined + for (const leg of legs) { + if (leg.dayId === dayId && (!last || leg.position > last.position)) last = leg + } + return last +} + +/** + * Which leg is on offer and where the stop would land, given where the place is. + * + * One function rather than two, so what the select shows and what the save writes cannot + * disagree: a dropdown reading "Hamburg to Berlin" while the stop lands on another day is + * a lie told quietly. + * + * The projection is the DEFAULT and never a gate. The charger this whole path exists for + * is exactly the one sitting a little too far off the line to be a via, so a distance + * that would refuse one is accepted here and only preselects the leg it landed nearest. + */ +export function serviceStopChoice( + mode: ServiceStopMode, + lat: number | null, + lng: number | null, + chosenLeg: string, +): ServiceStopChoice { + const located = lat != null && lng != null && Number.isFinite(lat) && Number.isFinite(lng) + if (!located) { + // A name on its own cannot be put on a drive. Nothing projects it onto a leg, so + // every leg is as good an answer as every other, and picking one anyway would file a + // petrol stop into the middle of a day nobody named. It goes to the trip's places + // instead, where it can be dragged onto the day it belongs to, and the section says + // so rather than letting it happen quietly. + return { legs: [], end: null, located: false, projected: null, legValue: '', placement: null } + } + const at: LatLng = { lat, lng } + const legs = serviceStopLegs(mode.days, at) + // Always on offer, routed or not: the day the panel is on is the day the traveller is + // looking at, and a stop meant for it has to be reachable even while its own line is + // still being drawn and every other day already has one. + const end: ServiceStopEnd | null = mode.appendDay + ? { value: `${mode.appendDay.dayId}:end`, ...mode.appendDay } + : null + const projected = mode.targetFor(lat, lng) + // Past a day's last stop the projection names a position no leg carries, because legs + // run between stops and there is nothing beyond the final one. The day it named is + // still the right day, so its last stretch is the answer; the first leg of the trip, + // which is what an unfiltered fallback reaches for, is days and hundreds of kilometres + // from where the place was measured. + const projectedLeg = projected + ? legs.find(leg => leg.dayId === projected.dayId && leg.position === projected.position) + ?? lastLegOf(legs, projected.dayId) + : undefined + // A leg the traveller picked wins: a drive that passes the same junction twice can only + // be guessed at once, and they are the one who knows which time they mean to stop. + const chosen = legs.some(leg => leg.value === chosenLeg) || end?.value === chosenLeg + const legValue = chosen ? chosenLeg : projectedLeg?.value ?? legs[0]?.value ?? end?.value ?? '' + const leg = legs.find(item => item.value === legValue) + let placement: ServiceStopPlacement | null = null + if (leg) { + // This leg's own measurement first: overruling the projection onto another stretch + // moves the place relative to the road, and the distance has to move with it. + // + // The projection's own figure stands in only for the leg it actually named. It was + // measured against the day's whole spine rather than that one stretch, which is the + // same answer for the stretch it landed on and a different answer for any other. A + // day that drives out and back reaches this: a stop that projects onto the spine + // before the one ahead of it leaves the slice between them empty, so that leg has + // no measurement of its own. Borrowing across legs would print a number that + // belongs to a stretch nobody chose, so anything else reports null and says nothing. + const isProjected = !!projected && leg.dayId === projected.dayId && leg.position === projected.position + placement = { + dayId: leg.dayId, + position: leg.position, + offRouteKm: leg.offRouteKm ?? (isProjected ? projected.offRouteKm : null), + } + } else if (end && legValue === end.value) { + placement = { dayId: end.dayId, position: end.position, offRouteKm: 0 } + } + return { legs, end, located: true, projected, legValue, placement } } diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index b6434fd53..494153443 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -277,7 +277,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { setRoadtripStopFill, saveRoadtripLimit, roadtripSettingsLoading, storedAssignments, roadtripVias, addRoadtripVia, moveRoadtripVia, removeRoadtripVia, resetDayBoundaries, - manualStopTargetFor, addManualRoadtripStop, + openManualRoadtripStop, serviceStopMode, setServiceStopForm, routeAlternatives, askRouteAlternatives, refuel, askRefuel, acceptRefuel, chooseRouteAlternative, alternativeOverlays, alternativeFocusPoints, mapFocusPoints, roadtripMapVias, focusRoadtripPoint, dayBoundaryControls, stayDraft, setStayDraft, editRoadtripStay, setRoadtripStay, roadtripEndDay, roadtripStay, highlightedAlternative, setHighlightedAlternative, @@ -731,8 +731,7 @@ 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} + onAddManual={can('place_edit', trip) ? openManualRoadtripStop : undefined} onFocusPoint={focusRoadtripPoint} /> {/* Under the search, because the limits are read while looking at @@ -1058,7 +1057,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null { /> )} - { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} /> + { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null); setServiceStopForm(false) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} serviceStop={serviceStopMode} /> setShowTripForm(false)} diff --git a/client/src/pages/TripPlannerPage.wiring.test.tsx b/client/src/pages/TripPlannerPage.wiring.test.tsx index 3dc040411..724b919e9 100644 --- a/client/src/pages/TripPlannerPage.wiring.test.tsx +++ b/client/src/pages/TripPlannerPage.wiring.test.tsx @@ -232,6 +232,9 @@ function baseState(): HookState { setPrefillCoords: vi.fn(), editingAssignmentId: null, setEditingAssignmentId: vi.fn(), + serviceStopMode: null, + setServiceStopForm: vi.fn(), + openManualRoadtripStop: vi.fn(), showTripForm: false, setShowTripForm: vi.fn(), showMembersModal: false, @@ -1056,6 +1059,9 @@ describe('TripPlannerPage — modals', () => { expect(hookState.setShowPlaceForm).toHaveBeenCalledWith(false) expect(hookState.setEditingPlace).toHaveBeenCalledWith(null) expect(hookState.setPrefillCoords).toHaveBeenCalledWith(null) + // The road trip's manual add is one of the ways this form opens; closing it has to + // put the form back to the one every other caller gets. + expect(hookState.setServiceStopForm).toHaveBeenCalledWith(false) act(() => { props('placeForm').onCategoryCreated({ id: 3, name: 'Food' }) }) const tripActions = hookState.tripActions as Record> diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx index f1ed10d0f..34bc75e1b 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -17,7 +17,6 @@ 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. @@ -1277,18 +1276,6 @@ describe('useTripPlanner road trip: a stop added by hand', () => { }] } - 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() @@ -1321,36 +1308,171 @@ describe('useTripPlanner road trip: a stop added by hand', () => { 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 () => { + it('FE-TP-ROAD-078: adding one by hand opens the place form on nothing at all', async () => { routedDay() const { result } = await renderRoadtrip() - act(() => { - result.current.addManualRoadtripStop(place(), { dayId: 5, position: 1, offRouteKm: 3.2 }) - }) + act(() => { result.current.openManualRoadtripStop() }) - 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) + // The real form, with its own typed-ahead search: nothing is known about the place + // yet, so nothing is prefilled and no day is fixed. + expect(result.current.showPlaceForm).toBe(true) + expect(result.current.prefillCoords).toBeNull() + expect(result.current.editingPlace).toBeNull() + expect(result.current.stopDraft).toBeNull() 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 () => { + it('FE-TP-ROAD-079: the form is handed every leg of the drive to choose between', async () => { + routedDay() + const { result } = await renderRoadtrip() + expect(result.current.serviceStopMode).toBeNull() + + act(() => { result.current.openManualRoadtripStop() }) + + expect(result.current.serviceStopMode).toMatchObject({ + days: [{ dayId: 5, dayNumber: 1 }], + appendDay: { dayId: 5, dayNumber: 1, position: 2 }, + }) + expect(result.current.serviceStopMode?.targetFor(53.0, 11.5)).toMatchObject({ dayId: 5, position: 1 }) + }) + + it('FE-TP-ROAD-081: with nothing routed there is no leg, only the day the panel is on', async () => { + routedDay() + // Two stops and no line between them: the trip has places but no drive yet. + rt.routes.days = [{ dayId: 5, dayNumber: 1, stops: [{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }], geometry: [] }] + const { result } = await renderRoadtrip() + + act(() => { result.current.openManualRoadtripStop() }) + + expect(result.current.serviceStopMode?.days).toEqual([]) + expect(result.current.serviceStopMode?.appendDay).toMatchObject({ dayId: 5, position: 2 }) + }) + + it('FE-TP-ROAD-082: the stop lands where the SAVE says, not where the form opened', async () => { routedDay() const { result } = await renderRoadtrip() - act(() => { - result.current.addManualRoadtripStop(place({ category: 'hotel' }), { dayId: 5, position: 1, offRouteKm: 0.2 }) + act(() => { result.current.openManualRoadtripStop() }) + await act(async () => { + await result.current.handleSavePlace({ + name: 'Supercharger Dammer Berge', + lat: 52.9, + lng: 11.4, + stop_type: 'charging', + duration_minutes: 30, + // Worked out in the form from the coordinates above, long after it opened. + _serviceStop: { dayId: 5, position: 1, offRouteKm: 3.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) + // The transport field never reaches the write. + expect(actions.addPlace).toHaveBeenCalledWith(42, expect.not.objectContaining({ _serviceStop: expect.anything() })) + expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 5, 900, 1) + expect(updateRouteForDay).toHaveBeenCalledWith(5) + }) + + it('FE-TP-ROAD-083: a leg named by the card is filed under the day the stop is STORED on', async () => { + // A card can open with yesterday's last stop (`nightSpill.ts`), so the card and the + // position in it are not the day and the position the assignment is written at. + seedTrip({ days: [buildDay({ id: 5, day_number: 1 }), buildDay({ id: 6, day_number: 2 })] }) + rt.corridor.day = { dayId: 6, dayNumber: 2 } + rt.routes.days = [{ + dayId: 6, + dayNumber: 2, + stops: [ + { lat: 53.55, lng: 9.99, ownerDayId: 5, ownerIndex: 2 }, + { lat: 53.0, lng: 11.5, ownerDayId: 6, ownerIndex: 0 }, + { lat: 52.52, lng: 13.4, ownerDayId: 6, ownerIndex: 1 }, + ], + geometry: [[53.55, 9.99], [53.0, 11.5], [52.52, 13.4]], + }] + const { result } = await renderRoadtrip() + + act(() => { result.current.openManualRoadtripStop() }) + await act(async () => { + await result.current.handleSavePlace({ + name: 'Rasthof', + lat: 53.2, + lng: 10.7, + _serviceStop: { dayId: 6, position: 1, offRouteKm: 0 }, + }) + }) + + // Not (42, 6, 900, 1): the stop the leg ends at is the second day's first, stored + // at index 0 there. + expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 6, 900, 0) + }) + + it('FE-TP-ROAD-084: a stop dropped into a routed day re-anchors its vias before it re-routes', async () => { + seedTrip({ days: [buildDay({ id: 5, day_number: 1 })] }) + rt.corridor.day = { dayId: 5, dayNumber: 1 } + rt.routes.days = [{ + dayId: 5, + dayNumber: 1, + stops: [{ lat: 53.55, lng: 9.99 }, { lat: 53.0, lng: 11.5 }, { lat: 52.52, lng: 13.4 }], + geometry: [[53.55, 9.99], [53.0, 11.5], [52.52, 13.4]], + }] + // One via on the second leg, anchored behind the stop the new one goes in front of. + rt.vias.byDay = { 5: [via(11, 5, 1, 0, 52.8, 12.2)] } + const { result } = await renderRoadtrip() + + act(() => { result.current.openManualRoadtripStop() }) + await act(async () => { + await result.current.handleSavePlace({ + name: 'Supercharger Dammer Berge', + lat: 53.2, + lng: 10.7, + _serviceStop: { dayId: 5, position: 1, offRouteKm: 1.2 }, + }) + }) + + // `after_order_index` is a POSITION in the day's stop list. Left alone, the via keeps + // index 1 and is redrawn onto the leg the new stop just took: the road then runs + // forward, doubles back and runs out again. + expect(actions.assignPlaceToDay).toHaveBeenCalledWith(42, 5, 900, 1) + expect(rt.vias.reanchor).toHaveBeenCalledWith(5, { vias: [{ id: 11, after_order_index: 2 }], remove: [] }) + // Awaited before the re-route: a correction landing after it would draw the wrong + // road first and the right one a moment later. + const assigned = actions.assignPlaceToDay.mock.invocationCallOrder[0] ?? 0 + const corrected = rt.vias.reanchor.mock.invocationCallOrder[0] ?? 0 + const routes = updateRouteForDay.mock.invocationCallOrder + const routed = routes[routes.length - 1] ?? 0 + expect(assigned).toBeLessThan(corrected) + expect(corrected).toBeLessThan(routed) + }) + + it('FE-TP-ROAD-085: the form is handed the road of each leg, to measure the place against', async () => { + routedDay() + const { result } = await renderRoadtrip() + + act(() => { result.current.openManualRoadtripStop() }) + + // One line per leg, cut out of the day's drawn road: the form measures the place + // against each of them and offers the nearest first. + const day = result.current.serviceStopMode?.days[0] + expect(day?.legLines).toHaveLength(1) + expect(day?.legLines?.[0]?.length).toBeGreaterThan(1) + }) + + it('FE-TP-ROAD-086: a stop the form could not place goes to the trip places, not onto a day', async () => { + routedDay() + const { result } = await renderRoadtrip() + + act(() => { result.current.openManualRoadtripStop() }) + await act(async () => { + await result.current.handleSavePlace({ + name: 'Supercharger Dammer Berge', + lat: null, + lng: null, + // A name with no coordinates cannot be measured onto a road, so the form says so + // and sends no placement at all. + _serviceStop: null, + }) + }) + + expect(actions.addPlace).toHaveBeenCalled() + expect(actions.assignPlaceToDay).not.toHaveBeenCalled() }) it('FE-TP-ROAD-080: a reader may not add one by hand either', async () => { @@ -1358,11 +1480,10 @@ describe('useTripPlanner road trip: a stop added by hand', () => { routedDay() const { result } = await renderRoadtrip() - act(() => { - result.current.addManualRoadtripStop(place(), { dayId: 5, position: 1, offRouteKm: 0 }) - }) + act(() => { result.current.openManualRoadtripStop() }) - expect(result.current.stopDraft).toBeNull() + expect(result.current.showPlaceForm).toBe(false) + expect(result.current.serviceStopMode).toBeNull() }) }) diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index f5254e560..531dc99e9 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -41,14 +41,14 @@ import { useRouteAlternatives } from '../../components/Roadtrip/useRouteAlternat import { buildAlternativeOverlays } from '../../components/Roadtrip/alternativeOverlays' import { stopArrival } from '../../components/Roadtrip/stopArrival' import type { CorridorPoi } from '../../components/Roadtrip/useCorridorPois' -import { projectOntoRoute, type LatLng } from '../../components/Roadtrip/corridor' +import { projectOntoRoute, sliceAtMeters, type LatLng } from '../../components/Roadtrip/corridor' import { insertIndexForAlong, reanchorAfterInsert, reanchorAfterRemove, reanchorByStopOrder, isServiceStopType, refuelStopTypeFor, reanchorAfterReorder, type DryPoint } from '../../components/Roadtrip/roadtripModel' -import type { ManualStopPlace, ManualStopTarget } from '../../components/Roadtrip/manualStop' +import type { ManualStopTarget, ServiceStopMode } from '../../components/Roadtrip/manualStop' import type { RoadtripStopDraft } from '../../components/Roadtrip/RoadtripStopPopup' import type { RoadtripStopType } from '@trek/shared' import { usePlaceSelection } from '../../hooks/usePlaceSelection' @@ -302,6 +302,14 @@ export function useTripPlanner() { * Null means the old behaviour: the server appends it at the end. */ const [placeFormPosition, setPlaceFormPosition] = useState(null) + /** + * Whether the open place form is asking for a service stop on the drive. + * + * Only the road trip's "add manually" sets it, and everything that opens the form for + * anything else clears it, so the ordinary add, the edit and the corridor hit are the + * form they have always been. + */ + const [serviceStopForm, setServiceStopForm] = useState(false) /** * The corridor hit waiting to become a stop, while the small popup is open. * @@ -724,6 +732,7 @@ export function useTripPlanner() { setEditingPlace(null) setEditingAssignmentId(null) setPlaceFormDayId(dayId ?? null) + setServiceStopForm(false) setShowPlaceForm(true) try { const { mapsApi } = await import('../../api/client') @@ -764,6 +773,7 @@ export function useTripPlanner() { setEditingAssignmentId(null) setPlaceFormDayId(dayId ?? null) setPlaceFormPosition(position ?? null) + setServiceStopForm(false) setShowPlaceForm(true) }, [trip]) @@ -1524,51 +1534,71 @@ export function useTripPlanner() { }, [anchorFor, roadtripRoutes.days]) /** - * A place looked up by hand, on its way to the popup a corridor hit goes through. + * Adding a stop the corridor search never found. * - * 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. + * The search reads OpenStreetMap, and a good share of the chargers actually standing + * at a motorway junction are not in it. 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 opens the form the rest of TREK adds places with, on nothing at all: no + * place, no coordinates, no day. The traveller finds the charger in the form's own + * typed-ahead search, which is the whole reason to use it, and where the stop belongs + * is worked out at the save, from what the save carries. */ - const addManualRoadtripStop = useCallback((place: ManualStopPlace, target: ManualStopTarget) => { + const openManualRoadtripStop = useCallback(() => { 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]) + setEditingPlace(null) + setEditingAssignmentId(null) + setPrefillCoords(null) + // Deliberately nowhere. A corridor hit knows its day and its position before the + // form opens; this one cannot, because nothing has been chosen yet. + setPlaceFormDayId(null) + setPlaceFormPosition(null) + setServiceStopForm(true) + setShowPlaceForm(true) + }, [can, trip]) + + /** + * What the place form needs to ask for a service stop, or null for every other use. + * + * The legs are flat across the whole drive rather than per card, because the charger + * the search missed is as likely to be on tomorrow's stretch as on today's. The names + * travel as plain strings so the form does its own labelling and this stays free of + * translated text. + */ + const serviceStopMode = useMemo(() => { + if (!serviceStopForm) return null + const panelDay = roadtripCorridor.day + return { + // A day that has not routed has no order to place anything in. + days: roadtripRoutes.days + .filter(day => day.geometry.length > 1) + .map(day => { + const spine: LatLng[] = day.geometry.map(([la, ln]) => ({ lat: la, lng: ln })) + const stopsAlong = day.stops.map(stop => projectOntoRoute({ lat: stop.lat, lng: stop.lng }, spine)?.alongKm ?? 0) + return { + dayId: day.dayId, + dayNumber: day.dayNumber, + stops: day.stops.map(stop => stop.name), + // Each leg's own road, cut out of the day's line where the two stops it runs + // between fall on it. The form measures the place against each of these, so + // the leg it offers first is visibly the nearest one rather than a guess the + // reader has to take on trust. + legLines: day.stops.slice(0, -1).map((_, i) => + sliceAtMeters(spine, (stopsAlong[i] ?? 0) * 1000, (stopsAlong[i + 1] ?? 0) * 1000)), + } + }), + // The end of the day the panel is looking at, which is what the dialog this + // replaced did. Offered whatever else has routed, not only when nothing has: with + // its neighbours drawn and its own line still coming, a day left out of this list + // is a day a stop meant for it cannot be put on at all. + appendDay: panelDay + ? { dayId: panelDay.dayId, dayNumber: panelDay.dayNumber, position: panelDay.stops.length } + : null, + targetFor: manualStopTargetFor, + } + }, [serviceStopForm, roadtripRoutes.days, roadtripCorridor.day, manualStopTargetFor]) const addRoadtripVia = useCallback(async (lat: number, lng: number) => { if (!can('day_edit', trip)) return @@ -1739,6 +1769,11 @@ export function useTripPlanner() { const handleSavePlace = useCallback(async (data) => { const pendingFiles = data._pendingFiles delete data._pendingFiles + // Where a service stop added by hand belongs on the drive. The form worked it out + // from the coordinates being saved, because until a place was chosen in it there + // were none to project. + const serviceStop = data._serviceStop + delete data._serviceStop if (editingPlace) { // Always strip time fields from place update — time is per-assignment only. // Same for the day-specific note (#2163): it belongs to the assignment, @@ -1768,15 +1803,41 @@ export function useTripPlanner() { return { id: editingPlace.id } } else { const place = await tripActions.addPlace(tripId, data) + // A card of the rail can draw stops that are STORED on the day before it + // (`nightSpill.ts`), so a leg named by the card and the position in it is + // translated once here, the same way every other path into this write is. + const card = serviceStop && roadtripRoutes.days.find(day => day.dayId === serviceStop.dayId) + const insert = serviceStop + ? (card && roadtripInsertion(card, serviceStop.position)) || { dayId: serviceStop.dayId, position: serviceStop.position } + : null + const dayId = insert ? insert.dayId : placeFormDayId + const position = insert ? insert.position : placeFormPosition // Added from inside a day? Then it belongs to that day. Without this the // place drops into the unplanned pool and, on mobile, into a different // screen entirely — which reads as "it wasn't saved" (#1998). - if (place?.id && placeFormDayId != null) { + if (place?.id && dayId != null) { + // Worked out BEFORE the stop lands, against the day as it stands and the road as + // it is currently driven: once the list has shifted there is no record of which + // leg each via was drawn for. A via is stored as (day, after_order_index) and + // that index is a POSITION in the day's stop list, so a stop dropped into the + // middle of a routed day pushes every via at or behind it onto the wrong leg and + // the drawn road runs forward, doubles back and runs out again. + const plan = insert && typeof data.lat === 'number' && typeof data.lng === 'number' + ? reanchorAfterInsert( + roadtripVias.byDay[insert.dayId] ?? [], + insert.position, + viaLiesBefore(insert.dayId, { lat: data.lat, lng: data.lng }), + ) + : null try { // With a position the stop lands where it will be driven past, not at the end // of the day. The slice has taken one all along; nothing ever passed it. - await tripActions.assignPlaceToDay(tripId, placeFormDayId, place.id, placeFormPosition) - updateRouteForDay(placeFormDayId) + await tripActions.assignPlaceToDay(tripId, dayId, place.id, position) + // Awaited before the day re-routes: the routing effect reads the anchors against + // the new stop list, so a correction landing after it would draw the wrong road + // first and the right one a moment later. + if (plan) await roadtripVias.reanchor(dayId, plan) + updateRouteForDay(dayId) } catch (err: unknown) { // The place itself exists; only the day link failed. toast.error(err instanceof Error ? err.message : t('common.unknownError')) @@ -1801,7 +1862,7 @@ export function useTripPlanner() { // exist a moment ago (#1298), the same way the booking modals work. return place?.id ? { id: place.id } : undefined } - }, [editingPlace, editingAssignmentId, placeFormDayId, placeFormPosition, tripId, toast, pushUndo, updateRouteForDay]) + }, [editingPlace, editingAssignmentId, placeFormDayId, placeFormPosition, roadtripRoutes.days, tripId, toast, pushUndo, updateRouteForDay, roadtripVias, viaLiesBefore]) // Open the place editor from any entry point (Places pool, inspector, map). // Times live per day-assignment, so when no day is in context resolve the @@ -1830,6 +1891,7 @@ export function useTripPlanner() { setEditingPlace(place) setEditingAssignmentId(preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id)) setPlaceFormDayId(null) + setServiceStopForm(false) setShowPlaceForm(true) }, [assignments, roadtripActive, tripAccommodations, days, overnightOptions, roadtripRoutes.days]) @@ -2344,7 +2406,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, + manualStopTargetFor, openManualRoadtripStop, serviceStopMode, setServiceStopForm, 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 d6bfcea74..3a9588ea0 100644 --- a/shared/src/i18n/ar/roadtrip.ts +++ b/shared/src/i18n/ar/roadtrip.ts @@ -203,6 +203,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.manualOffRoute': '{distance} عن المسار، لذا تحقق من المرحلة التي تناسبها.', 'roadtrip.poi.manualAppend': 'لا يوجد مسار بعد، لذا تُضاف في نهاية اليوم {number}.', 'roadtrip.poi.manualNoResults': 'لم يُعثر على أي مكان.', + 'roadtrip.poi.manualNoCoords': + 'بدون موضع، يُضاف هذا إلى أماكن الرحلة بدلاً من إحدى مراحل القيادة. اختر نتيجة من البحث أعلاه، أو أدخل الإحداثيات.', '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 3312bfa74..d151a476b 100644 --- a/shared/src/i18n/br/roadtrip.ts +++ b/shared/src/i18n/br/roadtrip.ts @@ -208,6 +208,8 @@ const roadtrip: TranslationStrings = { '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.poi.manualNoCoords': + 'Sem uma posição, ele vai para os locais da viagem em vez de entrar em um trecho do trajeto. Escolha um resultado da busca acima ou preencha as coordenadas.', '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 56b89032e..45927ed7a 100644 --- a/shared/src/i18n/ca/roadtrip.ts +++ b/shared/src/i18n/ca/roadtrip.ts @@ -210,6 +210,8 @@ const roadtrip: TranslationStrings = { '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.poi.manualNoCoords': + 'Sense una posició, això va als llocs del viatge en comptes d’anar a un trajecte. Tria un resultat de la cerca de dalt o omple les coordenades.', '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 120b2cbf6..fbd079c23 100644 --- a/shared/src/i18n/cs/roadtrip.ts +++ b/shared/src/i18n/cs/roadtrip.ts @@ -206,6 +206,8 @@ const roadtrip: TranslationStrings = { '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.poi.manualNoCoords': + 'Bez polohy se zastávka uloží mezi místa cesty, ne do jízdy. Vyberte výsledek z vyhledávání nahoře, nebo vyplňte souřadnice.', '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 ed81fe956..c78818472 100644 --- a/shared/src/i18n/de/roadtrip.ts +++ b/shared/src/i18n/de/roadtrip.ts @@ -205,6 +205,8 @@ const roadtrip: TranslationStrings = { '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.poi.manualNoCoords': + 'Ohne Position landet dieser Halt bei den Orten der Reise statt auf einer Fahrt. Wähle oben in der Suche ein Ergebnis aus oder trage die Koordinaten ein.', '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 757e014e9..87a14aac6 100644 --- a/shared/src/i18n/en/roadtrip.ts +++ b/shared/src/i18n/en/roadtrip.ts @@ -202,6 +202,8 @@ const roadtrip: TranslationStrings = { '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.poi.manualNoCoords': + 'Without a position this goes to the trip\'s places rather than onto a drive. Pick one from the search above, or fill in the coordinates.', '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 18dd44b17..4734f3408 100644 --- a/shared/src/i18n/es/roadtrip.ts +++ b/shared/src/i18n/es/roadtrip.ts @@ -209,6 +209,8 @@ const roadtrip: TranslationStrings = { '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.poi.manualNoCoords': + 'Sin una posición, se guarda en los lugares del viaje en vez de en un trayecto. Elige uno de la búsqueda de arriba o indica las coordenadas.', '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 4fda223b4..f98189ea7 100644 --- a/shared/src/i18n/fr/roadtrip.ts +++ b/shared/src/i18n/fr/roadtrip.ts @@ -209,6 +209,8 @@ const roadtrip: TranslationStrings = { '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.poi.manualNoCoords': + 'Sans position, il ira dans les lieux du voyage plutôt que sur un trajet. Choisissez un résultat dans la recherche ci-dessus, ou saisissez les coordonnées.', '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 2ae91dc50..5e22baf47 100644 --- a/shared/src/i18n/gr/roadtrip.ts +++ b/shared/src/i18n/gr/roadtrip.ts @@ -211,6 +211,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.manualOffRoute': '{distance} από τη διαδρομή, οπότε ελέγξτε σε ποιο σκέλος ανήκει.', 'roadtrip.poi.manualAppend': 'Δεν έχει υπολογιστεί ακόμη διαδρομή, οπότε μπαίνει στο τέλος της ημέρας {number}.', 'roadtrip.poi.manualNoResults': 'Δεν βρέθηκαν τοποθεσίες.', + 'roadtrip.poi.manualNoCoords': + 'Χωρίς θέση, αυτό μπαίνει στα μέρη του ταξιδιού και όχι σε μια διαδρομή ημέρας. Διαλέξτε ένα αποτέλεσμα από την αναζήτηση πιο πάνω ή συμπληρώστε τις συντεταγμένες.', '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 301c5e128..990c5f57d 100644 --- a/shared/src/i18n/hu/roadtrip.ts +++ b/shared/src/i18n/hu/roadtrip.ts @@ -207,6 +207,8 @@ const roadtrip: TranslationStrings = { '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.poi.manualNoCoords': + 'Pozíció nélkül ez az utazás helyei közé kerül, nem egy nap útvonalára. Válassz egyet a fenti keresésből, vagy töltsd ki a koordinátákat.', '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 673698844..f77be8bf7 100644 --- a/shared/src/i18n/id/roadtrip.ts +++ b/shared/src/i18n/id/roadtrip.ts @@ -209,6 +209,8 @@ const roadtrip: TranslationStrings = { '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.poi.manualNoCoords': + 'Tanpa posisi, ia masuk ke daftar tempat perjalanan, bukan ke salah satu ruas berkendara. Pilih satu dari hasil pencarian di atas, atau isi koordinatnya.', '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 1e9aa7168..fb85a5806 100644 --- a/shared/src/i18n/it/roadtrip.ts +++ b/shared/src/i18n/it/roadtrip.ts @@ -209,6 +209,8 @@ const roadtrip: TranslationStrings = { '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.poi.manualNoCoords': + 'Senza una posizione finisce tra i luoghi del viaggio, non su un tragitto. Scegli un risultato dalla ricerca qui sopra oppure inserisci le coordinate.', '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 1f37b9598..7f5208712 100644 --- a/shared/src/i18n/ja/roadtrip.ts +++ b/shared/src/i18n/ja/roadtrip.ts @@ -206,6 +206,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.manualOffRoute': 'ルートから {distance} 離れています。どの区間に入れるか確認してください。', 'roadtrip.poi.manualAppend': 'まだルートがないため、{number} 日目の最後に追加します。', 'roadtrip.poi.manualNoResults': '場所が見つかりませんでした。', + 'roadtrip.poi.manualNoCoords': 'この場所は位置情報がないため、その日の走行ではなく旅行の場所一覧に追加されます。上の検索から選ぶか、緯度と経度を入力してください。', '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 934b92d8b..fadc4c613 100644 --- a/shared/src/i18n/ko/roadtrip.ts +++ b/shared/src/i18n/ko/roadtrip.ts @@ -204,6 +204,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.manualOffRoute': '경로에서 {distance} 떨어져 있으니 어느 구간에 속하는지 확인하세요.', 'roadtrip.poi.manualAppend': '아직 계산된 경로가 없어 {number}일차 마지막에 추가됩니다.', 'roadtrip.poi.manualNoResults': '장소를 찾지 못했습니다.', + 'roadtrip.poi.manualNoCoords': '위치가 없으면 주행에 배치되지 않고 여행의 장소 목록에 추가됩니다. 위 검색에서 하나를 고르거나 좌표를 입력하세요.', '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 d0b6629ab..dc36ab93c 100644 --- a/shared/src/i18n/nl/roadtrip.ts +++ b/shared/src/i18n/nl/roadtrip.ts @@ -209,6 +209,8 @@ const roadtrip: TranslationStrings = { '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.poi.manualNoCoords': + 'Zonder positie komt hij bij de plaatsen van de reis terecht en niet in een rit. Kies er een via de zoekfunctie hierboven, of vul de coördinaten in.', '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 69035c3f0..abe1114df 100644 --- a/shared/src/i18n/pl/roadtrip.ts +++ b/shared/src/i18n/pl/roadtrip.ts @@ -207,6 +207,8 @@ const roadtrip: TranslationStrings = { '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.poi.manualNoCoords': + 'Bez pozycji trafi do miejsc podróży, a nie na przejazd. Wybierz wynik wyszukiwania powyżej albo uzupełnij współrzędne.', '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 499af49f8..3147a4964 100644 --- a/shared/src/i18n/ru/roadtrip.ts +++ b/shared/src/i18n/ru/roadtrip.ts @@ -209,6 +209,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.manualOffRoute': '{distance} от маршрута, проверьте, к какому участку оно относится.', 'roadtrip.poi.manualAppend': 'Маршрутов пока нет, поэтому место встанет в конце дня {number}.', 'roadtrip.poi.manualNoResults': 'Места не найдены.', + 'roadtrip.poi.manualNoCoords': + 'Без координат это останется среди мест поездки и не попадёт на участок маршрута. Выберите вариант из поиска выше или заполните координаты.', '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 04d32162a..f5f8d446d 100644 --- a/shared/src/i18n/sv/roadtrip.ts +++ b/shared/src/i18n/sv/roadtrip.ts @@ -206,6 +206,8 @@ const roadtrip: TranslationStrings = { '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.poi.manualNoCoords': + 'Utan position hamnar stoppet bland resans platser i stället för på en körning. Välj en träff i sökningen ovan eller fyll i koordinaterna.', '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 faf9d70da..db3996e1b 100644 --- a/shared/src/i18n/tr/roadtrip.ts +++ b/shared/src/i18n/tr/roadtrip.ts @@ -208,6 +208,8 @@ const roadtrip: TranslationStrings = { '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.poi.manualNoCoords': + 'Konum olmadan bu yer, bir sürüşe değil gezinin yerleri arasına eklenir. Yukarıdaki aramadan birini seçin veya koordinatları girin.', '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 4062f0ac2..870a985aa 100644 --- a/shared/src/i18n/uk/roadtrip.ts +++ b/shared/src/i18n/uk/roadtrip.ts @@ -208,6 +208,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.manualOffRoute': '{distance} від маршруту, тож перевірте, до якого відрізка це місце належить.', 'roadtrip.poi.manualAppend': 'Маршруту ще немає, тож це місце потрапить у кінець дня {number}.', 'roadtrip.poi.manualNoResults': 'Місць не знайдено.', + 'roadtrip.poi.manualNoCoords': + 'Без позиції на мапі це місце потрапить до місць подорожі, а не на переїзд. Оберіть результат пошуку вище або впишіть координати.', '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 cd8b97547..680e51f53 100644 --- a/shared/src/i18n/vi/roadtrip.ts +++ b/shared/src/i18n/vi/roadtrip.ts @@ -207,6 +207,8 @@ const roadtrip: TranslationStrings = { '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.poi.manualNoCoords': + 'Không có vị trí, nó sẽ vào danh sách địa điểm của chuyến đi chứ không nằm trên một chặng lái xe. Hãy chọn một kết quả ở ô tìm kiếm phía trên, hoặc điền tọa độ.', '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 b80568904..04bdba931 100644 --- a/shared/src/i18n/zh-TW/roadtrip.ts +++ b/shared/src/i18n/zh-TW/roadtrip.ts @@ -195,6 +195,7 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.manualOffRoute': '偏離路線 {distance},請確認它屬於哪一段。', 'roadtrip.poi.manualAppend': '目前尚無路線,因此會加在第 {number} 天的最後。', 'roadtrip.poi.manualNoResults': '找不到地點。', + 'roadtrip.poi.manualNoCoords': '沒有位置時,這個停靠點只會加入本行程的地點,不會排進某天的車程。請從上方的搜尋結果中選一個,或填入座標。', '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 017d02ce8..ec4d80d09 100644 --- a/shared/src/i18n/zh/roadtrip.ts +++ b/shared/src/i18n/zh/roadtrip.ts @@ -195,6 +195,8 @@ const roadtrip: TranslationStrings = { 'roadtrip.poi.manualOffRoute': '偏离路线 {distance},请确认它属于哪个路段。', 'roadtrip.poi.manualAppend': '还没有路线,因此它将添加到第 {number} 天的末尾。', 'roadtrip.poi.manualNoResults': '未找到地点。', + 'roadtrip.poi.manualNoCoords': + '没有位置信息时,它只会保存到本次行程的地点中,不会安排到某天的车程上。请从上方的搜索结果中选择一处,或填写坐标。', 'roadtrip.summary.distance': '距离', 'roadtrip.summary.driving': '驾驶时间', 'roadtrip.alt.ask': '其他路线',