From b8ec428ad1921ea4551dcf3cd3cf693ecd29e0ff Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 12 Nov 2026 14:00:47 +0100 Subject: [PATCH] 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. --- .../components/Layout/NativeWidgetsSync.tsx | 8 +++-- client/src/native/widgets.test.ts | 35 +++++++++++++++++++ client/src/native/widgets.ts | 17 +++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) 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;