fix(share): a stop's stay comes from its time window, not the one-hour place default

The public page showed places.duration_minutes under every stop, and that is
60 on every place nobody set a road-trip stay for, so a shared plan read "1 h"
everywhere. The stay is now the stop's start to end time, per visit, past
midnight included, and a stop without an end shows none.

Closes #2676
This commit is contained in:
Maurice
2026-10-18 11:56:23 +02:00
parent f901982903
commit a9c5cf6d9a
4 changed files with 51 additions and 6 deletions
+20 -2
View File
@@ -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)', () => {
@@ -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)
@@ -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()
@@ -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.
*