fix(planner): a place shows every booking linked to it, not just the first (#2201)

Four render sites resolved the day assignment's booking with find(), while the booking dialog happily links several to the same stop. A shared selector in dayMerge returns them all, time-sorted and stable, and the desktop plan tab, the place inspector, the mobile timeline and the mobile place sheet render the full list; the single-booking case is unchanged pixel for pixel.
This commit is contained in:
Maurice
2026-09-02 19:58:13 +02:00
committed by Maurice
parent 5d849ec292
commit faa27e3608
15 changed files with 425 additions and 217 deletions
+36 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getMergedItems } from './dayMerge'
import { parseTimeToMinutes, getSpanPhase, hidesOnMiddleDay, getTransportRouteEndpoints, getDisplayTimeForDay, getTransportForDay, getAssignmentReservations, getMergedItems } from './dayMerge'
describe('parseTimeToMinutes', () => {
it('parses HH:MM string', () => {
@@ -199,6 +199,41 @@ describe('getTransportForDay', () => {
})
})
describe('getAssignmentReservations', () => {
it('returns every booking pinned to the assignment, not just the first (#2201)', () => {
const reservations = [
{ id: 1, assignment_id: 42, reservation_time: '2025-06-01T10:00:00' },
{ id: 2, assignment_id: 42, reservation_time: '2025-06-01T09:00:00' },
{ id: 3, assignment_id: 7, reservation_time: '2025-06-01T08:00:00' },
]
expect(getAssignmentReservations(reservations, 42).map(r => r.id)).toEqual([2, 1])
})
it('puts untimed bookings last and breaks ties on the id', () => {
const reservations = [
{ id: 5, assignment_id: 42, reservation_time: null },
{ id: 4, assignment_id: 42, reservation_time: null },
{ id: 6, assignment_id: 42, reservation_time: '2025-06-01T09:00:00' },
]
expect(getAssignmentReservations(reservations, 42).map(r => r.id)).toEqual([6, 4, 5])
})
it('returns nothing without an assignment', () => {
const reservations = [{ id: 1, assignment_id: 42, reservation_time: null }]
expect(getAssignmentReservations(reservations, null)).toEqual([])
expect(getAssignmentReservations(reservations, undefined)).toEqual([])
})
it('leaves the caller array untouched', () => {
const reservations = [
{ id: 1, assignment_id: 42, reservation_time: '2025-06-01T10:00:00' },
{ id: 2, assignment_id: 42, reservation_time: '2025-06-01T09:00:00' },
]
getAssignmentReservations(reservations, 42)
expect(reservations.map(r => r.id)).toEqual([1, 2])
})
})
describe('getMergedItems', () => {
it('merges places and notes sorted by sortKey', () => {
const dayAssignments = [
+30
View File
@@ -235,6 +235,36 @@ export function getTransportForDay(opts: {
}).flatMap(r => expandFlightLegsForDay(r, dayId, getDayOrder, days))
}
/**
* Every booking pinned to one day assignment. A stop can carry several (the parking
* pass and the tickets for the same zoo), and the exclusion above keeps all of them
* out of the timeline, so the place row is the only surface that can show them, and
* a `find()` there dropped the rest without trace (#2201).
*
* Earliest first, untimed last, id as the tiebreaker: the store's array order is
* newest-first for locally created bookings and load order otherwise, which would
* shuffle the same two bookings between a reload and a live update.
*/
export function getAssignmentReservations<T extends {
id: number
assignment_id?: number | null
reservation_time?: string | null
}>(reservations: T[], assignmentId: number | null | undefined): T[] {
if (!assignmentId) return []
return reservations
.filter(r => r.assignment_id === assignmentId)
.sort((a, b) => {
const at = a.reservation_time || ''
const bt = b.reservation_time || ''
if (at !== bt) {
if (!at) return 1
if (!bt) return -1
return at < bt ? -1 : 1
}
return a.id - b.id
})
}
/**
* Order items chronologically: anything with a time (a place's place_time, a
* transport/leg display time, a timed note) sorts by that time. An item WITHOUT a