diff --git a/client/src/components/Planner/today.test.ts b/client/src/components/Planner/today.test.ts index c9eb625ac..1f74ead80 100644 --- a/client/src/components/Planner/today.test.ts +++ b/client/src/components/Planner/today.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { findTodayDayId, localToday } from './today' +import { findFocusDayId, findTodayDayId, localToday } from './today' describe('localToday', () => { it('FE-TODAY-001: reads the date off the local clock, not UTC', () => { @@ -44,3 +44,39 @@ describe('findTodayDayId', () => { expect(findTodayDayId([{ id: 7, date: '2026-08-11T00:00:00.000Z' }], at(2026, 8, 11))).toBe(7) }) }) + +describe('findFocusDayId', () => { + const days = [ + { id: 1, date: '2026-08-10' }, + { id: 2, date: '2026-08-12' }, + { id: 3, date: '2026-08-14' }, + ] + const at = (y: number, m: number, d: number) => new Date(y, m - 1, d, 10, 0, 0) + + it('FE-TODAY-007: is today while the trip is running', () => { + expect(findFocusDayId(days, at(2026, 8, 12))).toBe(2) + }) + + it('FE-TODAY-008: skips to the next dated day across a gap and before the trip', () => { + expect(findFocusDayId(days, at(2026, 8, 11))).toBe(2) + expect(findFocusDayId(days, at(2026, 8, 9))).toBe(1) + }) + + it('FE-TODAY-009: is null once the trip is over, leaving the caller its own fallback', () => { + expect(findFocusDayId(days, at(2026, 8, 15))).toBeNull() + }) + + it('FE-TODAY-010: has nothing to focus without dates', () => { + expect(findFocusDayId([{ id: 1, date: null }, { id: 2 }], at(2026, 8, 11))).toBeNull() + expect(findFocusDayId([], at(2026, 8, 11))).toBeNull() + }) + + it('FE-TODAY-011: tolerates full timestamps and leaves the caller’s array alone', () => { + const unordered = [ + { id: 2, date: '2026-08-12T00:00:00.000Z' }, + { id: 1, date: '2026-08-10T00:00:00.000Z' }, + ] + expect(findFocusDayId(unordered, at(2026, 8, 11))).toBe(2) + expect(unordered.map(d => d.id)).toEqual([2, 1]) + }) +}) diff --git a/client/src/components/Planner/today.ts b/client/src/components/Planner/today.ts index 4fee431ae..edd2d9596 100644 --- a/client/src/components/Planner/today.ts +++ b/client/src/components/Planner/today.ts @@ -28,3 +28,26 @@ export function findTodayDayId(days: Array<{ id: number; date?: string | null }> const match = days.find(d => typeof d.date === 'string' && d.date.slice(0, 10) === today) return match ? match.id : null } + +/** + * The day a single-day view should open on: today while the trip is running, + * otherwise the next dated day still ahead. A trip with a gap in its dates — + * or one that has not started yet — lands on the day that is actually coming + * instead of on day one. + * + * Null once every dated day has passed, and for an undated itinerary, so the + * caller keeps its own fallback: a finished trip opens where it always did, + * on its first day. + * + * The desktop plan shows every day at once and only needs findTodayDayId; the + * phone plan is single-day and has to land somewhere, which is the only reason + * the two differ. Both read "today" off localToday, so that part cannot drift. + */ +export function findFocusDayId(days: Array<{ id: number; date?: string | null }>, now?: Date): number | null { + const today = localToday(now) + const ahead = days + .map(d => ({ id: d.id, date: typeof d.date === 'string' ? d.date.slice(0, 10) : '' })) + .filter(d => d.date.length === 10 && d.date >= today) + .sort((a, b) => a.date.localeCompare(b.date)) + return ahead[0]?.id ?? null +} diff --git a/client/src/mobile/screens/trip/MTripShell.tsx b/client/src/mobile/screens/trip/MTripShell.tsx index 0ea0606b2..69f1dd5a7 100644 --- a/client/src/mobile/screens/trip/MTripShell.tsx +++ b/client/src/mobile/screens/trip/MTripShell.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, type ComponentType, type ReactNode } from 'react' -import { findTodayDayId } from '../../../components/Planner/today' +import { findFocusDayId } from '../../../components/Planner/today' import { ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, MoreHorizontal, PackageCheck, Plane, Plus, Rows3, Ticket, TrainFront, Trash2, Upload, Wallet, @@ -199,16 +199,22 @@ export default function MTripShell({ const [openFilesTrashSignal, setOpenFilesTrashSignal] = useState(0) // The mobile plan is single-day: make sure a day is active once days arrive. - // Only seed once so an intentional deselect elsewhere is not fought. When the - // trip is currently running, open on today's day instead of day 1 so you don't - // have to tap the current day every time; otherwise fall back to the first day. + // Only seed once so an intentional deselect elsewhere is not fought. Open on + // today while the trip is running, otherwise on the next day that is still + // ahead — a gap in the dates should not throw you back to day 1 — and fall + // back to the first day once the whole trip is behind us. const seededDayRef = useRef(false) useEffect(() => { - if (seededDayRef.current || planner.selectedDayId != null || days.length === 0) return + if (seededDayRef.current) return + // A day that is already active counts as seeded: a later deselect is the + // user's, and re-seeding it here would be exactly the fight this guard + // exists to avoid. + if (planner.selectedDayId != null) { seededDayRef.current = true; return } + if (days.length === 0) return seededDayRef.current = true - // Same rule as the desktop day plan (#1567), off the same helper so the two + // Off the same helper file as the desktop day plan (#1567), so the two // cannot drift on what "today" means. - planner.tripActions.setSelectedDay(findTodayDayId(days) ?? days[0].id) + planner.tripActions.setSelectedDay(findFocusDayId(days) ?? days[0].id) }, [planner.selectedDayId, days, planner.tripActions]) const trTab = planner.activeTab diff --git a/client/src/mobile/screens/trip/plan/planTimelineModel.ts b/client/src/mobile/screens/trip/plan/planTimelineModel.ts index c31a9fbc7..253e8cac8 100644 --- a/client/src/mobile/screens/trip/plan/planTimelineModel.ts +++ b/client/src/mobile/screens/trip/plan/planTimelineModel.ts @@ -220,21 +220,28 @@ const localIsoDate = (d: Date): string => /** * The "UP NEXT" pick: on today's day the first timed stop that hasn't started * yet (with a real countdown); otherwise the first timed stop of the day, or - * simply the first stop. Null when the day has no places. + * simply the first stop. + * + * Null when the day has no places, when the day is already behind us, and once + * today's timed plan has run out — a card headed "UP NEXT" that points at this + * morning's first stop, or at a day from last week, is not a plan, it is noise. */ export function findUpNext(day: Day | undefined, dayAssignments: Assignment[], now: Date): UpNext | null { if (dayAssignments.length === 0) return null const sorted = [...dayAssignments].sort((a, b) => a.order_index - b.order_index) const timeOf = (a: Assignment) => parseTimeToMinutes(a.place?.place_time) - const isToday = !!day?.date && day.date.slice(0, 10) === localIsoDate(now) - if (isToday) { - const nowMinutes = now.getHours() * 60 + now.getMinutes() - const upcoming = sorted - .filter(a => { const m = timeOf(a); return m != null && m >= nowMinutes }) - .sort((a, b) => (timeOf(a) ?? 0) - (timeOf(b) ?? 0)) - if (upcoming.length > 0) return { assignment: upcoming[0], minutesUntil: (timeOf(upcoming[0]) ?? 0) - nowMinutes } - } + const dayDate = day?.date?.slice(0, 10) + const today = localIsoDate(now) + if (dayDate && dayDate < today) return null const timed = sorted.filter(a => timeOf(a) != null).sort((a, b) => (timeOf(a) ?? 0) - (timeOf(b) ?? 0)) + if (dayDate === today) { + const nowMinutes = now.getHours() * 60 + now.getMinutes() + const upcoming = timed.find(a => (timeOf(a) ?? 0) >= nowMinutes) + if (upcoming) return { assignment: upcoming, minutesUntil: (timeOf(upcoming) ?? 0) - nowMinutes } + // Every timed stop of today has started — a day whose plan carries no times + // at all still shows its first stop, there is nothing stale about that. + if (timed.length > 0) return null + } return { assignment: timed[0] ?? sorted[0], minutesUntil: null } } diff --git a/client/tests/unit/mobile/trip/MTripShell.test.tsx b/client/tests/unit/mobile/trip/MTripShell.test.tsx index f2d3a454c..2cd606ada 100644 --- a/client/tests/unit/mobile/trip/MTripShell.test.tsx +++ b/client/tests/unit/mobile/trip/MTripShell.test.tsx @@ -64,16 +64,17 @@ function renderShell(overrides: Partial = {}) { TRIP_TABS, ...overrides, } as Partial) - const view = render( + const Shell = () => ( , + /> ) - return { ...view, planner: mocks.planner } + const view = render() + return { ...view, planner: mocks.planner, rerenderShell: () => view.rerender() } } const spy = (planner: TripPlanner, name: keyof TripPlanner) => @@ -120,9 +121,29 @@ describe('MTripShell', () => { expect(planner.tripActions.setSelectedDay).toHaveBeenCalledWith(11) }) - it('FE-MOB-SHELL-006: leaves an existing day selection alone', () => { - const { planner } = renderShell() + it('FE-MOB-SHELL-006: leaves an existing day selection alone, and a later deselect too', () => { + const { planner, rerenderShell } = renderShell() expect(planner.tripActions.setSelectedDay).not.toHaveBeenCalled() + + // Clearing the day is the user's doing; seeding it again here would be the + // shell fighting them for it. + planner.selectedDayId = null + rerenderShell() + expect(planner.tripActions.setSelectedDay).not.toHaveBeenCalled() + }) + + it('FE-MOB-SHELL-006b: seeds the next dated day when today falls in a gap', () => { + const now = new Date() + const iso = (offset: number) => { + const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offset) + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` + } + const days = [ + { id: 11, day_number: 1, date: iso(-2) }, + { id: 12, day_number: 2, date: iso(2) }, + ] as unknown as Day[] + const { planner } = renderShell({ days, selectedDayId: null } as Partial) + expect(planner.tripActions.setSelectedDay).toHaveBeenCalledWith(12) }) it('FE-MOB-SHELL-007: does not seed a day before the days arrive', () => { diff --git a/client/tests/unit/mobile/trip/planTimelineModel.test.ts b/client/tests/unit/mobile/trip/planTimelineModel.test.ts index 725d0a11c..1ece1187c 100644 --- a/client/tests/unit/mobile/trip/planTimelineModel.test.ts +++ b/client/tests/unit/mobile/trip/planTimelineModel.test.ts @@ -11,7 +11,7 @@ import type { Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn, } from '../../../../src/types' -// FE-MOB-PTLM-001 to FE-MOB-PTLM-043 +// FE-MOB-PTLM-001 to FE-MOB-PTLM-044 const DAYS = [ { id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null }, @@ -345,10 +345,12 @@ describe('planTimelineModel — findUpNext', () => { expect(up?.minutesUntil).toBe(45) }) - it('FE-MOB-PTLM-033: falls back to the first timed stop once the day is over', () => { - const up = findUpNext(DAY2, [early, noon, late], new Date(2026, 4, 2, 23, 30)) - expect(up?.assignment.id).toBe(11) - expect(up?.minutesUntil).toBeNull() + it('FE-MOB-PTLM-033: shows nothing once the timed plan for today has run out', () => { + expect(findUpNext(DAY2, [early, noon, late], new Date(2026, 4, 2, 23, 30))).toBeNull() + }) + + it('FE-MOB-PTLM-044: shows nothing for a day that is already behind us', () => { + expect(findUpNext(DAY2, [late, noon, early], new Date(2026, 4, 3, 8, 0))).toBeNull() }) it('FE-MOB-PTLM-034: never counts down on a day that is not today', () => { diff --git a/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts index df9650b93..3e5acaa8d 100644 --- a/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts +++ b/client/tests/unit/mobile/trip/useMPlanTimeline.test.ts @@ -239,7 +239,7 @@ describe('useMPlanTimeline', () => { expect(result.current.openTransitKeys.has('tr-61')).toBe(false) }) - it('FE-MOB-PLTL-014: counts down on today and never on any other day', () => { + it('FE-MOB-PLTL-014: counts down on today and shows nothing for a day gone by', () => { vi.useFakeTimers() vi.setSystemTime(new Date(2026, 4, 2, 10, 15)) const today = makePlanner({ assignments: { '2': [TIMED_EARLY, TIMED_LATE] } }) @@ -251,7 +251,7 @@ describe('useMPlanTimeline', () => { days: DAYS.map(d => (d.id === 2 ? { ...d, date: '2026-04-30' } : d)), }) const other = renderHook(() => useMPlanTimeline(past)) - expect(other.result.current.upNext).toEqual({ assignment: TIMED_EARLY, minutesUntil: null }) + expect(other.result.current.upNext).toBeNull() }) it('FE-MOB-PLTL-015: re-evaluates the countdown on the half-minute tick', () => { @@ -263,7 +263,7 @@ describe('useMPlanTimeline', () => { vi.setSystemTime(new Date(2026, 4, 2, 11, 30)) act(() => { vi.advanceTimersByTime(30_000) }) - expect(result.current.upNext).toEqual({ assignment: TIMED_EARLY, minutesUntil: null }) + expect(result.current.upNext).toBeNull() }) it('FE-MOB-PLTL-016: reorders the day when a stop is moved down', async () => {