Files
TREK/client/tests/unit/mobile/trip/planTimelineModel.test.ts
T
2026-10-04 12:59:55 +02:00

862 lines
47 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest'
import { Cloud, CloudLightning, CloudRain, CloudSnow, Sun, Wind } from 'lucide-react'
import {
breaksChronology, buildPlanRows, cityPillsForDay, findUpNext, getTransitMeta, hotelChipsForDay,
hotelLegsForDay, itemHasTime, parseReservationMeta, transportSubtitle, weatherIconFor,
type TransportEntry,
} from '../../../../src/mobile/screens/trip/plan/planTimelineModel'
import { getDisplayTimeForDay, getMergedItems, getTransportForDay, type MergedItem } from '../../../../src/utils/dayMerge'
import { buildDayRouteRuns, hotelBookendOf, type DayRoutePoint } from '../../../../src/components/Map/dayRoutePlan'
import { buildAssignment, buildDayNote, buildPlace, buildReservation } from '../../../helpers/factories'
import type {
Accommodation, Assignment, Day, DayNote, Place, Reservation, RouteSegment, TranslationFn,
} from '../../../../src/types'
// FE-MOB-PTLM-001 to FE-MOB-PTLM-060
const DAYS = [
{ id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null },
{ id: 2, trip_id: 1, day_number: 2, date: '2026-05-02', title: 'Old Town' },
{ id: 3, trip_id: 1, day_number: 3, date: '2026-05-03', title: null },
] as unknown as Day[]
const DAY2 = DAYS[1]
function place(id: number, name: string, lat: number | null, lng: number | null, time: string | null = null) {
return buildPlace({ id, name, lat, lng, place_time: time })
}
function assignment(id: number, order: number, p: ReturnType<typeof place>): Assignment {
return buildAssignment({ id, day_id: 2, order_index: order, place_id: p.id, place: p })
}
function seg(from: [number, number], to: [number, number], overrides: Partial<RouteSegment> = {}): RouteSegment {
return {
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2],
from,
to,
distance: 1200,
duration: 600,
walkingText: '15 min',
drivingText: '3 min',
distanceText: '1.2 km',
...overrides,
}
}
// What useRouteCalculation hands the timeline: one segment per pair of neighbouring
// waypoints in each drawn run, the hotel bookends tagged as such.
const segmentsOf = (runs: DayRoutePoint[][]): RouteSegment[] =>
runs.flatMap(run => run.slice(1).map((p, i) => {
const hotelBookend = hotelBookendOf(run[i], p)
return seg([run[i].lat, run[i].lng], [p.lat, p.lng], hotelBookend ? { hotelBookend } : {})
}))
const placeItem = (a: Assignment): MergedItem => ({ type: 'place', sortKey: a.order_index, data: a })
const noteItem = (n: DayNote): MergedItem => ({ type: 'note', sortKey: n.sort_order ?? 0, data: n })
const transportItem = (r: TransportEntry): MergedItem => ({ type: 'transport', sortKey: 0, data: r })
function tourAssignment(id: number, order: number, geometry: [number, number][], currentGeometry = JSON.stringify(geometry)): Assignment {
const [lat, lng] = geometry[0]
const tourPlace = buildPlace({ id: id + 100, name: `Tour ${id}`, lat, lng, route_geometry: currentGeometry })
return buildAssignment({ id, day_id: 2, order_index: order, place_id: tourPlace.id, place: tourPlace,
tour_place_id: tourPlace.id, tour_route_geometry: JSON.stringify(geometry) })
}
function tourRows(assignments: Assignment[], routeSegments: RouteSegment[], toursEnabled = true) {
return buildPlanRows({
merged: assignments.map(placeItem), reservations: [], routeSegments, dayId: 2, toursEnabled,
places: assignments.flatMap(assignment => assignment.place ? [assignment.place as Place] : []),
})
}
const connectors = (rows: ReturnType<typeof buildPlanRows>) =>
rows.flatMap(row => row.kind === 'conn' ? [row.seg] : [])
function accommodation(overrides: Partial<Accommodation>): Accommodation {
return {
id: 1,
trip_id: 1,
place_id: null,
start_day_id: 1,
end_day_id: 3,
check_in: null,
check_out: null,
place_name: 'Hotel Sacher',
place_lat: 48.2,
place_lng: 16.35,
...overrides,
} as unknown as Accommodation
}
describe('planTimelineModel — metadata parsing', () => {
it('FE-MOB-PTLM-001: returns an already-parsed metadata object untouched', () => {
const meta = { airline: 'LH', legs: [] }
expect(parseReservationMeta({ ...buildReservation(), metadata: meta } as unknown as Reservation)).toBe(meta)
})
it('FE-MOB-PTLM-002: parses a JSON metadata string', () => {
expect(parseReservationMeta(buildReservation({ metadata: '{"airline":"LH"}' }))).toEqual({ airline: 'LH' })
})
it('FE-MOB-PTLM-003: heals a double-encoded metadata string', () => {
const doubled = JSON.stringify(JSON.stringify({ airline: 'LH', flight_number: 'LH123' }))
expect(parseReservationMeta(buildReservation({ metadata: doubled }))).toEqual({
airline: 'LH', flight_number: 'LH123',
})
})
it('FE-MOB-PTLM-004: falls back to an empty object for broken, empty or scalar metadata', () => {
expect(parseReservationMeta(buildReservation({ metadata: '{not json' }))).toEqual({})
expect(parseReservationMeta(buildReservation({ metadata: '' }))).toEqual({})
expect(parseReservationMeta(buildReservation({ metadata: null }))).toEqual({})
expect(parseReservationMeta(buildReservation({ metadata: '5' }))).toEqual({})
// Double-encoded, with an inner payload that is broken or empty.
expect(parseReservationMeta(buildReservation({ metadata: JSON.stringify('{not json') }))).toEqual({})
expect(parseReservationMeta(buildReservation({ metadata: '""' }))).toEqual({})
})
it('FE-MOB-PTLM-005: reads transit metadata only from transit reservations with legs', () => {
const legs = [{ mode: 'subway', line: 'U2' }]
const transit = buildReservation({
type: 'transit', metadata: JSON.stringify({ transit: { legs, transfers: 1, duration: 900 } }),
})
expect(getTransitMeta(transit)).toEqual({ legs, transfers: 1, duration: 900 })
expect(getTransitMeta(buildReservation({ type: 'flight', metadata: JSON.stringify({ transit: { legs } }) }))).toBeNull()
expect(getTransitMeta(buildReservation({ type: 'transit', metadata: '{}' }))).toBeNull()
expect(getTransitMeta(buildReservation({ type: 'transit', metadata: '{"transit":{"legs":"nope"}}' }))).toBeNull()
})
})
describe('planTimelineModel — transportSubtitle', () => {
it('FE-MOB-PTLM-006: renders a synthetic leg as from → to', () => {
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 0, total: 2, from: 'FRA', to: 'IST' } } as TransportEntry
expect(transportSubtitle(res)).toBe('FRA → IST')
})
it('FE-MOB-PTLM-007: renders a leg without endpoints as an empty subtitle', () => {
const res = { ...buildReservation({ type: 'flight' }), __leg: { index: 1, total: 2, from: null, to: null } } as TransportEntry
expect(transportSubtitle(res)).toBe('')
})
it('FE-MOB-PTLM-008: joins airline, flight number and airports for a flight', () => {
const res = buildReservation({
type: 'flight',
metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123', departure_airport: 'FRA', arrival_airport: 'HND' }),
})
expect(transportSubtitle(res)).toBe('LH · LH123 · FRA → HND')
})
it('FE-MOB-PTLM-009: omits the airport pair when neither airport is known', () => {
const res = buildReservation({ type: 'flight', metadata: JSON.stringify({ airline: 'LH', flight_number: 'LH123' }) })
expect(transportSubtitle(res)).toBe('LH · LH123')
})
it('FE-MOB-PTLM-010: labels platform and seat for a train', () => {
const full = buildReservation({
type: 'train', metadata: JSON.stringify({ train_number: 'ICE 599', platform: '7', seat: '32A' }),
})
expect(transportSubtitle(full)).toBe('ICE 599 · Gl. 7 · Sitz 32A')
expect(transportSubtitle(buildReservation({ type: 'train', metadata: '{"train_number":"ICE 599"}' }))).toBe('ICE 599')
})
it('FE-MOB-PTLM-011: falls back to the location for any other type', () => {
expect(transportSubtitle(buildReservation({ type: 'bus', location: 'Central Station' }))).toBe('Central Station')
expect(transportSubtitle(buildReservation({ type: 'bus', location: null }))).toBe('')
})
})
describe('planTimelineModel — buildPlanRows', () => {
const museum = assignment(11, 0, place(101, 'Museum', 48.1, 16.1))
const park = assignment(12, 1, place(102, 'Park', 48.2, 16.2))
it('FE-MOB-PTLM-012: maps places, notes and transports to keyed rows', () => {
const note = buildDayNote({ id: 41, day_id: 2, text: 'Buy tickets' })
const bus = buildReservation({ id: 51, type: 'bus', day_id: 2, title: 'Bus 13A' })
const rows = buildPlanRows({
merged: [placeItem(museum), noteItem(note), transportItem(bus)],
reservations: [bus],
routeSegments: [],
dayId: 2,
})
expect(rows.map(r => [r.kind, r.key])).toEqual([
['place', 'pl-11'],
['note', 'note-41'],
['transport', 'tr-51'],
])
})
it('FE-MOB-PTLM-013: links a place row to the reservation booked on it', () => {
const dinner = buildReservation({ id: 52, type: 'restaurant', day_id: 2, assignment_id: 11 })
const rows = buildPlanRows({
merged: [placeItem(museum), placeItem(park)], reservations: [dinner], routeSegments: [], dayId: 2,
})
const [first, second] = rows.filter(r => r.kind === 'place')
expect(first.kind === 'place' && first.linkedReservations[0]).toBe(dinner)
expect(second.kind === 'place' && second.linkedReservations.length === 0).toBe(true)
})
it('FE-MOB-PTLM-014: keys a synthetic leg row by its leg index', () => {
const legRes = { ...buildReservation({ id: 60, type: 'flight', day_id: 2 }), __leg: { index: 1, total: 2, from: 'IST', to: 'NRT' } } as TransportEntry
const rows = buildPlanRows({ merged: [transportItem(legRes)], reservations: [], routeSegments: [], dayId: 2 })
expect(rows[0].key).toBe('tr-60-leg1')
})
it('FE-MOB-PTLM-015: recognises a transit booking as its own row kind', () => {
const transit = buildReservation({
id: 61, type: 'transit', day_id: 2,
metadata: JSON.stringify({ transit: { legs: [{ mode: 'subway', line: 'U2' }], transfers: 0 } }),
})
const rows = buildPlanRows({ merged: [transportItem(transit)], reservations: [], routeSegments: [], dayId: 2 })
expect(rows[0].kind).toBe('transit')
expect(rows[0].kind === 'transit' && rows[0].transit.legs).toHaveLength(1)
})
it('FE-MOB-PTLM-016: hides a car rental on the days between pickup and drop-off', () => {
const car = buildReservation({ id: 62, type: 'car', day_id: 1, end_day_id: 3 })
expect(buildPlanRows({ merged: [transportItem(car)], reservations: [], routeSegments: [], dayId: 2 })).toEqual([])
expect(buildPlanRows({ merged: [transportItem(car)], reservations: [], routeSegments: [], dayId: 1 })).toHaveLength(1)
})
it('FE-MOB-PTLM-043: hides a parking on the days between drop-off and pickup (#1937)', () => {
const parking = buildReservation({ id: 63, type: 'parking', day_id: 1, end_day_id: 3 })
expect(buildPlanRows({ merged: [transportItem(parking)], reservations: [], routeSegments: [], dayId: 2 })).toEqual([])
expect(buildPlanRows({ merged: [transportItem(parking)], reservations: [], routeSegments: [], dayId: 1 })).toHaveLength(1)
expect(buildPlanRows({ merged: [transportItem(parking)], reservations: [], routeSegments: [], dayId: 3 })).toHaveLength(1)
})
it('FE-MOB-PTLM-017: slots a connector between two located places and tags its origin', () => {
const leg = seg([48.1, 16.1], [48.2, 16.2])
const rows = buildPlanRows({
merged: [placeItem(museum), placeItem(park)], reservations: [], routeSegments: [leg], dayId: 2,
})
expect(rows.map(r => r.kind)).toEqual(['place', 'conn', 'place'])
const conn = rows[1]
expect(conn.kind === 'conn' && conn.seg).toBe(leg)
expect(conn.kind === 'conn' && conn.assignmentId).toBe(11)
expect(conn.key).toBe('conn-pl-11')
})
it('FE-MOB-PTLM-018: connects across an intervening note', () => {
const note = buildDayNote({ id: 41, day_id: 2 })
const rows = buildPlanRows({
merged: [placeItem(museum), noteItem(note), placeItem(park)],
reservations: [],
routeSegments: [seg([48.1, 16.1], [48.2, 16.2])],
dayId: 2,
})
expect(rows.map(r => r.kind)).toEqual(['place', 'conn', 'note', 'place'])
})
it('FE-MOB-PTLM-019: puts the drive past a booking without a location under that booking (#2502)', () => {
// The route rides straight past a bus saved without its stops and draws the road
// between the two places; the desktop day plan shows that leg under the bus.
const bus = buildReservation({ id: 51, type: 'bus', day_id: 2 })
const rows = buildPlanRows({
merged: [placeItem(museum), transportItem(bus), placeItem(park)],
reservations: [],
routeSegments: [seg([48.1, 16.1], [48.2, 16.2])],
dayId: 2,
})
expect(rows.map(r => r.key)).toEqual(['pl-11', 'tr-51', 'conn-tr-51', 'pl-12'])
// The leg was routed in the mode of the stop it left, so the menu edits that stop.
expect(rows[2]).toMatchObject({ kind: 'conn', assignmentId: 11 })
})
it('FE-MOB-PTLM-020: skips places without coordinates and unmatched segments', () => {
const nowhere = assignment(13, 2, place(103, 'Idea', null, null))
const rows = buildPlanRows({
merged: [placeItem(nowhere), placeItem(museum)],
reservations: [],
routeSegments: [seg([49.9, 17.9], [48.2, 16.2])],
dayId: 2,
})
expect(rows.map(r => r.kind)).toEqual(['place', 'place'])
})
it('FE-MOB-PTLM-021: consumes each segment only once', () => {
const again = assignment(14, 2, place(104, 'Museum again', 48.1, 16.1))
const parkAgain = assignment(15, 3, place(105, 'Park again', 48.2, 16.2))
const rows = buildPlanRows({
merged: [placeItem(museum), placeItem(park), placeItem(again), placeItem(parkAgain)],
reservations: [],
routeSegments: [seg([48.1, 16.1], [48.2, 16.2])],
dayId: 2,
})
// Only the first Museum → Park hop finds the single matching leg.
expect(rows.filter(r => r.kind === 'conn')).toHaveLength(1)
})
it('uses only Tour boundary connectors and leaves saved geometry unrouted', () => {
const before = assignment(31, 0, place(301, 'Before', 48.1, 11.1))
const tour = tourAssignment(32, 1, [[48.103, 11.103], [48.11, 11.11], [48.12, 11.12]])
const after = assignment(33, 2, place(303, 'After', 48.13, 11.13))
const incoming = seg([48.1, 11.1], [48.103, 11.103])
const outgoing = seg([48.12, 11.12], [48.13, 11.13])
const automaticTourRoute = seg([48.103, 11.103], [48.12, 11.12])
const rows = tourRows([before, tour, after], [incoming, outgoing, automaticTourRoute])
expect(connectors(rows)).toEqual([incoming, outgoing])
expect(rows.map(row => row.kind)).toEqual(['place', 'conn', 'place', 'conn', 'place'])
expect(rows.filter(row => row.kind === 'conn').map(row => row.assignmentId)).toEqual([before.id, tour.id])
})
it('uses the current Place projection when the saved assignment geometry has not refreshed', () => {
const before = assignment(35, 0, place(351, 'Before', 48.1, 11.1))
const tour = tourAssignment(36, 1, [[48.103, 11.103], [48.12, 11.12]], JSON.stringify([[48.105, 11.105], [48.125, 11.125]]))
const after = assignment(37, 2, place(353, 'After', 48.13, 11.13))
const currentIncoming = seg([48.1, 11.1], [48.105, 11.105])
const currentOutgoing = seg([48.125, 11.125], [48.13, 11.13])
const staleIncoming = seg([48.1, 11.1], [48.103, 11.103])
expect(connectors(tourRows([before, tour, after], [currentIncoming, currentOutgoing, staleIncoming])))
.toEqual([currentIncoming, currentOutgoing])
})
it('connects first, last and consecutive Tours only at valid boundaries', () => {
const before = assignment(41, 0, place(401, 'Before', 48.1, 11.1))
const first = tourAssignment(42, 1, [[48.11, 11.11], [48.12, 11.12]])
const second = tourAssignment(43, 2, [[48.13, 11.13], [48.14, 11.14]])
const after = assignment(44, 3, place(404, 'After', 48.15, 11.15))
const segments = [
seg([48.1, 11.1], [48.11, 11.11]),
seg([48.12, 11.12], [48.13, 11.13]),
seg([48.14, 11.14], [48.15, 11.15]),
]
const firstToAfter = seg([48.12, 11.12], [48.15, 11.15])
expect(connectors(tourRows([first, after], [firstToAfter]))).toEqual([firstToAfter])
expect(connectors(tourRows([before, first], [segments[0]]))).toEqual([segments[0]])
const rows = tourRows([before, first, second, after], segments)
expect(connectors(rows)).toEqual(segments)
expect(rows.filter(row => row.kind === 'conn').map(row => row.assignmentId)).toEqual([before.id, first.id, second.id])
})
it('connects a loop Tour at its shared boundary without routing through its geometry', () => {
const before = assignment(51, 0, place(501, 'Before', 48.1, 11.1))
const loop = tourAssignment(52, 1, [[48.11, 11.11], [48.12, 11.12], [48.11, 11.11]])
const after = assignment(53, 2, place(503, 'After', 48.13, 11.13))
const incoming = seg([48.1, 11.1], [48.11, 11.11])
const outgoing = seg([48.11, 11.11], [48.13, 11.13])
const inside = seg([48.11, 11.11], [48.11, 11.11])
expect(connectors(tourRows([before, loop, after], [incoming, outgoing, inside]))).toEqual([incoming, outgoing])
})
it('keeps excluded Tours visible and bridges surrounding Places as the central route builder does', () => {
const before = assignment(61, 0, place(601, 'Before', 48.1, 11.1))
const tour = { ...tourAssignment(62, 1, [[48.11, 11.11], [48.12, 11.12]]), route_excluded: true }
const after = assignment(63, 2, place(603, 'After', 48.13, 11.13))
const bridge = seg([48.1, 11.1], [48.13, 11.13])
const rows = tourRows([before, tour, after], [bridge])
expect(rows.map(row => row.kind)).toEqual(['place', 'conn', 'place', 'place'])
expect(connectors(rows)).toEqual([bridge])
expect(rows.some(row => row.kind === 'place' && row.assignment.id === tour.id)).toBe(true)
})
it('exposes invalid-Tour warning state and never invents an outgoing endpoint', () => {
const before = assignment(71, 0, place(701, 'Before', 48.1, 11.1))
const tour = tourAssignment(72, 1, [[48.11, 11.11], [48.12, 11.12]], 'not valid geometry')
const after = assignment(73, 2, place(703, 'After', 48.13, 11.13))
const incoming = seg([48.1, 11.1], [48.11, 11.11])
const falseOutgoing = seg([48.11, 11.11], [48.13, 11.13])
const rows = tourRows([before, tour, after], [incoming, falseOutgoing])
expect(connectors(rows)).toEqual([incoming])
expect(rows.find(row => row.kind === 'place' && row.assignment.id === tour.id)).toMatchObject({ invalidTour: true })
const noPlaceAnchor = { ...tour, place: buildPlace({ id: tour.place!.id, name: 'Tour without anchor', lat: null, lng: null, route_geometry: 'not valid geometry' }) }
const noAnchorRows = tourRows([before, noPlaceAnchor, after], [seg([48.1, 11.1], [48.13, 11.13])])
expect(connectors(noAnchorRows)).toEqual([])
expect(noAnchorRows.find(row => row.kind === 'place' && row.assignment.id === tour.id)).toMatchObject({ invalidTour: true })
})
it('keeps Tours-off routing on the legacy single Place anchor', () => {
const before = assignment(81, 0, place(801, 'Before', 48.1, 11.1))
const tour = tourAssignment(82, 1, [[48.11, 11.11], [48.12, 11.12]])
const after = assignment(83, 2, place(803, 'After', 48.13, 11.13))
const rows = tourRows([before, tour, after], [
seg([48.1, 11.1], [48.11, 11.11]),
seg([48.11, 11.11], [48.13, 11.13]),
], false)
expect(connectors(rows).map(item => [item.from, item.to])).toEqual([
[[48.1, 11.1], [48.11, 11.11]],
[[48.11, 11.11], [48.13, 11.13]],
])
expect(rows.find(row => row.kind === 'place' && row.assignment.id === tour.id)).toMatchObject({ invalidTour: false })
})
})
describe('planTimelineModel — hotel chips and legs', () => {
it('FE-MOB-PTLM-022: orders check-out before check-in before an ongoing stay, each chip naming its stay (#2210)', () => {
const chips = hotelChipsForDay(DAY2, DAYS, [
accommodation({ id: 1, start_day_id: 1, end_day_id: 3, place_name: 'Long Stay' }),
accommodation({ id: 2, start_day_id: 2, end_day_id: 3, place_name: 'New Hotel', check_in: '15:00', place_id: 102 }),
accommodation({ id: 3, start_day_id: 1, end_day_id: 2, place_name: 'Old Hotel', check_out: '11:00' }),
])
expect(chips).toEqual([
{ key: 'out-3', variant: 'checkout', name: 'Old Hotel', time: '11:00', accId: 3, placeId: null },
{ key: 'in-2', variant: 'checkin', name: 'New Hotel', time: '15:00', accId: 2, placeId: 102 },
{ key: 'stay-1', variant: 'stay', name: 'Long Stay', time: null, accId: 1, placeId: null },
])
})
it('FE-MOB-PTLM-023: falls back to the reservation title and drops unnamed stays', () => {
const chips = hotelChipsForDay(DAY2, DAYS, [
accommodation({ id: 4, place_name: null, reservation_title: 'Airbnb Wieden' }),
accommodation({ id: 5, place_name: null, reservation_title: null }),
])
expect(chips).toEqual([{ key: 'stay-4', variant: 'stay', name: 'Airbnb Wieden', time: null, accId: 4, placeId: null }])
})
it('FE-MOB-PTLM-042: leaves the chip time empty when the stay has no check-in or check-out', () => {
const chips = hotelChipsForDay(DAY2, DAYS, [
accommodation({ id: 9, start_day_id: 1, end_day_id: 2, place_name: 'Old Hotel' }),
accommodation({ id: 10, start_day_id: 2, end_day_id: 3, place_name: 'New Hotel' }),
])
expect(chips.map(c => [c.variant, c.time])).toEqual([['checkout', null], ['checkin', null]])
})
it('FE-MOB-PTLM-024: ignores accommodations outside the day range', () => {
expect(hotelChipsForDay(DAY2, DAYS, [accommodation({ id: 6, start_day_id: 1, end_day_id: 1 })])).toEqual([])
})
it('FE-MOB-PTLM-025: picks the hotel bookend legs out of the calculated segments', () => {
const hotel = accommodation({ id: 7, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 })
const out = seg([48.0, 16.0], [48.1, 16.1], { hotelBookend: 'morning' })
const back = seg([48.2, 16.2], [48.0, 16.0], { hotelBookend: 'evening' })
const legs = hotelLegsForDay(DAY2, DAYS, [hotel], [out, seg([48.1, 16.1], [48.2, 16.2]), back])
expect(legs.top).toEqual({ seg: out, name: 'Hotel Sacher' })
expect(legs.bottom).toEqual({ seg: back, name: 'Hotel Sacher' })
})
it('FE-MOB-PTLM-026: returns no legs when the calculation drew no bookend, whatever touches the hotel (#2501)', () => {
// A stop on the hotel's own spot starts and ends legs at its coordinates too.
const hotel = accommodation({ id: 8, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 })
const legs = hotelLegsForDay(DAY2, DAYS, [hotel], [seg([48.1, 16.1], [48.0, 16.0]), seg([48.0, 16.0], [48.2, 16.2])])
expect(legs).toEqual({ top: null, bottom: null })
})
it('FE-MOB-PTLM-027: returns no legs without an accommodation on the day', () => {
const tagged = [seg([48.0, 16.0], [48.1, 16.1], { hotelBookend: 'morning' }), seg([48.1, 16.1], [48.0, 16.0], { hotelBookend: 'evening' })]
expect(hotelLegsForDay(DAY2, DAYS, [], tagged)).toEqual({ top: null, bottom: null })
})
it('FE-MOB-PTLM-050: another stay\'s bookends are not this day\'s, as right after a day switch (#2501)', () => {
// The calc still holds the day before's legs, out of and back to Munich, while
// the new day, a night in Hamburg, is being routed.
const munich = { lat: 48.137, lng: 11.575 }
const hamburg = accommodation({ id: 9, start_day_id: 2, end_day_id: 3, place_name: 'Hamburg Inn', place_lat: 53.551, place_lng: 9.993 })
const stale = [
seg([munich.lat, munich.lng], [48.14, 11.58], { hotelBookend: 'morning' }),
seg([48.14, 11.58], [48.15, 11.59]),
seg([48.15, 11.59], [munich.lat, munich.lng], { hotelBookend: 'evening' }),
]
expect(hotelLegsForDay(DAY2, DAYS, [hamburg], stale)).toEqual({ top: null, bottom: null })
})
it('FE-MOB-PTLM-045: the evening leg is the drive that closes the day, not an earlier one onto the hotel spot (#2476)', () => {
// A stop planned on the hotel's own spot early in the day: the drive there reaches
// the hotel's coordinates first, but the day ends with the drive back from the park.
const hotel = accommodation({ id: 7, start_day_id: 1, end_day_id: 3, place_lat: 48.0, place_lng: 16.0 })
const out = seg([48.0, 16.0], [48.1, 16.1], { hotelBookend: 'morning' })
const toHotelSpot = seg([48.1, 16.1], [48.0, 16.0])
const onward = seg([48.0, 16.0], [48.2, 16.2])
const back = seg([48.2, 16.2], [48.0, 16.0], { hotelBookend: 'evening' })
const legs = hotelLegsForDay(DAY2, DAYS, [hotel], [out, toHotelSpot, onward, back])
expect(legs.top?.seg).toBe(out)
expect(legs.bottom?.seg).toBe(back)
})
describe('a moving day with a flight between the two stays (#2476)', () => {
// Day 2 checks out of Munich and into Hamburg; the flight sits between them.
const HOTEL_A = { lat: 48.137, lng: 11.575 }
const HOTEL_B = { lat: 53.551, lng: 9.993 }
const MUC = { lat: 48.353, lng: 11.786 }
const HAM = { lat: 53.63, lng: 9.988 }
const stays = [
accommodation({ id: 1, start_day_id: 1, end_day_id: 2, place_name: 'Hotel A', place_lat: HOTEL_A.lat, place_lng: HOTEL_A.lng }),
accommodation({ id: 2, start_day_id: 2, end_day_id: 3, place_name: 'Hotel B', place_lat: HOTEL_B.lat, place_lng: HOTEL_B.lng }),
]
const flight = (located: boolean) => buildReservation({
id: 7, type: 'flight', title: 'LH 2078', day_id: 2, end_day_id: 2,
reservation_time: '2026-05-02T15:15', reservation_end_time: '2026-05-02T17:20',
endpoints: located
? [
{ role: 'from', sequence: 0, name: 'MUC', code: null, ...MUC, timezone: null, local_date: null, local_time: null },
{ role: 'to', sequence: 1, name: 'HAM', code: null, ...HAM, timezone: null, local_date: null, local_time: null },
]
: [],
})
const legsOf = (reservations: Reservation[]) => {
const runs = buildDayRouteRuns(2, {
days: DAYS, assignments: {}, reservations, accommodations: stays, optimizeFromAccommodation: true,
})
return hotelLegsForDay(DAY2, DAYS, stays, segmentsOf(runs))
}
it('FE-MOB-PTLM-046: shows no drive from one hotel to the other, above or below the flight', () => {
// Saved without its airports, the flight leaves nothing to connect: the plan
// used to show the whole Munich to Hamburg drive twice, around the flight.
expect(legsOf([flight(false)])).toEqual({ top: null, bottom: null })
// With them, the morning drive goes to the departure airport and the evening
// one comes from the arrival airport.
const located = legsOf([flight(true)])
expect(located.top).toMatchObject({ name: 'Hotel A', seg: { from: [HOTEL_A.lat, HOTEL_A.lng], to: [MUC.lat, MUC.lng] } })
expect(located.bottom).toMatchObject({ name: 'Hotel B', seg: { from: [HAM.lat, HAM.lng], to: [HOTEL_B.lat, HOTEL_B.lng] } })
})
it('FE-MOB-PTLM-047: without a flight the one drive between the two stays shows once, not above and below', () => {
// No stop and no booking: the day is the drive from Munich to Hamburg (#1297).
// That one segment leaves the morning hotel and reaches the evening one.
const legs = legsOf([])
expect(legs.top).toMatchObject({ name: 'Hotel A', seg: { from: [HOTEL_A.lat, HOTEL_A.lng], to: [HOTEL_B.lat, HOTEL_B.lng] } })
expect(legs.bottom).toBeNull()
})
})
})
describe('planTimelineModel: a day that lands and then drives to the hotel (#2501, #2502)', () => {
// Day 1 checks into a Hamburg hotel at three. The flight lands at one, and the
// traveller put the hotel on the day as a stop of their own, then the town hall.
const HOTEL = { lat: 53.5465, lng: 9.9727 }
const AMS = { lat: 52.3105, lng: 4.7683 }
const HAM = { lat: 53.6304, lng: 9.9882 }
const TOWN_HALL = { lat: 53.5503, lng: 9.9937 }
const stay = accommodation({
id: 30, place_id: 900, place_name: 'Hotel Hafen', place_lat: HOTEL.lat, place_lng: HOTEL.lng,
start_day_id: 1, end_day_id: 3, check_in: '15:00', check_out: '11:00',
})
const flight = buildReservation({
id: 7, type: 'flight', title: 'KL 1783', day_id: 1, end_day_id: 1, day_plan_position: -0.5,
reservation_time: '2026-05-01T10:00', reservation_end_time: '2026-05-01T13:00',
endpoints: [
{ role: 'from', sequence: 0, name: 'AMS', code: null, ...AMS, timezone: null, local_date: null, local_time: null },
{ role: 'to', sequence: 1, name: 'HAM', code: null, ...HAM, timezone: null, local_date: null, local_time: null },
],
})
const hotelStop = buildAssignment({ id: 21, day_id: 1, order_index: 0, place: place(900, 'Hotel Hafen', HOTEL.lat, HOTEL.lng) })
const townHall = buildAssignment({ id: 22, day_id: 1, order_index: 1, place: place(901, 'Town Hall', TOWN_HALL.lat, TOWN_HALL.lng) })
// A day as the phone timeline builds it: merged the way useMPlanTimeline merges,
// segments the way useRouteCalculation routes the same day.
const planOf = (dayId: number, dayAssignments: Assignment[], reservations: Reservation[], stays: Accommodation[]) => {
const merged = getMergedItems({
dayAssignments,
dayNotes: [],
dayTransports: getTransportForDay({ reservations, dayId, dayAssignmentIds: dayAssignments.map(a => a.id), days: DAYS }),
dayId,
})
const segments = segmentsOf(buildDayRouteRuns(dayId, {
days: DAYS, assignments: { [String(dayId)]: dayAssignments }, reservations, accommodations: stays, optimizeFromAccommodation: true,
}))
return {
rows: buildPlanRows({ merged, reservations, routeSegments: segments, dayId }),
legs: hotelLegsForDay(DAYS.find(d => d.id === dayId)!, DAYS, stays, segments),
}
}
const dayOne = () => planOf(1, [hotelStop, townHall], [flight], [stay])
const ends = (s: RouteSegment) => [s.from, s.to]
it('FE-MOB-PTLM-048: opens with the flight, shows the hop from the hotel stop once and ends at the hotel', () => {
const { rows, legs } = dayOne()
// No drive out of a hotel the traveller has not reached yet.
expect(legs.top).toBeNull()
const hops = rows.filter(r => r.kind === 'conn').map(r => r.kind === 'conn' && ends(r.seg))
expect(hops.filter(h => JSON.stringify(h) === JSON.stringify([[HOTEL.lat, HOTEL.lng], [TOWN_HALL.lat, TOWN_HALL.lng]]))).toHaveLength(1)
expect(rows[0]).toMatchObject({ kind: 'transport', key: 'tr-7' })
// The day closes with the drive back to the hotel.
expect(legs.bottom).toMatchObject({ name: 'Hotel Hafen', seg: { from: [TOWN_HALL.lat, TOWN_HALL.lng], to: [HOTEL.lat, HOTEL.lng] } })
})
it('FE-MOB-PTLM-049: a bookend is never read as the hop between two stops that share its ends', () => {
// The tag decides, not the coordinates: two stops on the very spots a bookend
// joins get no connector out of it.
const bookend = seg([HOTEL.lat, HOTEL.lng], [TOWN_HALL.lat, TOWN_HALL.lng], { hotelBookend: 'morning' })
const rows = buildPlanRows({
merged: [placeItem(hotelStop), placeItem(townHall)], reservations: [], routeSegments: [bookend], dayId: 1,
})
expect(rows.map(r => r.kind)).toEqual(['place', 'place'])
})
it('FE-MOB-PTLM-051: the drive from the arrival airport to the first stop sits under the flight (#2502)', () => {
const { rows } = dayOne()
expect(rows.map(r => r.kind)).toEqual(['transport', 'conn', 'place', 'conn', 'place'])
const landed = rows[1]
expect(landed).toMatchObject({ key: 'conn-tr-7', seg: { from: [HAM.lat, HAM.lng], to: [HOTEL.lat, HOTEL.lng] } })
// It leaves no stop, so there is no stop whose outgoing mode it would change.
expect(landed.kind === 'conn' && landed.assignmentId).toBeUndefined()
})
it('FE-MOB-PTLM-052: the drive to the departure airport sits under the stop it leaves from', () => {
const HARBOUR = { lat: 53.5436, lng: 9.9661 }
const homeFlight = buildReservation({
id: 8, type: 'flight', title: 'KL 1790', day_id: 3, end_day_id: 3, day_plan_position: 5,
reservation_time: '2026-05-03T18:00', reservation_end_time: '2026-05-03T19:10',
endpoints: [
{ role: 'from', sequence: 0, name: 'HAM', code: null, ...HAM, timezone: null, local_date: null, local_time: null },
{ role: 'to', sequence: 1, name: 'AMS', code: null, ...AMS, timezone: null, local_date: null, local_time: null },
],
})
const harbour = buildAssignment({ id: 23, day_id: 3, order_index: 0, place: place(902, 'Harbour', HARBOUR.lat, HARBOUR.lng) })
const { rows } = planOf(3, [harbour], [homeFlight], [])
expect(rows.map(r => r.kind)).toEqual(['place', 'conn', 'transport'])
// Its mode is the stop's own, so the row keeps the stop's menu.
expect(rows[1]).toMatchObject({ key: 'conn-pl-23', assignmentId: 23, seg: { from: [HARBOUR.lat, HARBOUR.lng], to: [HAM.lat, HAM.lng] } })
})
it('FE-MOB-PTLM-053: two flights back to back get no drive between the airports (#1394)', () => {
const endpoint = (role: 'from' | 'to', at: { lat: number; lng: number }) =>
({ role, sequence: role === 'from' ? 0 : 1, name: role, code: null, ...at, timezone: null, local_date: null, local_time: null })
const IST = { lat: 41.2753, lng: 28.7519 }
const SAW = { lat: 40.8986, lng: 29.3092 }
const inbound = buildReservation({ id: 9, type: 'flight', day_id: 2, endpoints: [endpoint('from', AMS), endpoint('to', IST)] })
const onward = buildReservation({ id: 10, type: 'flight', day_id: 2, endpoints: [endpoint('from', SAW), endpoint('to', HAM)] })
const rows = buildPlanRows({
merged: [transportItem(inbound), transportItem(onward)],
reservations: [],
routeSegments: [seg([IST.lat, IST.lng], [SAW.lat, SAW.lng])],
dayId: 2,
})
expect(rows.map(r => r.kind)).toEqual(['transport', 'transport'])
})
it('FE-MOB-PTLM-054: a located transit ride gets the walk to its first stop and from its last one', () => {
const S1 = { lat: 48.11, lng: 16.11 }
const S2 = { lat: 48.19, lng: 16.19 }
const ride = buildReservation({
id: 64, type: 'transit', day_id: 2,
metadata: JSON.stringify({ transit: { legs: [{ mode: 'subway', line: 'U2' }], transfers: 0 } }),
endpoints: [
{ role: 'from', sequence: 0, name: 'S1', code: null, ...S1, timezone: null, local_date: null, local_time: null },
{ role: 'to', sequence: 1, name: 'S2', code: null, ...S2, timezone: null, local_date: null, local_time: null },
],
})
const museum = assignment(11, 0, place(101, 'Museum', 48.1, 16.1))
const park = assignment(12, 1, place(102, 'Park', 48.2, 16.2))
const rows = buildPlanRows({
merged: [placeItem(museum), transportItem(ride), placeItem(park)],
reservations: [],
routeSegments: [seg([48.1, 16.1], [S1.lat, S1.lng]), seg([S2.lat, S2.lng], [48.2, 16.2])],
dayId: 2,
})
expect(rows.map(r => r.key)).toEqual(['pl-11', 'conn-pl-11', 'tr-64', 'conn-tr-64', 'pl-12'])
})
it('FE-MOB-PTLM-055: a transit ride saved on the stops themselves gets no connector that goes nowhere', () => {
// The transit planner saves a journey between two of the day's places at their
// own coordinates, so the route has a leg of no length on either side of it.
const LANDING = { lat: 53.5457, lng: 9.9666 }
const ride = buildReservation({
id: 65, type: 'transit', title: 'Town Hall → Landungsbruecken', day_id: 2, end_day_id: 2, day_plan_position: 0.5,
reservation_time: '2026-05-02T10:00', reservation_end_time: '2026-05-02T10:12',
metadata: JSON.stringify({ transit: { legs: [{ mode: 'SUBWAY', line: 'U3' }], transfers: 0 } }),
endpoints: [
{ role: 'from', sequence: 0, name: 'Town Hall', code: null, ...TOWN_HALL, timezone: null, local_date: null, local_time: null },
{ role: 'to', sequence: 1, name: 'Landungsbruecken', code: null, ...LANDING, timezone: null, local_date: null, local_time: null },
],
})
const hall = buildAssignment({ id: 24, day_id: 2, order_index: 0, place: place(901, 'Town Hall', TOWN_HALL.lat, TOWN_HALL.lng) })
const landing = buildAssignment({ id: 25, day_id: 2, order_index: 1, place: place(903, 'Landungsbruecken', LANDING.lat, LANDING.lng) })
const drawn = segmentsOf(buildDayRouteRuns(2, {
days: DAYS, assignments: { '2': [hall, landing] }, reservations: [ride], accommodations: [stay], optimizeFromAccommodation: true,
}))
expect(drawn.filter(s => JSON.stringify(s.from) === JSON.stringify(s.to))).toHaveLength(2)
const { rows, legs } = planOf(2, [hall, landing], [ride], [stay])
expect(rows.map(r => r.key)).toEqual(['pl-24', 'tr-65', 'pl-25'])
// The drives out of and back to the hotel are real and stay.
expect(legs.top).toMatchObject({ seg: { from: [HOTEL.lat, HOTEL.lng], to: [TOWN_HALL.lat, TOWN_HALL.lng] } })
expect(legs.bottom).toMatchObject({ seg: { from: [LANDING.lat, LANDING.lng], to: [HOTEL.lat, HOTEL.lng] } })
})
it('FE-MOB-PTLM-056: two stops on the very same spot keep their connector', () => {
// Only a booking's station on the stop is no leg; the stop's own menu stays.
const inn = assignment(13, 0, place(103, 'Inn', 48.1, 16.1))
const bar = assignment(14, 1, place(104, 'Bar in the inn', 48.1, 16.1))
const rows = buildPlanRows({
merged: [placeItem(inn), placeItem(bar)], reservations: [], routeSegments: [seg([48.1, 16.1], [48.1, 16.1])], dayId: 2,
})
expect(rows.map(r => r.key)).toEqual(['pl-13', 'conn-pl-13', 'pl-14'])
})
describe('a booking the route has no location for', () => {
// The route rides straight past it, so the drive through it is drawn on the map,
// and the desktop day plan shows that leg under the booking.
const station = (role: 'from' | 'to', p: { lat: number; lng: number }, name: string) =>
({ role, sequence: role === 'from' ? 0 : 1, name, code: null, ...p, timezone: null, local_date: null, local_time: null })
const LANDING = { lat: 53.5457, lng: 9.9666 }
it('FE-MOB-PTLM-057: the drive to the departure airport past a taxi without its stops goes under the taxi (#2502)', () => {
const hall = buildAssignment({ id: 26, day_id: 3, order_index: 0, place: place(901, 'Town Hall', TOWN_HALL.lat, TOWN_HALL.lng) })
const taxi = buildReservation({ id: 70, type: 'taxi', title: 'Taxi to the airport', day_id: 3, end_day_id: 3, day_plan_position: 0.5 })
const homeFlight = buildReservation({
id: 71, type: 'flight', title: 'KL 1790', day_id: 3, end_day_id: 3, day_plan_position: 0.8,
reservation_time: '2026-05-03T18:00', reservation_end_time: '2026-05-03T19:10',
endpoints: [station('from', HAM, 'HAM'), station('to', AMS, 'AMS')],
})
const { rows } = planOf(3, [hall], [taxi, homeFlight], [])
expect(rows.map(r => r.key)).toEqual(['pl-26', 'tr-70', 'conn-tr-70', 'tr-71'])
// Routed in the mode of the stop it left, so the menu edits that stop.
expect(rows[2]).toMatchObject({ assignmentId: 26, seg: { from: [TOWN_HALL.lat, TOWN_HALL.lng], to: [HAM.lat, HAM.lng] } })
})
it('FE-MOB-PTLM-058: an event between two stops carries the drive between them, stations or not (#2502)', () => {
const hall = buildAssignment({ id: 27, day_id: 2, order_index: 0, place: place(901, 'Town Hall', TOWN_HALL.lat, TOWN_HALL.lng) })
const landing = buildAssignment({ id: 28, day_id: 2, order_index: 1, place: place(903, 'Landungsbruecken', LANDING.lat, LANDING.lng) })
const concert = buildReservation({ id: 72, type: 'event', title: 'Concert', day_id: 2, end_day_id: 2, day_plan_position: 0.5 })
// A transit booking turned into an event in the booking form keeps its stations,
// and the route still only rides transport bookings.
const retyped = { ...concert, endpoints: [station('from', TOWN_HALL, 'Town Hall'), station('to', HAM, 'HAM')] }
for (const booking of [concert, retyped]) {
const { rows } = planOf(2, [hall, landing], [booking], [])
expect(rows.map(r => r.key)).toEqual(['pl-27', 'tr-72', 'conn-tr-72', 'pl-28'])
expect(rows[2]).toMatchObject({ assignmentId: 27, seg: { from: [TOWN_HALL.lat, TOWN_HALL.lng], to: [LANDING.lat, LANDING.lng] } })
}
})
it('FE-MOB-PTLM-059: an arrival carries past it into the next drive, and two flights around it stay apart (#1394)', () => {
const taxi = buildReservation({ id: 73, type: 'taxi', title: 'Taxi', day_id: 1, end_day_id: 1, day_plan_position: -0.2 })
const { rows } = planOf(1, [hotelStop, townHall], [flight, taxi], [stay])
expect(rows.map(r => r.key)).toEqual(['tr-7', 'tr-73', 'conn-tr-73', 'pl-21', 'conn-pl-21', 'pl-22'])
expect(rows[2]).toMatchObject({ seg: { from: [HAM.lat, HAM.lng], to: [HOTEL.lat, HOTEL.lng] } })
// It leaves the airport, not a stop, so there is no stop's mode to change.
expect(rows[2].kind === 'conn' && rows[2].assignmentId).toBeUndefined()
const IST = { lat: 41.2753, lng: 28.7519 }
const SAW = { lat: 40.8986, lng: 29.3092 }
const inbound = buildReservation({ id: 9, type: 'flight', day_id: 2, endpoints: [station('from', AMS, 'AMS'), station('to', IST, 'IST')] })
const shuttle = buildReservation({ id: 74, type: 'bus', day_id: 2 })
const onward = buildReservation({ id: 10, type: 'flight', day_id: 2, endpoints: [station('from', SAW, 'SAW'), station('to', HAM, 'HAM')] })
const transfer = buildPlanRows({
merged: [transportItem(inbound), transportItem(shuttle), transportItem(onward)],
reservations: [],
routeSegments: [seg([IST.lat, IST.lng], [SAW.lat, SAW.lng])],
dayId: 2,
})
expect(transfer.map(r => r.kind)).toEqual(['transport', 'transport', 'transport'])
})
it('FE-MOB-PTLM-060: the drive goes under the last of several such bookings, and none of them opens the day', () => {
const museum = assignment(11, 0, place(101, 'Museum', 48.1, 16.1))
const park = assignment(12, 1, place(102, 'Park', 48.2, 16.2))
const early = buildReservation({ id: 75, type: 'taxi', day_id: 2 })
const taxi = buildReservation({ id: 76, type: 'taxi', day_id: 2 })
const note = buildDayNote({ id: 42, day_id: 2 })
const concert = buildReservation({ id: 77, type: 'event', day_id: 2 })
const rows = buildPlanRows({
merged: [transportItem(early), placeItem(museum), transportItem(taxi), noteItem(note), transportItem(concert), placeItem(park)],
reservations: [],
routeSegments: [seg([48.1, 16.1], [48.2, 16.2])],
dayId: 2,
})
expect(rows.map(r => r.key)).toEqual(['tr-75', 'pl-11', 'tr-76', 'note-42', 'tr-77', 'conn-tr-77', 'pl-12'])
})
})
})
describe('planTimelineModel — cityPillsForDay', () => {
const t = ((key: string, params?: Record<string, string | number>) =>
params ? `${key}:${Object.values(params).join(',')}` : key) as unknown as TranslationFn
it('FE-MOB-PTLM-028: splits a transfer title into one pill per city', () => {
expect(cityPillsForDay({ ...DAY2, title: 'Tokyo → Kyoto ' } as Day, t)).toEqual(['Tokyo', 'Kyoto'])
})
it('FE-MOB-PTLM-029: keeps a plain title as a single pill', () => {
expect(cityPillsForDay(DAY2, t)).toEqual(['Old Town'])
})
it('FE-MOB-PTLM-030: falls back to the day number for blank or arrow-only titles', () => {
expect(cityPillsForDay({ ...DAY2, title: ' ' } as Day, t)).toEqual(['planner.dayN:2'])
expect(cityPillsForDay({ ...DAY2, title: ' → ' } as Day, t)).toEqual(['planner.dayN:2'])
expect(cityPillsForDay(undefined, t)).toEqual(['planner.dayN:0'])
})
})
describe('planTimelineModel — findUpNext', () => {
const early = assignment(11, 0, place(101, 'Breakfast', 48.1, 16.1, '09:00'))
const noon = assignment(12, 1, place(102, 'Museum', 48.2, 16.2, '11:00'))
const late = assignment(13, 2, place(103, 'Dinner', 48.3, 16.3, '19:00'))
it('FE-MOB-PTLM-031: returns null for a day without stops', () => {
expect(findUpNext(DAY2, [], new Date(2026, 4, 2, 10, 0))).toBeNull()
})
it('FE-MOB-PTLM-032: counts down to the next stop still ahead today', () => {
const up = findUpNext(DAY2, [late, early, noon], new Date(2026, 4, 2, 10, 15))
expect(up?.assignment.id).toBe(12)
expect(up?.minutesUntil).toBe(45)
})
it('FE-MOB-PTLM-033: shows nothing once the timed plan for today has run out', () => {
expect(findUpNext(DAY2, [early, noon, late], new Date(2026, 4, 2, 23, 30))).toBeNull()
})
it('FE-MOB-PTLM-044: shows nothing for a day that is already behind us', () => {
expect(findUpNext(DAY2, [late, noon, early], new Date(2026, 4, 3, 8, 0))).toBeNull()
})
it('FE-MOB-PTLM-034: never counts down on a day that is not today', () => {
const up = findUpNext(DAY2, [late, noon, early], new Date(2026, 4, 1, 8, 0))
expect(up?.assignment.id).toBe(11)
expect(up?.minutesUntil).toBeNull()
})
it('FE-MOB-PTLM-035: uses the manual order when no stop carries a time', () => {
const a = assignment(21, 1, place(201, 'Park', 48.1, 16.1))
const b = assignment(22, 0, place(202, 'Cafe', 48.2, 16.2))
const up = findUpNext(DAY2, [a, b], new Date(2026, 4, 2, 10, 0))
expect(up?.assignment.id).toBe(22)
expect(up?.minutesUntil).toBeNull()
})
it('FE-MOB-PTLM-036: treats a day without a date as not today', () => {
const up = findUpNext({ ...DAY2, date: null } as unknown as Day, [noon, early], new Date(2026, 4, 2, 10, 0))
expect(up?.assignment.id).toBe(11)
expect(findUpNext(undefined, [noon, early], new Date(2026, 4, 2, 10, 0))?.assignment.id).toBe(11)
})
})
describe('planTimelineModel — chronology helpers', () => {
it('FE-MOB-PTLM-037: detects which merged items carry a time', () => {
expect(itemHasTime(placeItem(assignment(11, 0, place(101, 'Museum', 48.1, 16.1, '09:00'))), 2)).toBe(true)
expect(itemHasTime(placeItem(assignment(12, 1, place(102, 'Park', 48.2, 16.2))), 2)).toBe(false)
expect(itemHasTime(noteItem(buildDayNote({ id: 41, time: '10:30' })), 2)).toBe(true)
expect(itemHasTime(noteItem(buildDayNote({ id: 42, time: null })), 2)).toBe(false)
})
it('FE-MOB-PTLM-038: reads a transport time through the per-day display time', () => {
const bus = buildReservation({ id: 51, type: 'bus', day_id: 2, reservation_time: '2026-05-02T08:00' })
expect(itemHasTime(transportItem(bus), 2)).toBe(true)
const car = buildReservation({ id: 52, type: 'car', day_id: 1, end_day_id: 3, reservation_time: '2026-05-01T08:00' })
expect(itemHasTime(transportItem(car), 2)).toBe(false)
})
it('FE-MOB-PTLM-039: reports an order that puts a later time before an earlier one', () => {
const nine = placeItem(assignment(11, 0, place(101, 'Breakfast', 48.1, 16.1, '09:00')))
const eleven = placeItem(assignment(12, 1, place(102, 'Museum', 48.2, 16.2, '11:00')))
expect(breaksChronology([nine, eleven], 2, getDisplayTimeForDay)).toBe(false)
expect(breaksChronology([eleven, nine], 2, getDisplayTimeForDay)).toBe(true)
})
it('FE-MOB-PTLM-040: lets untimed items sit anywhere between timed ones', () => {
const nine = placeItem(assignment(11, 0, place(101, 'Breakfast', 48.1, 16.1, '09:00')))
const free = placeItem(assignment(12, 1, place(102, 'Park', 48.2, 16.2)))
const eleven = noteItem(buildDayNote({ id: 41, time: '11:00' }))
expect(breaksChronology([nine, free, eleven], 2, getDisplayTimeForDay)).toBe(false)
const bus = transportItem(buildReservation({ id: 51, type: 'bus', day_id: 2, reservation_time: '2026-05-02T07:00' }))
const getDisplayTime = vi.fn(getDisplayTimeForDay)
expect(breaksChronology([nine, bus], 2, getDisplayTime)).toBe(true)
expect(getDisplayTime).toHaveBeenCalledWith(bus.data, 2)
})
})
describe('planTimelineModel — weatherIconFor', () => {
it('FE-MOB-PTLM-041: maps the known conditions and defaults to a cloud', () => {
expect(weatherIconFor('Clear')).toBe(Sun)
expect(weatherIconFor('Rain')).toBe(CloudRain)
expect(weatherIconFor('Thunderstorm')).toBe(CloudLightning)
expect(weatherIconFor('Snow')).toBe(CloudSnow)
expect(weatherIconFor('Haze')).toBe(Wind)
expect(weatherIconFor('Tornado')).toBe(Cloud)
expect(weatherIconFor(undefined)).toBe(Cloud)
})
})