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.
This commit is contained in:
Maurice
2026-11-10 09:29:40 +01:00
parent 6ebb92af6c
commit 2ab4459bc3
2 changed files with 91 additions and 4 deletions
+54
View File
@@ -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,
+37 -4
View File
@@ -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 {