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; +}