diff --git a/client/src/pages/SharedTripPage.test.tsx b/client/src/pages/SharedTripPage.test.tsx index 15b61fe2f..aaf6691e6 100644 --- a/client/src/pages/SharedTripPage.test.tsx +++ b/client/src/pages/SharedTripPage.test.tsx @@ -1586,8 +1586,9 @@ describe('SharedTripPage', () => { id: 301, day_id: 21, order_index: 0, notes: 'go early, before the coaches', place: { id: 501, name: 'Louvre', description: 'The big one', address: 'Rue de Rivoli, Paris', - lat: 48.86, lng: 2.34, category_id: null, price: null, place_time: '09:00', end_time: null, - duration_minutes: 150, notes: 'Skip the pyramid queue', website: 'https://louvre.fr', + // The stay comes from the window; the place default of an hour is not a plan (#2676). + lat: 48.86, lng: 2.34, category_id: null, price: null, place_time: '09:00', end_time: '11:30', + duration_minutes: 60, notes: 'Skip the pyramid queue', website: 'https://louvre.fr', phone: '+33 1 40 20 50 50', image_url: null, transport_mode: 'walking', category: null, tags: [], }, }, @@ -1639,6 +1640,23 @@ describe('SharedTripPage', () => { expect(document.querySelector('a[href^="javascript:"]')).toBeNull(); expect(screen.queryByText(/min$/)).toBeNull(); }); + + it('shows no stay for a stop with only a start, whatever the place default says (#2676)', async () => { + const startOnly = { + 21: [{ + id: 303, day_id: 21, order_index: 0, notes: null, + place: { + id: 503, name: 'Café', description: null, address: 'Somewhere 1', lat: null, lng: null, + duration_minutes: 60, notes: null, website: null, phone: null, + category_id: null, price: null, place_time: '15:00', end_time: null, image_url: null, + transport_mode: 'walking', category: null, tags: [], + }, + }], + }; + await open('start-only-token', payload({ days, assignments: startOnly })); + await waitFor(() => expect(screen.getByText('Somewhere 1')).toBeInTheDocument()); + expect(screen.queryByText('1 h')).toBeNull(); + }); }); describe('FE-PAGE-SHARED-039: a booking shows its note and its link, and nothing that is not http(s) (#2320)', () => { diff --git a/client/src/pages/sharedTrip/SharedPlaceDetails.tsx b/client/src/pages/sharedTrip/SharedPlaceDetails.tsx index a71532eb4..34f7dfdca 100644 --- a/client/src/pages/sharedTrip/SharedPlaceDetails.tsx +++ b/client/src/pages/sharedTrip/SharedPlaceDetails.tsx @@ -4,7 +4,7 @@ import { fs } from '../../components/shared/DialogShell' import { Tooltip } from '../../components/shared/Tooltip' import { WhiteBadge } from '../../components/Planner/planParts' import { getGoogleMapsUrlForPlace } from '../../components/Planner/placeGoogleMaps' -import { formatDurationMinutes, isHttpUrl } from './sharedTripModel' +import { formatDurationMinutes, isHttpUrl, plannedStayMinutes } from './sharedTripModel' /** * The read-only detail of one stop on a shared plan (#2320). @@ -14,7 +14,8 @@ import { formatDurationMinutes, isHttpUrl } from './sharedTripModel' * usually also left a note on the place and another on the day it was * planned for, and a link would show none of that. This shows what the owner * chose to share, laid out the way the planner lays it out: address, then - * description, then the two notes, then how long they meant to stay, then + * description, then the two notes, then how long they meant to stay (from the + * stop's start and end time), then * the ways to reach the place. * * Nothing here is editable and nothing here is a route into the app: every @@ -46,7 +47,7 @@ export function SharedPlaceDetails({ place, assignmentNotes }: { place: SharedPl name: place.name, address: place.address ?? null, lat: place.lat ?? null, lng: place.lng ?? null, google_place_id: null, google_ftid: null, }) - const duration = formatDurationMinutes(place.duration_minutes) + const duration = formatDurationMinutes(plannedStayMinutes(place.place_time, place.end_time)) const dayNote = assignmentNotes?.trim() || null const placeNote = place.notes?.trim() || null const hasLinks = !!(website || phone || mapsUrl) diff --git a/client/src/pages/sharedTrip/sharedTripModel.test.ts b/client/src/pages/sharedTrip/sharedTripModel.test.ts index 9cd4c12d9..eec77ec09 100644 --- a/client/src/pages/sharedTrip/sharedTripModel.test.ts +++ b/client/src/pages/sharedTrip/sharedTripModel.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest' import { AxiosError, AxiosHeaders } from 'axios' import { - coverSrc, dayHasEntries, formatDateRange, formatDurationMinutes, groupInOrder, isHttpUrl, isSharedTripPayload, legFacts, linkHost, + coverSrc, dayHasEntries, formatDateRange, formatDurationMinutes, groupInOrder, isHttpUrl, isSharedTripPayload, legFacts, linkHost, plannedStayMinutes, sharedTripLoadError, spanLabelKey, stopNumbers, transportFacts, unplannedPlaces, } from './sharedTripModel' @@ -25,6 +25,15 @@ describe('sharedTripModel (#2320)', () => { expect(formatDurationMinutes(89.6)).toBe('1 h 30 min') }) + it('reads the planned stay off the time window, never off the place default (#2676)', () => { + expect(plannedStayMinutes('10:00', '12:30')).toBe(150) + expect(plannedStayMinutes('22:30', '01:00')).toBe(150) + expect(plannedStayMinutes('10:00', null)).toBeNull() + expect(plannedStayMinutes(null, '12:00')).toBeNull() + expect(plannedStayMinutes('10:00', '10:00')).toBeNull() + expect(plannedStayMinutes(undefined, undefined)).toBeNull() + }) + it('writes nothing for a missing, zero or nonsense figure', () => { expect(formatDurationMinutes(null)).toBeNull() expect(formatDurationMinutes(undefined)).toBeNull() diff --git a/client/src/pages/sharedTrip/sharedTripModel.ts b/client/src/pages/sharedTrip/sharedTripModel.ts index 4b70535b5..7d71e42e1 100644 --- a/client/src/pages/sharedTrip/sharedTripModel.ts +++ b/client/src/pages/sharedTrip/sharedTripModel.ts @@ -3,6 +3,7 @@ * page and its detail blocks can share them and a test can drive them * without rendering. */ +import { parseTimeToMinutes } from '../../utils/dayMerge' /** * Whether a string is a link the page may render as one. @@ -22,6 +23,22 @@ export function isHttpUrl(value: unknown): value is string { } } +/** + * How long the owner planned to stay, read off the stop's time window. + * + * Not `places.duration_minutes`: every place carries one, 60 unless someone + * set a road-trip stay, so a link showed "1 h" under every stop (#2676). The + * window is what the owner actually planned, and it can differ per visit. + * Without an end there is no stay to show. An end before the start runs past + * midnight. + */ +export function plannedStayMinutes(start: string | null | undefined, end: string | null | undefined): number | null { + const from = parseTimeToMinutes(start) + const to = parseTimeToMinutes(end) + if (from == null || to == null || from === to) return null + return to > from ? to - from : to + 24 * 60 - from +} + /** * "45 min", "2 h", "1 h 30 min" — the planned time at a place. *