From da679e6266c7007ed78a1441a3b620b9bab39d04 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 14 Sep 2026 16:22:03 +0200 Subject: [PATCH] feat(roadtrip): add a stop by hand in the form the rest of TREK adds places in The dialog built for this last round was its own thing and showed it: the suggestion list was barely legible, clipped at the bottom, and never more than two rows tall. It also lacked most of what the ordinary add-place form has had for years. So it is gone, and "Add manually" opens that form instead, in a service-stop mode. The category control becomes the kind of stop and the time spent there, the costs section is dropped, and an "Add between" row says where on the drive it goes. Everything else comes along rather than being rebuilt: the typed-ahead search with its source marks, the coordinate fields for a charger no index knows, address, website, phone, notes, times, files and the duplicate check. With the mode absent the form is the same component it was, which its own suites still pin. Where the stop lands is worked out when it is saved rather than when the form opens, because the coordinate only exists once a place has been chosen. One function answers both what the select shows and where the stop goes, so the two cannot disagree, and the write goes through the same assignment path as every other insert, via re-anchoring included: a stop pushed into a day shifts every via anchored at or after it, and without re-anchoring first the drawn road runs forward, doubles back and runs out again. The select recommends rather than merely offering. Every leg of every routed day is measured against the place separately and they are listed nearest first, each with its own distance, so the preselected one visibly is the closest and overruling it is an informed choice. The word they all shared moved into the caption. A stop with no position at all is not filed on a drive by guesswork. It keeps its kind and goes to the trip's places, and the form says so and says how to change it. --- .../Planner/PlaceFormModal.test.tsx | 250 ++++++++++- .../src/components/Planner/PlaceFormModal.tsx | 169 +++++++- .../Roadtrip/RoadtripCorridorPanel.test.tsx | 56 ++- .../Roadtrip/RoadtripCorridorPanel.tsx | 96 ++--- .../Roadtrip/RoadtripManualStopModal.test.tsx | 304 ------------- .../Roadtrip/RoadtripManualStopModal.tsx | 407 ------------------ .../components/Roadtrip/RoadtripStopPopup.tsx | 60 +-- .../Roadtrip/ServiceStopSection.test.tsx | 169 ++++++++ .../Roadtrip/ServiceStopSection.tsx | 150 +++++++ client/src/components/Roadtrip/StopChips.tsx | 81 ++++ .../components/Roadtrip/manualStop.test.ts | 184 ++++++++ client/src/components/Roadtrip/manualStop.ts | 239 +++++++++- client/src/pages/TripPlannerPage.tsx | 7 +- .../src/pages/TripPlannerPage.wiring.test.tsx | 6 + .../useTripPlanner.roadtrip.test.tsx | 191 ++++++-- .../src/pages/tripPlanner/useTripPlanner.ts | 160 ++++--- shared/src/i18n/ar/roadtrip.ts | 2 + shared/src/i18n/br/roadtrip.ts | 2 + shared/src/i18n/ca/roadtrip.ts | 2 + shared/src/i18n/cs/roadtrip.ts | 2 + shared/src/i18n/de/roadtrip.ts | 2 + shared/src/i18n/en/roadtrip.ts | 2 + shared/src/i18n/es/roadtrip.ts | 2 + shared/src/i18n/fr/roadtrip.ts | 2 + shared/src/i18n/gr/roadtrip.ts | 2 + shared/src/i18n/hu/roadtrip.ts | 2 + shared/src/i18n/id/roadtrip.ts | 2 + shared/src/i18n/it/roadtrip.ts | 2 + shared/src/i18n/ja/roadtrip.ts | 1 + shared/src/i18n/ko/roadtrip.ts | 1 + shared/src/i18n/nl/roadtrip.ts | 2 + shared/src/i18n/pl/roadtrip.ts | 2 + shared/src/i18n/ru/roadtrip.ts | 2 + shared/src/i18n/sv/roadtrip.ts | 2 + shared/src/i18n/tr/roadtrip.ts | 2 + shared/src/i18n/uk/roadtrip.ts | 2 + shared/src/i18n/vi/roadtrip.ts | 2 + shared/src/i18n/zh-TW/roadtrip.ts | 1 + shared/src/i18n/zh/roadtrip.ts | 2 + 39 files changed, 1632 insertions(+), 940 deletions(-) delete mode 100644 client/src/components/Roadtrip/RoadtripManualStopModal.test.tsx delete mode 100644 client/src/components/Roadtrip/RoadtripManualStopModal.tsx create mode 100644 client/src/components/Roadtrip/ServiceStopSection.test.tsx create mode 100644 client/src/components/Roadtrip/ServiceStopSection.tsx create mode 100644 client/src/components/Roadtrip/StopChips.tsx create mode 100644 client/src/components/Roadtrip/manualStop.test.ts 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': '其他路线',