From 2ab4459bc38d874b9dc321d9724326f6280ae9d7 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 10 Nov 2026 09:29:40 +0100 Subject: [PATCH] fix(native): the Live Activity keeps a day with flights ahead open instead of calling it done A booking with stopovers was one stop at its first departure, and a stop counted as done the moment it began. With a car at one and a connecting flight at two, the Live Activity said all done for today mid-afternoon. Each leg is now a stop of its own, as the planner shows it, and a flight or anything else with an end time counts as under way until it lands or ends. --- client/src/native/widgets.test.ts | 54 +++++++++++++++++++++++++++++++ client/src/native/widgets.ts | 41 ++++++++++++++++++++--- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/client/src/native/widgets.test.ts b/client/src/native/widgets.test.ts index d2b29d1dc..9bc3bed05 100644 --- a/client/src/native/widgets.test.ts +++ b/client/src/native/widgets.test.ts @@ -211,6 +211,60 @@ describe('the richer snapshot', () => { expect(withBookings(plain, [], '2026-11-06', (t) => t)).toBe(plain); }); + it('makes a stop of every leg of a flight with a stopover, and keeps a flight in the air ahead until it lands', () => { + const plain = todayOf(trip(1, '2026-11-09', '2026-11-10'), [day(5, 1, '2026-11-09', []), day(6, 2, '2026-11-10', [])], '2026-11-09', () => 'D', (t) => t); + if (!plain) throw new Error('the trip runs today'); + const legs = [ + { from: 'SNA', to: 'ATL', dep_day_id: 5, dep_time: '14:00', arr_day_id: 5, arr_time: '21:11' }, + { from: 'ATL', to: 'ORF', dep_day_id: 5, dep_time: '22:40', arr_day_id: 6, arr_time: '00:31' }, + ]; + const today = withBookings( + plain, + [ + { id: 1, trip_id: 1, title: 'Car', type: 'car', status: 'confirmed', reservation_time: '2026-11-09T13:00' }, + { + id: 2, + trip_id: 1, + title: 'SNA → ATL → ORF', + type: 'flight', + status: 'confirmed', + reservation_time: '2026-11-09T14:00', + metadata: JSON.stringify({ legs }), + }, + ] as never, + '2026-11-09', + (t) => t + ); + expect(today.stops.map((s) => s.name)).toEqual(['Car', 'SNA → ATL', 'ATL → ORF']); + // In the air on the first leg, between the legs, and on the second leg past midnight. + expect(tripDayState(today, 16 * 60 + 22, 'done')).toMatchObject({ nextName: 'SNA → ATL', done: 1, total: 3 }); + expect(tripDayState(today, 21 * 60 + 30, 'done')).toMatchObject({ nextName: 'ATL → ORF', done: 2 }); + expect(tripDayState(today, 23 * 60 + 30, 'done')).toMatchObject({ nextName: 'ATL → ORF', done: 2 }); + }); + + it('counts a booking with an end time as under way until it ends', () => { + const plain = todayOf(trip(1, '2026-11-09', '2026-11-09'), [day(5, 1, '2026-11-09', [])], '2026-11-09', () => 'D', (t) => t); + if (!plain) throw new Error('the trip runs today'); + const today = withBookings( + plain, + [ + { + id: 1, + trip_id: 1, + title: 'Tour', + type: 'tour', + status: 'confirmed', + reservation_time: '2026-11-09T10:00', + reservation_end_time: '2026-11-09T13:00', + }, + ] as never, + '2026-11-09', + (t) => t + ); + expect(tripDayState(today, 11 * 60, 'done')).toMatchObject({ nextName: 'Tour', done: 0 }); + expect(tripDayState(today, 13 * 60 + 1, 'done')).toMatchObject({ nextName: 'done', done: 1 }); + }); + it('carries the extras and leaves out what is empty', () => { const snapshot = buildSnapshot( { ...trip(1, '2026-11-06', '2026-11-07'), day_count: 2 } as Trip, diff --git a/client/src/native/widgets.ts b/client/src/native/widgets.ts index e08878b7b..93318b598 100644 --- a/client/src/native/widgets.ts +++ b/client/src/native/widgets.ts @@ -1,4 +1,5 @@ import type { Day, Reservation, Trip } from '../types'; +import { getFlightLegs, getTrainLegs, usesStationRoute } from '../utils/flightLegs'; import { splitReservationDateTime } from '../utils/formatters'; import { heroTrip, sortTripsByRelevance } from '../utils/heroTrip'; import { hasNativeFeature } from './capabilities'; @@ -56,6 +57,8 @@ interface WidgetStop { minutes: number | null; color?: string | null; symbol?: string | null; + /** Minutes after midnight it ends, past midnight as 1440: a flight in the air is not done yet. */ + endMinutes?: number | null; } interface SnapshotStop { @@ -120,6 +123,8 @@ export interface TripDay { path: string; dayLabel: string; stops: WidgetStop[]; + /** The day's id, which the legs of a multi-leg booking name. */ + dayId?: number; /** 1-based position of the day in the trip, and the trip's day count. */ index?: number; total?: number; @@ -211,6 +216,7 @@ export function todayOf( path: `/trips/${trip.id}`, dayLabel: dayLabel(index + 1, ordered.length), stops, + dayId: day.id, index: index + 1, total: ordered.length, }; @@ -224,15 +230,37 @@ export function withBookings( formatTime: (time: string) => string ): TripDay { const booked = reservations.flatMap((r): WidgetStop[] => { + if (r.status === 'cancelled') return []; + const symbol = RESERVATION_SYMBOLS[r.type] ?? 'bookmark.fill'; + // A booking with stopovers is a stop per leg, as the planner shows it: one + // stop at the first departure counted the day done while legs were ahead. + const legs = r.type === 'flight' ? getFlightLegs(r) : usesStationRoute(r.type) ? getTrainLegs(r) : []; + if (legs.length > 1 && day.dayId != null && legs.every((l) => l.dep_day_id != null)) { + return legs.flatMap((leg): WidgetStop[] => { + if (leg.dep_day_id !== day.dayId || !leg.dep_time) return []; + return [ + { + name: leg.from && leg.to ? `${leg.from} → ${leg.to}` : r.title, + time: formatTime(leg.dep_time), + minutes: minutesOf(leg.dep_time), + endMinutes: leg.arr_day_id === day.dayId ? minutesOf(leg.arr_time) : 24 * 60, + color: null, + symbol, + }, + ]; + }); + } const { date, time } = splitReservationDateTime(r.reservation_time); - if (r.status === 'cancelled' || !time || date !== today) return []; + if (!time || date !== today) return []; + const end = splitReservationDateTime(r.reservation_end_time); return [ { name: r.title, time: formatTime(time), minutes: minutesOf(time), + endMinutes: !end.time ? null : end.date && end.date !== today ? 24 * 60 : minutesOf(end.time), color: null, - symbol: RESERVATION_SYMBOLS[r.type] ?? 'bookmark.fill', + symbol, }, ]; }); @@ -303,10 +331,15 @@ interface TripDayState { total: number; } -/** The Live Activity's state: the first stop whose time is still ahead, else the last. */ +/** + * The Live Activity's state: the first stop still ahead or still under way (a + * flight in the air until it lands), else the last. + */ export function tripDayState(day: TripDay, nowMinutes: number, allDone: string): TripDayState { const timed = day.stops.filter((s) => s.minutes != null); - const nextIndex = day.stops.findIndex((s) => s.minutes != null && s.minutes >= nowMinutes); + const nextIndex = day.stops.findIndex( + (s) => s.minutes != null && (s.minutes >= nowMinutes || (s.endMinutes != null && s.endMinutes > nowMinutes)) + ); const done = nextIndex < 0 ? timed.length : day.stops.slice(0, nextIndex).filter((s) => s.minutes != null).length; const next = nextIndex >= 0 ? day.stops[nextIndex] : null; return {