From b5457d9e6a76c7bd14fbc9aa56487689aecdc706 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 23 Sep 2026 21:28:34 +0200 Subject: [PATCH] fix(planner): a booked hotel stays in the place list when road trip stops are hidden in Days Since 4.3.0 every lodging booking types its place as 'hotel', and 'hotel' is one of the road trip stop types. With "Show in Days too" switched off, Days dropped every such place from the trip's place list, so a hotel vanished from the sidebar, from the booking's own place picker and from the Days map the moment it was booked, and a click on it in the day list found nothing. Nothing was deleted: road trip mode kept showing it. Days now leaves out only road trip stops nobody booked. A lodging place stays, and so does any place a stay or a booking points at, on the desktop and on the phone. Trips planned on 4.3.1 show their hotels again without any change to their data. An imported GPX or KML track is no longer offered as the place of a stay, in the booking form, the day's hotel picker and the phone's sheets. A stay that already names a track keeps showing it. --- .../Planner/DayDetailPanel.test.tsx | 11 +++++- .../src/components/Planner/DayDetailPanel.tsx | 4 ++- .../Planner/ReservationModal.test.tsx | 25 +++++++++++++- .../components/Planner/ReservationModal.tsx | 3 +- .../trip/sheets/MAccommodationSheet.tsx | 4 ++- .../screens/trip/sheets/MReservationSheet.tsx | 4 ++- .../tripPlanner/tripPlannerModel.test.ts | 34 ++++++++++++++++++- .../src/pages/tripPlanner/tripPlannerModel.ts | 34 +++++++++++++++++++ .../useTripPlanner.roadtrip.test.tsx | 34 ++++++++++++++++++- .../src/pages/tripPlanner/useTripPlanner.ts | 9 +++-- client/src/utils/stayPlaces.test.ts | 25 ++++++++++++++ client/src/utils/stayPlaces.ts | 14 ++++++++ .../mobile/trip/MAccommodationSheet.test.tsx | 15 +++++++- .../mobile/trip/MReservationSheet.test.tsx | 16 ++++++++- wiki/Accommodations.md | 6 ++-- wiki/Trip-Planner-Overview.md | 2 +- 16 files changed, 225 insertions(+), 15 deletions(-) create mode 100644 client/src/utils/stayPlaces.test.ts create mode 100644 client/src/utils/stayPlaces.ts diff --git a/client/src/components/Planner/DayDetailPanel.test.tsx b/client/src/components/Planner/DayDetailPanel.test.tsx index 9be8ad13d..dfa26ad6e 100644 --- a/client/src/components/Planner/DayDetailPanel.test.tsx +++ b/client/src/components/Planner/DayDetailPanel.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-084 +// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-085 import React from 'react'; import { fireEvent, render, screen, waitFor, within, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; @@ -520,6 +520,15 @@ describe('DayDetailPanel', () => { expect(await screen.findByText('102 Quai de Jemmapes')).toBeInTheDocument(); }); + it('FE-PLANNER-DAYDETAIL-085: the hotel picker leaves out imported tracks', async () => { + const hotel = buildPlace({ id: 10, name: 'Hotel du Nord' }); + const track = buildPlace({ id: 12, name: 'Canal Saint-Martin walk', route_geometry: '[[48.87,2.36],[48.88,2.37]]' }); + render(); + await userEvent.click(await screen.findByText(/Add accommodation/i)); + await screen.findByText('Hotel du Nord'); + expect(screen.queryByText('Canal Saint-Martin walk')).not.toBeInTheDocument(); + }); + it('FE-PLANNER-DAYDETAIL-030: selecting a place in hotel picker enables save button', async () => { const place = buildPlace({ id: 10, name: 'Maison Blanche' }); server.use( diff --git a/client/src/components/Planner/DayDetailPanel.tsx b/client/src/components/Planner/DayDetailPanel.tsx index 439f66fbc..481cf7e9d 100644 --- a/client/src/components/Planner/DayDetailPanel.tsx +++ b/client/src/components/Planner/DayDetailPanel.tsx @@ -21,6 +21,7 @@ import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../ty import { isDayInAccommodationRange } from '../../utils/dayOrder' import { formatClockTime, splitReservationDateTime } from '../../utils/formatters' import { useDayDetail } from './useDayDetail' +import { stayPlaces } from '../../utils/stayPlaces' const WEATHER_ICON_MAP = { Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle, @@ -660,7 +661,8 @@ function HotelPickerModal({ showHotelPicker, setShowHotelPicker, font, t, hotelD {/* Place List */}
{(() => { - const filtered = hotelCategoryFilter ? places.filter(p => p.category_id === hotelCategoryFilter) : places + const offered = stayPlaces(places, hotelForm.place_id) + const filtered = hotelCategoryFilter ? offered.filter(p => p.category_id === hotelCategoryFilter) : offered return filtered.length === 0 ? (
{t('day.noPlacesForHotel')}
) : filtered.map(p => ( diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index e5927db5a..61bff07bd 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -1,4 +1,4 @@ -// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-100 +// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-102 import { render, screen, waitFor, fireEvent, within, act } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { http, HttpResponse } from 'msw'; @@ -627,6 +627,29 @@ describe('ReservationModal', () => { ); }); + it('FE-PLANNER-RESMODAL-101: an imported track is not offered as the place of a stay', async () => { + const hotel = buildPlace({ id: 21, name: 'Hotel Adler' }); + const track = buildPlace({ id: 22, name: 'Rheinsteig', route_geometry: '[[50.1,7.6],[50.2,7.7]]' }); + render(); + + await userEvent.click(screen.getByRole('button', { name: /Accommodation/i })); + const field = screen.getAllByText('Accommodation').find(el => el.tagName === 'LABEL')!.parentElement!; + await userEvent.click(within(field).getByRole('button')); + + expect(screen.getByText('Hotel Adler')).toBeInTheDocument(); + expect(screen.queryByText('Rheinsteig')).not.toBeInTheDocument(); + }); + + it('FE-PLANNER-RESMODAL-102: a stay already booked at a track still shows that track', () => { + const track = buildPlace({ id: 22, name: 'Rheinsteig', route_geometry: '[[50.1,7.6],[50.2,7.7]]' }); + const days = [buildDay({ id: 1 }), buildDay({ id: 2 })]; + const accommodations = [{ id: 7, trip_id: 1, place_id: 22, start_day_id: 1, end_day_id: 2 }]; + const res = buildReservation({ id: 9, type: 'hotel', title: 'Hut', accommodation_id: 7 }); + render(); + + expect(screen.getByText('Rheinsteig')).toBeInTheDocument(); + }); + it('FE-PLANNER-RESMODAL-043: hover styles applied to file picker items', async () => { const res = buildReservation({ id: 5 }); const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' }); diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index c7d73bdaf..ea708266f 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -24,6 +24,7 @@ import type { TripMember } from '../Budget/BudgetPanelMemberChips' import type { BookingExpenseRequest } from './BookingCostsSection.types' import type { BookingReviewDraft } from './parsedItemToDraft' import { typeToCostCategory } from '@trek/shared' +import { stayPlaces } from '../../utils/stayPlaces' const TYPE_OPTIONS = [ { value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel }, @@ -601,7 +602,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p placeholder={t('reservations.meta.pickHotel')} options={[ { value: '', label: '—' }, - ...places.map(p => ({ value: p.id, label: p.name })), + ...stayPlaces(places, form.hotel_place_id).map(p => ({ value: p.id, label: p.name })), ]} searchable size="sm" diff --git a/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx b/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx index 7ce0b31e1..388e8098d 100644 --- a/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MAccommodationSheet.tsx @@ -10,6 +10,7 @@ import { applyStayStops } from '../../../../store/stayStops' import { useTranslation } from '../../../../i18n' import { Eyebrow } from './MTripSheetUi' import type { MTripSheetsProps } from '../MTripShell' +import { stayPlaces } from '../../../../utils/stayPlaces' interface AccommodationPayload { dayId?: number @@ -97,7 +98,8 @@ export default function MAccommodationSheet({ planner, shell }: MTripSheetsProps : (d.title ? t('planner.dayN', { n: i + 1 }) : undefined), })) - const filteredPlaces = categoryFilter != null ? places.filter(p => p.category_id === categoryFilter) : places + const offeredPlaces = stayPlaces(places, form.place_id) + const filteredPlaces = categoryFilter != null ? offeredPlaces.filter(p => p.category_id === categoryFilter) : offeredPlaces const save = async () => { setSaving(true) diff --git a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx index d29902ae5..13517ed8d 100644 --- a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx @@ -20,6 +20,7 @@ import { useTripStore } from '../../../../store/tripStore' import type { TripMember } from '../../../../types' import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types' import type { TripPlanner } from '../MTripShell' +import { stayPlaces } from '../../../../utils/stayPlaces' export interface MReservationSheetProps { planner: TripPlanner @@ -212,6 +213,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati d ? new Date(`${d.slice(0, 10)}T00:00:00Z`).toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : undefined const placeOptions = [{ value: '', label: '—' }, ...places.map(p => ({ value: p.id, label: p.name }))] + const hotelPlaceOptions = [{ value: '', label: '—' }, ...stayPlaces(places, form.hotel_place_id).map(p => ({ value: p.id, label: p.name }))] const dayOptions = days.map(d => ({ value: d.id, label: d.title || t('dayplan.dayN', { n: d.day_number }), @@ -449,7 +451,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati return next }) }} - options={placeOptions} + options={hotelPlaceOptions} placeholder={t('reservations.meta.pickHotel')} searchable size="sm" diff --git a/client/src/pages/tripPlanner/tripPlannerModel.test.ts b/client/src/pages/tripPlanner/tripPlannerModel.test.ts index fc5020a97..7bebe088b 100644 --- a/client/src/pages/tripPlanner/tripPlannerModel.test.ts +++ b/client/src/pages/tripPlanner/tripPlannerModel.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { resolvePoolAssignmentId } from './tripPlannerModel' +import { placesForDays, resolvePoolAssignmentId } from './tripPlannerModel' import { buildAssignment, buildPlace } from '../../../tests/helpers/factories' describe('resolvePoolAssignmentId', () => { @@ -23,3 +23,35 @@ describe('resolvePoolAssignmentId', () => { expect(resolvePoolAssignmentId(assignments, 7)).toBeNull() }) }) + +describe('placesForDays', () => { + const town = buildPlace({ id: 1, stop_type: null }) + const hotel = buildPlace({ id: 2, stop_type: 'hotel' }) + const pump = buildPlace({ id: 3, stop_type: 'fuel' }) + const brauhaus = buildPlace({ id: 4, stop_type: 'restaurant' }) + const campsite = buildPlace({ id: 5, stop_type: 'campsite' }) + const all = [town, hotel, pump, brauhaus, campsite] + const none = { accommodations: [], reservations: [] } + + it('FE-TP-MODEL-001: hands the list back untouched while road trip stops show in Days', () => { + expect(placesForDays(all, false, none)).toBe(all) + }) + + it('FE-TP-MODEL-002: leaves out every road trip stop nobody booked, but never a lodging', () => { + // The hotel's booking may be gone; the 'hotel' type it stamped on the place is not. + expect(placesForDays(all, true, none).map(p => p.id)).toEqual([1, 2]) + }) + + it('FE-TP-MODEL-003: keeps the place a stay is at, whatever type it carries', () => { + const kept = placesForDays(all, true, { accommodations: [{ place_id: 5 }, { place_id: null }], reservations: [] }) + expect(kept.map(p => p.id)).toEqual([1, 2, 5]) + }) + + it('FE-TP-MODEL-004: keeps the place a booking names, directly or through its stay', () => { + const kept = placesForDays(all, true, { + accommodations: [], + reservations: [{ place_id: 4 }, { accommodation_place_id: 5 }, { place_id: null, accommodation_place_id: null }], + }) + expect(kept.map(p => p.id)).toEqual([1, 2, 4, 5]) + }) +}) diff --git a/client/src/pages/tripPlanner/tripPlannerModel.ts b/client/src/pages/tripPlanner/tripPlannerModel.ts index 03e80a4a1..6f49ab4ea 100644 --- a/client/src/pages/tripPlanner/tripPlannerModel.ts +++ b/client/src/pages/tripPlanner/tripPlannerModel.ts @@ -4,8 +4,42 @@ * the FE "page = wiring container + data hook" convention (see PATTERN.md). */ +import { isServiceStopType } from '../../components/Roadtrip/roadtripModel' import type { Assignment } from '../../types' +/** What a stay or a booking says about the place it is for. */ +export interface BookedPlaceSources { + accommodations: { place_id?: number | null }[] + reservations: { place_id?: number | null; accommodation_place_id?: number | null }[] +} + +/** + * The trip's places as Days shows them. + * + * With "Show in Days too" switched off, a place typed as a road trip stop (a pump, a + * rest area, a campsite) belongs to the drive and stays out of Days, list and map alike. + * A place a stay or a booking points at is not such a stop, whatever its type says, and + * neither is lodging: every lodging booking types its place as 'hotel', the type stays + * after the booking is gone, and dropping it took the booked hotel out of the place + * list, the booking's own place picker and the Days map the moment it was booked. + */ +export function placesForDays

( + places: P[], + hideRoadtripStops: boolean, + { accommodations, reservations }: BookedPlaceSources, +): P[] { + if (!hideRoadtripStops) return places + const booked = new Set() + for (const stay of accommodations) { + if (stay.place_id != null) booked.add(stay.place_id) + } + for (const reservation of reservations) { + if (reservation.place_id != null) booked.add(reservation.place_id) + if (reservation.accommodation_place_id != null) booked.add(reservation.accommodation_place_id) + } + return places.filter(place => !isServiceStopType(place.stop_type) || place.stop_type === 'hotel' || booked.has(place.id)) +} + /** * Resolve the day-assignment to use when a place is edited from the Places pool, * where no day is in context. Times live per day-assignment (#1247), so we can diff --git a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx index 403e32e3c..7b343ea96 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx +++ b/client/src/pages/tripPlanner/useTripPlanner.roadtrip.test.tsx @@ -1,5 +1,5 @@ import { roadtripPreferencesRepo } from '../../repo/roadtripPreferencesRepo' -// FE-TP-ROAD-001 to FE-TP-ROAD-153 +// FE-TP-ROAD-001 to FE-TP-ROAD-154 import React from 'react' import { renderHook, act, waitFor } from '@testing-library/react' import { TranslationProvider } from '../../i18n/TranslationContext' @@ -2293,6 +2293,38 @@ it('preserves exclusive service stops when reordering the visible Days stops', a expect(actions.reorderAssignments).toHaveBeenCalledWith(42, 5, [13, 12, 11]) }) +it('FE-TP-ROAD-154: a place a booking points at stays in Days while road trip stops are hidden there', async () => { + // Every lodging booking types its place as 'hotel', and 'hotel' is a road trip stop + // type. With "Show in Days too" off, Days dropped the booked hotel from the place list, + // from the booking's own place picker and from the map the moment it was booked. + const town = buildPlace({ id: 101, name: 'Cologne' }) + const hotel = buildPlace({ id: 102, name: 'Cologne Cathedral', stop_type: 'hotel' }) + const pump = buildPlace({ id: 103, name: 'Pump', stop_type: 'fuel' }) + const brauhaus = buildPlace({ id: 104, name: 'Brauhaus', stop_type: 'restaurant' }) + const campsite = buildPlace({ id: 105, name: 'Camping Rhein', stop_type: 'campsite' }) + seedTrip({ + places: [town, hotel, pump, brauhaus, campsite], + days: [buildDay({ id: 5 }), buildDay({ id: 6 })], + reservations: [ + buildReservation({ id: 9, type: 'restaurant', place_id: 104, day_id: 5 }), + buildReservation({ id: 10, type: 'hotel', accommodation_id: 8, accommodation_place_id: 105 }), + ], + }) + vi.mocked(accommodationRepo.list).mockResolvedValue({ + accommodations: [{ id: 7, trip_id: 42, place_id: 102, start_day_id: 5, end_day_id: 6, reservation_title: null }], + } as never) + useSettingsStore.setState(s => ({ settings: { ...s.settings, roadtrip_service_stops_in_days: false } })) + const { result } = await renderRoadtrip() + + act(() => result.current.toggleRoadtripMode()) + + // The pump nobody booked stays a road trip stop, out of Days. + await waitFor(() => expect(result.current.places.map(p => p.id)).toEqual([101, 102, 104, 105])) + // Road trip mode keeps every place, as before. + act(() => result.current.toggleRoadtripMode()) + expect(result.current.places.map(p => p.id)).toEqual([101, 102, 103, 104, 105]) +}) + /** * The phone's own way into the drive. * diff --git a/client/src/pages/tripPlanner/useTripPlanner.ts b/client/src/pages/tripPlanner/useTripPlanner.ts index f649631de..0dfd67791 100644 --- a/client/src/pages/tripPlanner/useTripPlanner.ts +++ b/client/src/pages/tripPlanner/useTripPlanner.ts @@ -67,7 +67,7 @@ import type { Accommodation, Assignment, TripMember, Day, Place, Reservation } f import { OFM_POSITRON, DEFAULT_MAP_LAT, DEFAULT_MAP_LNG, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults' import { useTileUrl } from '../../hooks/useTileUrl' import { applyStayStops } from '../../store/stayStops' -import { resolvePoolAssignmentId } from './tripPlannerModel' +import { placesForDays, resolvePoolAssignmentId } from './tripPlannerModel' import { isDeepLinkableTripTab, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs' import { isRoutableReservation } from '../../utils/reservationRoutes' import { @@ -175,7 +175,6 @@ export function useTripPlanner() { Object.entries(storedAssignments).map(([dayId, visits]) => [dayId, visits.filter(v => !hidden(v))]), ) }, [roadtripMode, roadtripSettings.roadtrip_service_stops_in_days, storedAssignments]) - const places = useMemo(() => roadtripMode || roadtripSettings.roadtrip_service_stops_in_days !== false ? allPlaces : allPlaces.filter(place => !isServiceStopType(place.stop_type)), [roadtripMode, roadtripSettings.roadtrip_service_stops_in_days, allPlaces]) const toggleRoadtripMode = useCallback(() => { setRoadtripMode(prev => { const next = !prev @@ -185,6 +184,12 @@ export function useTripPlanner() { }, [tripId]) const [collabFeatures, setCollabFeatures] = useState<{ chat: boolean; notes: boolean; links: boolean; polls: boolean; whatsnext: boolean }>({ chat: true, notes: true, links: true, polls: true, whatsnext: true }) const [tripAccommodations, setTripAccommodations] = useState([]) + const places = useMemo( + () => (roadtripMode + ? allPlaces + : placesForDays(allPlaces, roadtripSettings.roadtrip_service_stops_in_days === false, { accommodations: tripAccommodations, reservations })), + [roadtripMode, roadtripSettings.roadtrip_service_stops_in_days, allPlaces, tripAccommodations, reservations], + ) const [allowedFileTypes, setAllowedFileTypes] = useState(null) const [tripMembers, setTripMembers] = useState([]) diff --git a/client/src/utils/stayPlaces.test.ts b/client/src/utils/stayPlaces.test.ts new file mode 100644 index 000000000..22206e3cc --- /dev/null +++ b/client/src/utils/stayPlaces.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { stayPlaces } from './stayPlaces' + +// FE-UTIL-STAYPLACES-001 to FE-UTIL-STAYPLACES-003 + +describe('stayPlaces', () => { + const hotel = { id: 1, route_geometry: null } + const hut = { id: 2 } + const track = { id: 3, route_geometry: '[[50.1,7.6],[50.2,7.7]]' } + + it('FE-UTIL-STAYPLACES-001: offers every place but an imported track', () => { + expect(stayPlaces([hotel, hut, track]).map(p => p.id)).toEqual([1, 2]) + expect(stayPlaces([hotel, hut, track], '').map(p => p.id)).toEqual([1, 2]) + expect(stayPlaces([hotel, hut, track], null).map(p => p.id)).toEqual([1, 2]) + }) + + it('FE-UTIL-STAYPLACES-002: keeps the track a stay already names, by number or by text', () => { + expect(stayPlaces([hotel, track], 3).map(p => p.id)).toEqual([1, 3]) + expect(stayPlaces([hotel, track], '3').map(p => p.id)).toEqual([1, 3]) + }) + + it('FE-UTIL-STAYPLACES-003: a stay at an ordinary place lets no track in', () => { + expect(stayPlaces([hotel, track], 1).map(p => p.id)).toEqual([1]) + }) +}) diff --git a/client/src/utils/stayPlaces.ts b/client/src/utils/stayPlaces.ts new file mode 100644 index 000000000..8364d831a --- /dev/null +++ b/client/src/utils/stayPlaces.ts @@ -0,0 +1,14 @@ +/** + * The places a stay can be booked at: every place but an imported GPX or KML track, + * which is a route to follow, not somewhere to sleep. + * + * The place a stay already names stays in the list, track or not, so a booking made + * before tracks were left out still shows what it points at and saves unchanged. + */ +export function stayPlaces

( + places: P[], + currentPlaceId?: number | string | null, +): P[] { + return places.filter(place => !place.route_geometry + || (currentPlaceId != null && currentPlaceId !== '' && String(place.id) === String(currentPlaceId))) +} diff --git a/client/tests/unit/mobile/trip/MAccommodationSheet.test.tsx b/client/tests/unit/mobile/trip/MAccommodationSheet.test.tsx index e122e06b4..331209e0f 100644 --- a/client/tests/unit/mobile/trip/MAccommodationSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MAccommodationSheet.test.tsx @@ -8,7 +8,7 @@ import { useSettingsStore } from '../../../../src/store/settingsStore' import { isBlurred } from '../../../helpers/bookingCodeBlur' import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-ACCSH-001 to FE-MOB-ACCSH-029 +// FE-MOB-ACCSH-001 to FE-MOB-ACCSH-031 // The pickers have their own suites; here they only need to be addressable, so // they render as plain controls. data-value keeps the requested value readable @@ -395,4 +395,17 @@ describe('MAccommodationSheet', () => { expect(update.mock.calls[0][2]).toMatchObject({ confirmation: 'ABC-1' }) }) }) + + it('FE-MOB-ACCSH-030: an imported track is not offered as the place of a stay', () => { + const track = { id: 103, name: 'Donauradweg', address: null, category_id: null, image_url: null, route_geometry: '[[48.2,16.3],[48.3,16.4]]' } + setup(makePlanner({ places: [...PLACES, track] })) + expect(screen.getByText('Hotel Sacher')).toBeInTheDocument() + expect(screen.queryByText('Donauradweg')).toBeNull() + }) + + it('FE-MOB-ACCSH-031: a stay already at a track still lists that track', () => { + const track = { id: 103, name: 'Donauradweg', address: null, category_id: null, image_url: null, route_geometry: '[[48.2,16.3],[48.3,16.4]]' } + setup(makePlanner({ places: [...PLACES, track], tripAccommodations: [{ ...EXISTING, place_id: 103 }] }), makeShell({ dayId: 12, accId: 77 })) + expect(placeRow('Donauradweg')).toBeInTheDocument() + }) }) diff --git a/client/tests/unit/mobile/trip/MReservationSheet.test.tsx b/client/tests/unit/mobile/trip/MReservationSheet.test.tsx index f2bcce499..1f708638f 100644 --- a/client/tests/unit/mobile/trip/MReservationSheet.test.tsx +++ b/client/tests/unit/mobile/trip/MReservationSheet.test.tsx @@ -9,7 +9,7 @@ import { buildPlanner } from '../../../helpers/mobileTrip' import { resetAllStores } from '../../../helpers/store' import { act, fireEvent, render, screen, waitFor } from '../../../helpers/render' -// FE-MOB-RESSH-001 to FE-MOB-RESSH-061 +// FE-MOB-RESSH-001 to FE-MOB-RESSH-063 // Date/time/select pickers have their own suites — here they only have to be // addressable, so they render as plain controls. @@ -469,6 +469,20 @@ describe('MReservationSheet', () => { expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('Philharmonikerstrasse 4') }) + it('FE-MOB-RESSH-062: an imported track is not offered as the place of a stay', () => { + const track = { id: 103, name: 'Donauradweg', address: null, route_geometry: '[[48.2,16.3],[48.3,16.4]]' } + setup(makePlanner({ places: [...PLACES, track], editingReservation: HOTEL })) + const hotelOptions = [...screen.getByLabelText('reservations.meta.pickHotel').querySelectorAll('option')].map(o => o.textContent) + expect(hotelOptions).toContain('Hotel Sacher') + expect(hotelOptions).not.toContain('Donauradweg') + }) + + it('FE-MOB-RESSH-063: a stay already at a track keeps that track selected', () => { + const track = { id: 103, name: 'Donauradweg', address: null, route_geometry: '[[48.2,16.3],[48.3,16.4]]' } + setup(makePlanner({ places: [...PLACES, track], tripAccommodations: [{ ...ACCOMMODATIONS[0], place_id: 103 }], editingReservation: HOTEL })) + expect(screen.getByLabelText('reservations.meta.pickHotel')).toHaveAttribute('data-value', '103') + }) + it('FE-MOB-RESSH-023: a hotel without an accommodation falls back to the booking location', () => { setup(makePlanner({ editingReservation: { ...HOTEL, accommodation_id: null } })) expect(screen.getByPlaceholderText('reservations.locationPlaceholder')).toHaveValue('fallback address') diff --git a/wiki/Accommodations.md b/wiki/Accommodations.md index 8190e983a..364a6b89e 100644 --- a/wiki/Accommodations.md +++ b/wiki/Accommodations.md @@ -77,8 +77,10 @@ A few details worth knowing: placed, and the booking simply rides along with it. - Moving the booking to a different check-in day moves its stop with it. Deleting the booking removes the stop it created, and leaves a stop you placed yourself standing. -- Hotels are service stops, so **Show in Days too** under **Service stops** in the Road - Trip settings also decides whether they appear in the places list. +- Hotels count as service stops on the drive, so **Show in Days too** under **Service + stops** in the Road Trip settings decides whether the hotel's stop shows in the day list. + The hotel itself always stays in the places list, on the Days map and in the booking + forms, whatever the switch says. Nights booked before this existed are given their stop when the server upgrades, so trips you already have show their hotels on the drive without anyone re-saving anything. Trips diff --git a/wiki/Trip-Planner-Overview.md b/wiki/Trip-Planner-Overview.md index 9d0f57c40..e27bb81cc 100644 --- a/wiki/Trip-Planner-Overview.md +++ b/wiki/Trip-Planner-Overview.md @@ -108,7 +108,7 @@ Driving settings includes **Show hazard areas**, off by default and shared by th - [Reservations-and-Bookings](Reservations-and-Bookings) - [Admin-Addons](Admin-Addons) -Driving settings also controls **Show in Days too** under **Service stops**. It is on by default and applies to every service stop in the trip, including existing stops. Turn it off to keep service stops exclusively in the Roadtrip view and omit them from the normal Days view and its route. Turning it on restores their visibility without creating duplicates. The setting is shared with fellow travellers and can also be changed through `update_roadtrip_settings` using `roadtrip_service_stops_in_days`. +Driving settings also controls **Show in Days too** under **Service stops**. It is on by default and applies to every service stop in the trip, including existing stops. Turn it off to keep service stops exclusively in the Roadtrip view and omit them from the normal Days view and its route. Hotels, and any place a booking points at, stay in the places list and on the Days map either way. Turning it on restores their visibility without creating duplicates. The setting is shared with fellow travellers and can also be changed through `update_roadtrip_settings` using `roadtrip_service_stops_in_days`. **Start and end each day at your stay**, under **Route line** in Driving settings, starts each Roadtrip day after a booked night at that hotel and ends each day before one there. It is off by default and shared by the trip. The hotel rows are drawn from the booking, not stored as stops, so the Days view stays as it is and switching it off gives the drive exactly as before. On the phone it is the one switch in **Driving figures**. See [Road-Trip](Road-Trip#starting-and-ending-the-day-at-the-stay).