From ae082c1fd02ab7b1bdb360f3fb32a00b2ae2fe35 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 6 Nov 2026 20:25:41 +0100 Subject: [PATCH] fix(native): the widgets show the trip the dashboard features With several trips running at once, the widgets took whichever came first on the device, so an old test trip spanning today pushed out the journey under way, and the today widget counted its days. The dashboard's choice moves to utils/heroTrip.ts (running first, earliest start, else the next to come) and the widgets ask the same function, so the home screen always shows the trip the app opens on. --- client/src/native/widgets.test.ts | 9 +++++ client/src/native/widgets.ts | 15 ++++---- client/src/pages/dashboard/dashboardModel.ts | 15 ++------ client/src/pages/dashboard/useDashboard.ts | 5 ++- client/src/utils/heroTrip.test.ts | 25 +++++++++++++ client/src/utils/heroTrip.ts | 37 ++++++++++++++++++++ 6 files changed, 81 insertions(+), 25 deletions(-) create mode 100644 client/src/utils/heroTrip.test.ts create mode 100644 client/src/utils/heroTrip.ts diff --git a/client/src/native/widgets.test.ts b/client/src/native/widgets.test.ts index 9fc79cbf4..286fa601b 100644 --- a/client/src/native/widgets.test.ts +++ b/client/src/native/widgets.test.ts @@ -51,6 +51,15 @@ describe('pickTrip', () => { 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( diff --git a/client/src/native/widgets.ts b/client/src/native/widgets.ts index 95e5996ae..cf109afac 100644 --- a/client/src/native/widgets.ts +++ b/client/src/native/widgets.ts @@ -1,5 +1,6 @@ import type { Day, Reservation, Trip } from '../types'; import { splitReservationDateTime } from '../utils/formatters'; +import { heroTrip, sortTripsByRelevance } from '../utils/heroTrip'; import { hasNativeFeature } from './capabilities'; import { isNativeIos } from './platform'; import { reportNativeFailure } from './reportFailure'; @@ -121,17 +122,13 @@ function minutesOf(time: string | null | undefined): number | null { } /** - * The running trip if there is one, else the next to start. Trips without dates - * and trips already over are not candidates. + * The trip the dashboard's hero features (utils/heroTrip.ts): the running one, + * else the next to start, so the widgets show the trip the app opens on. + * Archived trips are not candidates, as on the dashboard. */ export function pickTrip(trips: Trip[], today: string): Trip | null { - const dated = trips.flatMap((t) => - t.start_date && t.end_date && !t.is_archived ? [{ trip: t, start: t.start_date, end: t.end_date }] : [] - ); - const running = dated.find((d) => d.start <= today && d.end >= today); - if (running) return running.trip; - const upcoming = dated.filter((d) => d.start > today).sort((a, b) => a.start.localeCompare(b.start)); - return upcoming[0]?.trip ?? null; + const active = trips.filter((t) => !t.is_archived); + return heroTrip(sortTripsByRelevance(active, today), today); } /** The symbols of the booking kinds a day's timeline shows next to its stops. */ diff --git a/client/src/pages/dashboard/dashboardModel.ts b/client/src/pages/dashboard/dashboardModel.ts index 981bcfc68..407504202 100644 --- a/client/src/pages/dashboard/dashboardModel.ts +++ b/client/src/pages/dashboard/dashboardModel.ts @@ -7,6 +7,7 @@ import type { Trip } from '../../types' import { localIsoDate } from '../../utils/localDate' +import { sortTripsByRelevance } from '../../utils/heroTrip' // The dashboard works with the canonical Trip shape returned by the list/get // endpoints (it already carries the computed day_count/place_count/is_owner/ @@ -83,17 +84,5 @@ export function getTripStatus(trip: DashboardTrip): 'ongoing' | 'today' | 'tomor } export function sortTrips(trips: DashboardTrip[]): DashboardTrip[] { - const today = localIsoToday() - const rank = (t: DashboardTrip) => { - if (t.start_date && t.end_date && t.start_date <= today && t.end_date >= today) return 0 - if (t.start_date && t.start_date >= today) return 1 - return 2 - } - return [...trips].sort((a, b) => { - const ra = rank(a), rb = rank(b) - if (ra !== rb) return ra - rb - const ad = a.start_date || '', bd = b.start_date || '' - if (ra <= 1) return ad.localeCompare(bd) - return bd.localeCompare(ad) - }) + return sortTripsByRelevance(trips, localIsoToday()) } diff --git a/client/src/pages/dashboard/useDashboard.ts b/client/src/pages/dashboard/useDashboard.ts index 524347e31..6afd1f1d5 100644 --- a/client/src/pages/dashboard/useDashboard.ts +++ b/client/src/pages/dashboard/useDashboard.ts @@ -9,6 +9,7 @@ import { useTranslation } from '../../i18n' import { useToast } from '../../components/shared/Toast' import { getApiErrorMessage } from '../../types' import { localIsoToday } from './dashboardModel' +import { heroTrip } from '../../utils/heroTrip' import { useTripSearch } from './useTripSearch' import type { TripCreateRequest } from '@trek/shared' import { @@ -120,9 +121,7 @@ export function useDashboard() { // A trip the hero features on its own merit: one that is running, else the next // one coming up. Only that one is taken out of the grid below, so the same trip // isn't shown twice. - const featured = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today) - || trips.find(t => t.start_date && t.start_date >= today) - || null + const featured = heroTrip(trips, today) // With neither, the hero still shows something rather than sitting empty — but // that trip is only borrowed for the header and must stay in the grid, or a user // whose trips are all finished (or undated) sees "No trips yet". #1706 diff --git a/client/src/utils/heroTrip.test.ts b/client/src/utils/heroTrip.test.ts new file mode 100644 index 000000000..7353bf052 --- /dev/null +++ b/client/src/utils/heroTrip.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { heroTrip, sortTripsByRelevance } from './heroTrip'; + +const trip = (id: number, start_date: string | null, end_date: string | null) => ({ id, start_date, end_date }); + +describe('heroTrip', () => { + const today = '2026-11-06'; + 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-10-20', '2026-11-18'), + trip(5, null, null), + ]; + + it('orders running trips first, then the ones coming up, then the rest, latest first', () => { + expect(sortTripsByRelevance(trips, today).map((t) => t.id)).toEqual([4, 3, 2, 1, 5]); + }); + + it('features the running trip that started first, else the next to come', () => { + expect(heroTrip(sortTripsByRelevance(trips, today), today)?.id).toBe(4); + expect(heroTrip(sortTripsByRelevance([trips[0], trips[1]], today), today)?.id).toBe(2); + expect(heroTrip(sortTripsByRelevance([trips[0], trips[4]], today), today)).toBeNull(); + }); +}); diff --git a/client/src/utils/heroTrip.ts b/client/src/utils/heroTrip.ts new file mode 100644 index 000000000..6f25217b3 --- /dev/null +++ b/client/src/utils/heroTrip.ts @@ -0,0 +1,37 @@ +/** + * Which trip the dashboard features, in one place: the dashboard's hero and + * the iOS widgets (native/widgets.ts) both ask here, so the trip on the home + * screen is always the one the app opens on. + */ +interface DatedTrip { + start_date?: string | null; + end_date?: string | null; +} + +/** 0 running, 1 coming up, 2 past or undated. */ +function rank(trip: DatedTrip, today: string): number { + if (trip.start_date && trip.end_date && trip.start_date <= today && trip.end_date >= today) return 0; + if (trip.start_date && trip.start_date >= today) return 1; + return 2; +} + +/** Running trips first, then the ones coming up, both soonest first; the rest latest first. */ +export function sortTripsByRelevance(trips: T[], today: string): T[] { + return [...trips].sort((a, b) => { + const ra = rank(a, today); + const rb = rank(b, today); + if (ra !== rb) return ra - rb; + const ad = a.start_date || ''; + const bd = b.start_date || ''; + if (ra <= 1) return ad.localeCompare(bd); + return bd.localeCompare(ad); + }); +} + +/** + * The trip the hero features on its own merit: the first running one in that + * order, else the first coming up; null when every trip is past or undated. + */ +export function heroTrip(sorted: T[], today: string): T | null { + return sorted.find((t) => rank(t, today) === 0) || sorted.find((t) => rank(t, today) === 1) || null; +}