Files
TREK/client/src/utils/plannedPlaces.ts
T
Maurice 4d6820a4d4 @
fix(planner): route a layover flight per leg, and count linked places as planned

Two v4.0.0 reports, both a surface answering a question with less than it had.

The map built its own waypoint order. It read the BOOKING position and never
expanded metadata.legs, while a multi-leg booking stores its position per leg —
so a Frankfurt to Vancouver flight via Reykjavik was dropped from the list and
the airport before it was joined to the airport after it in one driving run
across the Atlantic. It now takes the order from getMergedItems, the same one
the day plan renders, which brings the span filter and the leg expansion with
it. getMergedItems in turn had never read day_plan_position, the one slot the
map did read, which is how the two could disagree at all.

getTransportRouteEndpoints handed back the booking outermost airports for every
leg. A stop between two legs was therefore routed from the arrival airport back
to the departure one — a drive from Vancouver to Frankfurt with a cafe in
Reykjavik on the way. A leg now spans its own pair, when the endpoints line up
one per stop with the legs; anything ambiguous keeps the role lookup.

Being planned meant one thing: a day_assignments row. A hotel is linked through
its stay and a venue through its booking, and neither is ever dragged onto a
day, so both sat under Unplanned while the day header printed their names. The
three places that asked the question — desktop sidebar, map markers, phone
browser — now ask one helper that also counts a stay and a day-anchored
booking. A booking that names no day still counts as unplanned, because nothing
says when it happens, and the day plan renders nothing for it either.
@
2026-08-27 19:21:06 +02:00

89 lines
3.5 KiB
TypeScript

import { isDayInAccommodationRange } from './dayOrder'
import type { AssignmentsMap, Day } from '../types'
/**
* Which of a trip's places are "planned", i.e. have a place in the itinerary.
*
* Dragging a place onto a day is the obvious way, and for a long time it was the
* only one this answered — so a hotel linked to a booking sat under "Unplanned"
* while its name was printed in the day header two panels away (#2072). A stay is
* on days by construction (day_accommodations.start_day_id / end_day_id are both
* NOT NULL), and a booking that names a day is on the plan as much as any dragged
* pin, so both count.
*
* A booking with no day does not: it exists, but nothing says when, which is
* exactly what "unplanned" means.
*
* The three surfaces that ask this — the desktop sidebar, the map's marker set and
* the phone browser — have to agree, or the list and the pins disagree about the
* same place (#1541). Hence one function rather than three predicates.
*/
/** A stay, as the planner and the reservation rows carry it. */
export interface PlannedAccommodation {
place_id?: number | null
start_day_id?: number | null
end_day_id?: number | null
}
/** A booking, reduced to what decides whether it plans its place. */
export interface PlannedReservation {
place_id?: number | null
day_id?: number | null
}
export interface PlannedSources {
assignments: AssignmentsMap
accommodations?: PlannedAccommodation[]
reservations?: PlannedReservation[]
}
function addAssignmentPlaces(ids: Set<number>, dayAssignments: { place?: { id?: number | null } | null }[]): void {
for (const a of dayAssignments) {
if (a.place?.id != null) ids.add(a.place.id)
}
}
/** Every place the trip has a plan for, across all days. */
export function plannedPlaceIds({ assignments, accommodations = [], reservations = [] }: PlannedSources): Set<number> {
const ids = new Set<number>()
for (const dayAssignments of Object.values(assignments || {})) addAssignmentPlaces(ids, dayAssignments)
for (const acc of accommodations) {
// A deleted place leaves place_id NULL behind (ON DELETE SET NULL).
if (acc.place_id != null) ids.add(acc.place_id)
}
for (const r of reservations) {
// day_id only, matching what the day plan itself renders: a booking carrying
// just an end day appears on no day at all.
if (r.place_id != null && r.day_id != null) ids.add(r.place_id)
}
return ids
}
/** The same question for one day — what the map answers while a day is selected. */
export function plannedPlaceIdsForDay(
dayId: number,
days: Day[],
{ assignments, accommodations = [], reservations = [] }: PlannedSources,
): Set<number> {
const ids = new Set<number>()
addAssignmentPlaces(ids, assignments?.[String(dayId)] || [])
const day = (days || []).find(d => d.id === dayId)
for (const acc of accommodations) {
if (acc.place_id == null || acc.start_day_id == null) continue
const end = acc.end_day_id ?? acc.start_day_id
// A stay covers the nights in between, so its place is planned on each of
// them, not only on the two days it is pinned to. The day rows are the only
// thing that can order them; without them, fall back to the id range the
// rest of the planner uses.
const inRange = day
? isDayInAccommodationRange(day, acc.start_day_id, end, days)
: dayId >= Math.min(acc.start_day_id, end) && dayId <= Math.max(acc.start_day_id, end)
if (inRange) ids.add(acc.place_id)
}
for (const r of reservations) {
if (r.place_id != null && r.day_id === dayId) ids.add(r.place_id)
}
return ids
}