diff --git a/client/src/components/Layout/NativeWidgetsSync.tsx b/client/src/components/Layout/NativeWidgetsSync.tsx index 0252a2800..a1b6a6aba 100644 --- a/client/src/components/Layout/NativeWidgetsSync.tsx +++ b/client/src/components/Layout/NativeWidgetsSync.tsx @@ -20,6 +20,7 @@ import { widgetCover, widgetWeather } from '../../native/widgetMedia'; import { atlasSummary, collabSummary, packingDetail, type WidgetAtlas } from '../../native/widgetOnline'; import { buildSnapshot, + liveDayDue, pickTrip, publishWidgets, todayOf, @@ -207,9 +208,10 @@ export default function NativeWidgetsSync(): null { }); } if (cancelled) return; - const live = main.day - ? tripDayState(main.day, now.getHours() * 60 + now.getMinutes(), t('native.widget.dayDone')) - : null; + // The Live Activity from three hours before the day's first timed stop, not all day long. + const minutes = now.getHours() * 60 + now.getMinutes(); + const live = + main.day && liveDayDue(main.day, minutes) ? tripDayState(main.day, minutes, t('native.widget.dayDone')) : null; await publishWidgets(main.snapshot, live, choices); await publishToWatch(hero, main, today); }; diff --git a/client/src/native/widgets.test.ts b/client/src/native/widgets.test.ts index 18be985eb..f220ae68a 100644 --- a/client/src/native/widgets.test.ts +++ b/client/src/native/widgets.test.ts @@ -15,6 +15,7 @@ vi.mock('./trekShell', () => ({ import { TrekShell } from './trekShell'; import { buildSnapshot, + liveDayDue, pickTrip, publishWidgets, todayOf, @@ -151,6 +152,40 @@ describe('todayOf and tripDayState', () => { }); }); +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( diff --git a/client/src/native/widgets.ts b/client/src/native/widgets.ts index aebe46c82..4b4019eda 100644 --- a/client/src/native/widgets.ts +++ b/client/src/native/widgets.ts @@ -357,6 +357,23 @@ export function tripDayState(day: TripDay, nowMinutes: number, allDone: string): }; } +/** How long before the day's first timed stop its Live Activity appears. */ +export const LIVE_LEAD_MINUTES = 3 * 60; + +/** + * Whether the travel day's Live Activity belongs on the lock screen now: from + * three hours before the first stop with a time, then until the day is done. A + * day whose first stop is an evening flight does not hold the lock screen from + * the morning on. + */ +export function liveDayDue(day: TripDay, nowMinutes: number): boolean { + const first = day.stops.reduce( + (min, s) => (s.minutes != null && (min === null || s.minutes < min) ? s.minutes : min), + null + ); + return first !== null && nowMinutes >= first - LIVE_LEAD_MINUTES; +} + /** A trip a widget can be set to in Edit Widget, and its snapshot. */ export interface TripWidgetSnapshot { id: number;