fix(mobile): drop stale up-next cards and open the plan on the next day (#2057)

The UP NEXT card kept pointing at this morning's first stop hours after it had
started, and at day one of a trip that ended last month. A header promising what
comes next, sitting above something that is over. It now stays away on a day that
has passed and once today's timed plan has run out — a day whose stops carry no
times at all still shows its first stop, there is nothing stale about that. The
timeline card already collapses the reserved space when there is no up-next, so
the layout closes cleanly.

Seeding the plan day also lands on the next day still ahead when today falls into
a gap in the dates, instead of dropping back to day one. A trip that is already
over keeps opening on its first day.

And a day that was selected before the shell mounted now counts as seeded, so
clearing the selection is left alone instead of being re-seeded on the next
render — which is what the guard was there for in the first place.

Based on #2055.
This commit is contained in:
Maurice
2026-08-27 11:02:09 +02:00
committed by GitHub
parent f61bab3ffd
commit 495c5e95ec
7 changed files with 125 additions and 30 deletions
+37 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest' import { describe, it, expect } from 'vitest'
import { findTodayDayId, localToday } from './today' import { findFocusDayId, findTodayDayId, localToday } from './today'
describe('localToday', () => { describe('localToday', () => {
it('FE-TODAY-001: reads the date off the local clock, not UTC', () => { 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) 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])
})
})
+23
View File
@@ -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) const match = days.find(d => typeof d.date === 'string' && d.date.slice(0, 10) === today)
return match ? match.id : null 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
}
+13 -7
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type ComponentType, type ReactNode } from 'react' import { useEffect, useRef, useState, type ComponentType, type ReactNode } from 'react'
import { findTodayDayId } from '../../../components/Planner/today' import { findFocusDayId } from '../../../components/Planner/today'
import { import {
ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, MoreHorizontal, PackageCheck, ChevronDown, ChevronLeft, Download, FileDown, List, Map as MapIcon, MoreHorizontal, PackageCheck,
Plane, Plus, Rows3, Ticket, TrainFront, Trash2, Upload, Wallet, Plane, Plus, Rows3, Ticket, TrainFront, Trash2, Upload, Wallet,
@@ -199,16 +199,22 @@ export default function MTripShell({
const [openFilesTrashSignal, setOpenFilesTrashSignal] = useState(0) const [openFilesTrashSignal, setOpenFilesTrashSignal] = useState(0)
// The mobile plan is single-day: make sure a day is active once days arrive. // 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 // Only seed once so an intentional deselect elsewhere is not fought. Open on
// trip is currently running, open on today's day instead of day 1 so you don't // today while the trip is running, otherwise on the next day that is still
// have to tap the current day every time; otherwise fall back to the first day. // 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) const seededDayRef = useRef(false)
useEffect(() => { 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 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. // 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]) }, [planner.selectedDayId, days, planner.tripActions])
const trTab = planner.activeTab const trTab = planner.activeTab
@@ -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 * 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 * 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 { export function findUpNext(day: Day | undefined, dayAssignments: Assignment[], now: Date): UpNext | null {
if (dayAssignments.length === 0) return null if (dayAssignments.length === 0) return null
const sorted = [...dayAssignments].sort((a, b) => a.order_index - b.order_index) const sorted = [...dayAssignments].sort((a, b) => a.order_index - b.order_index)
const timeOf = (a: Assignment) => parseTimeToMinutes(a.place?.place_time) const timeOf = (a: Assignment) => parseTimeToMinutes(a.place?.place_time)
const isToday = !!day?.date && day.date.slice(0, 10) === localIsoDate(now) const dayDate = day?.date?.slice(0, 10)
if (isToday) { const today = localIsoDate(now)
const nowMinutes = now.getHours() * 60 + now.getMinutes() if (dayDate && dayDate < today) return null
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 timed = sorted.filter(a => timeOf(a) != null).sort((a, b) => (timeOf(a) ?? 0) - (timeOf(b) ?? 0)) 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 } return { assignment: timed[0] ?? sorted[0], minutesUntil: null }
} }
@@ -64,16 +64,17 @@ function renderShell(overrides: Partial<TripPlanner> = {}) {
TRIP_TABS, TRIP_TABS,
...overrides, ...overrides,
} as Partial<TripPlanner>) } as Partial<TripPlanner>)
const view = render( const Shell = () => (
<MTripShell <MTripShell
PlanTimeline={slot('plan-timeline')} PlanTimeline={slot('plan-timeline')}
MapArea={slot('map-area')} MapArea={slot('map-area')}
PlacesBrowser={slot('places-browser')} PlacesBrowser={slot('places-browser')}
TabPanel={TabSlot} TabPanel={TabSlot}
Sheets={slot('sheets')} Sheets={slot('sheets')}
/>, />
) )
return { ...view, planner: mocks.planner } const view = render(<Shell />)
return { ...view, planner: mocks.planner, rerenderShell: () => view.rerender(<Shell />) }
} }
const spy = (planner: TripPlanner, name: keyof TripPlanner) => const spy = (planner: TripPlanner, name: keyof TripPlanner) =>
@@ -120,9 +121,29 @@ describe('MTripShell', () => {
expect(planner.tripActions.setSelectedDay).toHaveBeenCalledWith(11) expect(planner.tripActions.setSelectedDay).toHaveBeenCalledWith(11)
}) })
it('FE-MOB-SHELL-006: leaves an existing day selection alone', () => { it('FE-MOB-SHELL-006: leaves an existing day selection alone, and a later deselect too', () => {
const { planner } = renderShell() const { planner, rerenderShell } = renderShell()
expect(planner.tripActions.setSelectedDay).not.toHaveBeenCalled() 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<TripPlanner>)
expect(planner.tripActions.setSelectedDay).toHaveBeenCalledWith(12)
}) })
it('FE-MOB-SHELL-007: does not seed a day before the days arrive', () => { it('FE-MOB-SHELL-007: does not seed a day before the days arrive', () => {
@@ -11,7 +11,7 @@ import type {
Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn, Accommodation, Assignment, Day, DayNote, Reservation, RouteSegment, TranslationFn,
} from '../../../../src/types' } from '../../../../src/types'
// FE-MOB-PTLM-001 to FE-MOB-PTLM-043 // FE-MOB-PTLM-001 to FE-MOB-PTLM-044
const DAYS = [ const DAYS = [
{ id: 1, trip_id: 1, day_number: 1, date: '2026-05-01', title: null }, { 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) expect(up?.minutesUntil).toBe(45)
}) })
it('FE-MOB-PTLM-033: falls back to the first timed stop once the day is over', () => { it('FE-MOB-PTLM-033: shows nothing once the timed plan for today has run out', () => {
const up = findUpNext(DAY2, [early, noon, late], new Date(2026, 4, 2, 23, 30)) expect(findUpNext(DAY2, [early, noon, late], new Date(2026, 4, 2, 23, 30))).toBeNull()
expect(up?.assignment.id).toBe(11) })
expect(up?.minutesUntil).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', () => { it('FE-MOB-PTLM-034: never counts down on a day that is not today', () => {
@@ -239,7 +239,7 @@ describe('useMPlanTimeline', () => {
expect(result.current.openTransitKeys.has('tr-61')).toBe(false) 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.useFakeTimers()
vi.setSystemTime(new Date(2026, 4, 2, 10, 15)) vi.setSystemTime(new Date(2026, 4, 2, 10, 15))
const today = makePlanner({ assignments: { '2': [TIMED_EARLY, TIMED_LATE] } }) 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)), days: DAYS.map(d => (d.id === 2 ? { ...d, date: '2026-04-30' } : d)),
}) })
const other = renderHook(() => useMPlanTimeline(past)) 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', () => { 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)) vi.setSystemTime(new Date(2026, 4, 2, 11, 30))
act(() => { vi.advanceTimersByTime(30_000) }) 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 () => { it('FE-MOB-PLTL-016: reorders the day when a stop is moved down', async () => {