Files
TREK/client/src/native/widgets.test.ts
T
Maurice b8ec428ad1 fix(native): show the travel day's Live Activity from three hours before its first stop
The Live Activity started with the first sync of a travel day and stayed on
the lock screen all day, so a day whose first stop is an evening flight held
it from the early morning on. It now appears three hours before the day's
first timed stop and stays until the day is done; liveDayDue() decides.
2026-11-12 14:00:47 +01:00

408 lines
14 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import type { Day, Trip } from '../types';
vi.mock('./platform', () => ({ isNativeIos: vi.fn(() => true), isNativeApp: vi.fn(() => true) }));
vi.mock('./capabilities', () => ({ hasNativeFeature: vi.fn(() => true) }));
vi.mock('./reportFailure', () => ({ reportNativeFailure: vi.fn() }));
vi.mock('./trekShell', () => ({
TrekShell: {
setWidgetSnapshot: vi.fn(async () => {}),
syncTripDay: vi.fn(async () => ({ running: true })),
endTripDay: vi.fn(async () => {}),
},
}));
import { TrekShell } from './trekShell';
import {
buildSnapshot,
liveDayDue,
pickTrip,
publishWidgets,
todayOf,
tripDayState,
widgetTripChoices,
withBookings,
} from './widgets';
const trip = (id: number, start: string | null, end: string | null, archived = 0) =>
({ id, title: `Trip ${id}`, start_date: start, end_date: end, is_archived: archived }) as unknown as Trip;
const day = (id: number, n: number, date: string, places: { name: string; time?: string }[]) =>
({
id,
trip_id: 1,
day_number: n,
date,
assignments: places.map((p, i) => ({
id: id * 10 + i,
order_index: i,
place: { name: p.name, place_time: p.time ?? null },
})),
}) as unknown as Day;
const labels = {
nextTrip: 'Next',
today: 'Today',
noTrip: 'None',
inDays: 'in {n} days',
tomorrow: 'Tomorrow',
nothingPlanned: '-',
};
describe('pickTrip', () => {
it('prefers the running trip, then the next upcoming one', () => {
const trips = [
trip(1, '2026-12-01', '2026-12-05'),
trip(2, '2026-11-20', '2026-11-25'),
trip(3, '2026-11-01', '2026-11-10'),
];
expect(pickTrip(trips, '2026-11-06')?.id).toBe(3);
expect(pickTrip(trips, '2026-11-12')?.id).toBe(2);
});
it('of several running trips takes the dashboard hero: the one that started first', () => {
const trips = [
trip(1, '2026-11-02', '2026-11-06'),
trip(2, '2026-10-20', '2026-11-18'),
trip(3, '2026-11-04', '2026-11-12'),
];
expect(pickTrip(trips, '2026-11-06')?.id).toBe(2);
});
it('skips undated, archived and finished trips', () => {
expect(
pickTrip(
[trip(1, null, null), trip(2, '2026-12-01', '2026-12-02', 1), trip(3, '2026-01-01', '2026-01-02')],
'2026-11-06'
)
).toBeNull();
});
});
describe('todayOf and tripDayState', () => {
const days = [
day(2, 2, '2026-11-07', []),
day(1, 1, '2026-11-06', [{ name: 'Museum', time: '09:00' }, { name: 'Walk' }, { name: 'Dinner', time: '19:30' }]),
];
it('finds today in day order and keeps the plan order', () => {
const today = todayOf(
trip(1, '2026-11-06', '2026-11-07'),
days,
'2026-11-06',
(n, m) => `${n}/${m}`,
(t) => `T${t}`
);
expect(today?.dayLabel).toBe('1/2');
expect(today?.stops.map((s) => s.name)).toEqual(['Museum', 'Walk', 'Dinner']);
expect(today?.stops[0]).toMatchObject({ time: 'T09:00', minutes: 540 });
});
it('is null on a day outside the trip', () => {
expect(
todayOf(
trip(1, '2026-11-06', '2026-11-07'),
days,
'2026-11-09',
() => '',
(t) => t
)
).toBeNull();
});
it('names the next timed stop and counts the ones behind', () => {
const today = todayOf(
trip(1, '2026-11-06', '2026-11-07'),
days,
'2026-11-06',
() => 'D',
(t) => t
);
if (!today) throw new Error('the trip runs today');
expect(tripDayState(today, 8 * 60, 'done')).toMatchObject({ nextName: 'Museum', done: 0, total: 2 });
expect(tripDayState(today, 12 * 60, 'done')).toMatchObject({ nextName: 'Dinner', nextTime: '19:30', done: 1 });
expect(tripDayState(today, 22 * 60, 'done')).toMatchObject({ nextName: 'done', done: 2, nextTime: undefined });
});
it('gives the Android Live Update the next stop in minutes and one colour per timed stop', () => {
const state = tripDayState(
{
tripTitle: 'T',
path: '/trips/1',
dayLabel: 'D',
stops: [
{ name: 'Museum', time: '10:00', minutes: 600, color: '#D6273B' },
{ name: 'Walk', time: null, minutes: null },
{ name: 'Dinner', time: '19:30', minutes: 1170, color: null },
],
},
12 * 60,
'done'
);
expect(state).toMatchObject({
nextName: 'Dinner',
nextMinutes: 1170,
colors: ['#D6273B', null],
done: 1,
total: 2,
});
expect(
tripDayState({ tripTitle: 'T', path: '/', dayLabel: 'D', stops: [] }, 0, 'done').nextMinutes
).toBeUndefined();
});
});
describe('liveDayDue', () => {
const evening = {
tripTitle: 'T',
path: '/trips/1',
dayLabel: 'D',
stops: [
{ name: 'Walk', time: null, minutes: null },
{ name: 'Uber to the airport', time: '18:00', minutes: 18 * 60 },
{ name: 'Flight', time: '21:00', minutes: 21 * 60 },
],
};
it('keeps the lock screen free until three hours before the first timed stop', () => {
expect(liveDayDue(evening, 6 * 60 + 32)).toBe(false);
expect(liveDayDue(evening, 14 * 60 + 59)).toBe(false);
expect(liveDayDue(evening, 15 * 60)).toBe(true);
});
it('stays on through the day once it has started, until the day is done', () => {
expect(liveDayDue(evening, 19 * 60)).toBe(true);
expect(liveDayDue(evening, 23 * 60 + 30)).toBe(true);
});
it('starts from the earliest timed stop, wherever it stands in the list', () => {
const early = { ...evening, stops: [...evening.stops, { name: 'Breakfast', time: '08:00', minutes: 8 * 60 }] };
expect(liveDayDue(early, 5 * 60)).toBe(true);
expect(liveDayDue(early, 4 * 60 + 59)).toBe(false);
});
it('has nothing to show on a day without times', () => {
expect(liveDayDue({ ...evening, stops: [{ name: 'Walk', time: null, minutes: null }] }, 12 * 60)).toBe(false);
});
});
describe('publishWidgets', () => {
it('writes the snapshot without the internal minutes and ends the activity on a day without times', async () => {
const snapshot = buildSnapshot(
trip(1, '2026-11-06', '2026-11-07'),
{ tripTitle: 'Trip 1', path: '/trips/1', dayLabel: 'D', stops: [{ name: 'Walk', time: null, minutes: null }] },
labels
);
expect(snapshot.today?.stops[0]).toEqual({ name: 'Walk', time: null });
await publishWidgets(snapshot, {
tripTitle: 'Trip 1',
path: '/trips/1',
dayLabel: 'D',
nextName: 'x',
nextTime: undefined,
done: 0,
total: 0,
});
expect(JSON.parse(vi.mocked(TrekShell.setWidgetSnapshot).mock.calls[0][0].json).trip.path).toBe('/trips/1');
expect(TrekShell.endTripDay).toHaveBeenCalled();
expect(TrekShell.syncTripDay).not.toHaveBeenCalled();
});
});
describe('the richer snapshot', () => {
it('puts the timed bookings of the day between the stops and gives the day its place in the trip', () => {
const days = [
day(1, 1, '2026-11-06', [{ name: 'Museum', time: '09:00' }, { name: 'Walk' }, { name: 'Dinner', time: '19:30' }]),
day(2, 2, '2026-11-07', []),
];
const plain = todayOf(
trip(1, '2026-11-06', '2026-11-07'),
days,
'2026-11-06',
() => 'D',
(t) => t
);
if (!plain) throw new Error('the trip runs today');
const today = withBookings(
plain,
[
{ id: 1, trip_id: 1, title: 'Train', type: 'train', status: 'confirmed', reservation_time: '2026-11-06T12:15' },
{
id: 2,
trip_id: 1,
title: 'Tomorrow',
type: 'flight',
status: 'confirmed',
reservation_time: '2026-11-07T08:00',
},
{ id: 3, trip_id: 1, title: 'Gone', type: 'event', status: 'cancelled', reservation_time: '2026-11-06T10:00' },
] as never,
'2026-11-06',
(t) => t
);
expect(today.stops.map((s) => s.name)).toEqual(['Museum', 'Walk', 'Train', 'Dinner']);
expect(today.stops[2]).toMatchObject({ time: '12:15', minutes: 735, symbol: 'tram.fill' });
expect(today).toMatchObject({ index: 1, total: 2 });
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('lands a leg without an arrival day on the day it leaves', () => {
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 legs = [
{ from: 'SNA', to: 'ATL', dep_day_id: 5, dep_time: '08:00', arr_time: '10:00' },
{ from: 'ATL', to: 'ORF', dep_day_id: 5, dep_time: '12:00', arr_time: '14:00' },
];
const today = withBookings(
plain,
[
{ id: 2, trip_id: 1, title: 'Hop', type: 'flight', status: 'confirmed', metadata: JSON.stringify({ legs }) },
] as never,
'2026-11-09',
(t) => t
);
expect(tripDayState(today, 15 * 60, 'done')).toMatchObject({ nextName: 'done', 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,
{
tripTitle: 'Trip 1',
path: '/trips/1',
dayLabel: 'D',
index: 1,
total: 2,
stops: [{ name: 'Tea', time: '10:00', minutes: 600, color: '#2FA37A', symbol: 'leaf.fill' }],
},
labels,
{
place: 'Kyoto, Japan',
cover: null,
placeCount: 12,
memberCount: 0,
weather: { temp: 18, main: 'Clear', label: 'Sunny' },
bookings: [],
todos: { open: 1, total: 2, items: [], path: '/trips/1?tab=listen' },
packing: null,
}
);
expect(snapshot.trip).toEqual({
title: 'Trip 1',
start: '2026-11-06',
end: '2026-11-07',
path: '/trips/1',
place: 'Kyoto, Japan',
dayCount: 2,
placeCount: 12,
});
expect(snapshot.today).toEqual({
dayLabel: 'D',
index: 1,
total: 2,
stops: [{ name: 'Tea', time: '10:00', minutes: 600, color: '#2FA37A', symbol: 'leaf.fill' }],
});
expect(snapshot.weather).toEqual({ temp: 18, main: 'Clear', label: 'Sunny' });
expect(snapshot.bookings).toEqual([]);
expect(snapshot.todos?.open).toBe(1);
expect(snapshot).not.toHaveProperty('packing');
});
});
describe('widgets set to a trip', () => {
it('offers the trips running or ahead, in the dashboard order, at most six', () => {
const trips = [
trip(1, '2026-01-01', '2026-01-05'),
trip(2, '2026-12-01', '2026-12-05'),
trip(3, '2026-11-04', '2026-11-12'),
trip(4, '2026-11-20', '2026-11-22', 1),
trip(5, null, null),
...[6, 7, 8, 9, 10, 11].map((id) => trip(id, `2027-0${id - 5}-01`, `2027-0${id - 5}-03`)),
];
expect(widgetTripChoices(trips, '2026-11-06').map((t) => t.id)).toEqual([3, 2, 6, 7, 8, 9]);
});
it('writes each trip under its own key and the list to pick from', async () => {
vi.mocked(TrekShell.setWidgetSnapshot).mockClear();
const snapshot = buildSnapshot(trip(2, '2026-12-01', '2026-12-05'), null, labels);
await publishWidgets(snapshot, null, [
{ id: 2, title: 'Trip 2', start: '2026-12-01', end: '2026-12-05', snapshot },
]);
const calls = vi.mocked(TrekShell.setWidgetSnapshot).mock.calls.map(([options]) => options);
expect(calls.map((c) => c.key)).toEqual([undefined, 'trip.2', 'trips']);
expect(JSON.parse(calls[2].json)).toEqual([{ id: 2, title: 'Trip 2', start: '2026-12-01', end: '2026-12-05' }]);
});
});