From 21afe89b1371cebb35685f4beb34d6f393f20449 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sun, 8 Nov 2026 02:42:37 +0000 Subject: [PATCH] feat(mobile): a road trip day follows an imported track on an iPad and an unfolded Duo The desktop's track dialog comes to the large screens as a sheet over the planner's followTrack, the hook RoadtripTrackModal runs on: the trip's tracks nearest to the day first, the drive fitted to the one picked with via points, and those vias dropped again. The stage card offers it in the wide and split size classes, to an editor of days, on a trip that holds a track, and names the track the day follows. The phone and the folded Duo have no entry to it. --- client/scripts/feature-pairs.json | 1 + .../screens/trip/roadtrip/MRoadtripTab.tsx | 2 + .../screens/trip/roadtrip/MRtTrackSheet.tsx | 188 ++++++++++++++++++ .../screens/trip/sheets/MTripSheets.tsx | 2 + client/tests/helpers/mobileTrip.ts | 8 + .../unit/mobile/trip/MRtTrackSheet.test.tsx | 145 ++++++++++++++ 6 files changed, 346 insertions(+) create mode 100644 client/src/mobile/screens/trip/roadtrip/MRtTrackSheet.tsx create mode 100644 client/tests/unit/mobile/trip/MRtTrackSheet.test.tsx diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json index 7a630954f..72e148c37 100644 --- a/client/scripts/feature-pairs.json +++ b/client/scripts/feature-pairs.json @@ -1048,6 +1048,7 @@ "src/mobile/screens/trip/roadtrip/MRtLimitsEditor.tsx": "Large screen form inside MRtInfoSheet over the planner's saveRoadtripLimit, the desktop dialog's own save, and the shared dayWindow, parseAvoid and rangeFromSpec.", "src/mobile/screens/trip/roadtrip/MRtKindSheet.tsx": "Thin sheet over planner.setRoadtripStopKind that already shares STOP_KINDS with StopKindPicker.", "src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx": "Phone only stop detail sheet over the shared roadtripRowModel, stayReading and planner writers.", + "src/mobile/screens/trip/roadtrip/MRtTrackSheet.tsx": "Large screen sheet over the planner's followTrack, the same hook RoadtripTrackModal runs on; the phone has no entry to it.", "src/mobile/screens/trip/roadtrip/stageBadges.ts": "Badge helpers for the phone stage header and alternatives bar, which have no desktop counterpart.", "src/mobile/screens/trip/roadtrip/useMRoadtrip.ts": "Phone stage screen adapter over the shared roadtripRowModel plus a live clock tick.", "src/mobile/screens/trip/roadtrip/useMRtAlternatives.ts": "Tap to preview, confirm to write layer over the planner's shared route alternatives.", diff --git a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx index 500690789..afec1d0ff 100644 --- a/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx +++ b/client/src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx @@ -6,6 +6,7 @@ import { useMRtCorridor } from './useMRtCorridor' import { useMRtAlternatives } from './useMRtAlternatives' import MRtCorridorBar from './MRtCorridorBar' import MRtAlternativesBar from './MRtAlternativesBar' +import { MRtTrackEntry } from './MRtTrackSheet' import { RtAutoRow, RtBookendRow, RtBookingChips, RtDryRow, RtLegRow, RtRideRow, RtSpillRow, RtStopRow, type RowChrome } from './MRoadtripRows' import MBadge from '../../../components/MBadge' import MDancingTrek from '../../../components/MDancingTrek' @@ -289,6 +290,7 @@ function RoadtripStagePage({ planner, shell, dayId: pageDayId, alts, chrome }: O )} + {rt.upNext && } diff --git a/client/src/mobile/screens/trip/roadtrip/MRtTrackSheet.tsx b/client/src/mobile/screens/trip/roadtrip/MRtTrackSheet.tsx new file mode 100644 index 000000000..33dc961fe --- /dev/null +++ b/client/src/mobile/screens/trip/roadtrip/MRtTrackSheet.tsx @@ -0,0 +1,188 @@ +import { AlertTriangle, Check, RotateCw, Spline } from 'lucide-react'; +import { useState } from 'react'; +import type { FollowTrackOutcome } from '../../../../components/Roadtrip/useFollowTrack'; +import { useIsWide } from '../../../../hooks/sizeClass'; +import { useTranslation } from '../../../../i18n'; +import { useSettingsStore } from '../../../../store/settingsStore'; +import { formatDistance } from '../../../../utils/units'; +import MSheet from '../../../components/MSheet'; +import type { MTripSheetsProps } from '../MTripShell'; +import { SHEET_PRIMARY, SHEET_SECONDARY, TileHeader } from '../sheets/MTripSheetUi'; + +/** + * Which imported track a day drives along, on an iPad or an unfolded Duo. + * + * The desktop's track dialog (RoadtripTrackModal) as a sheet, over the same `followTrack` + * of the planner: it lists the trip's tracks nearest to the day first, fits the drive to + * the one picked with via points, and drops those vias again. The phone has no entry to + * it; the stage there stays a plan read on the road, and reshaping a whole day's drive + * is work for the larger screen. The sheet opens when the planner's `followTrack` has a + * day, which only the large screen's stage card asks for. + */ + +/** Within this many kilometres a track counts as running along the day. */ +const ON_THIS_DAY_KM = 2; + +/** The sentence for a finished fit: nothing to do, as close as asked, or as close as it got. */ +function outcomeKey(outcome: FollowTrackOutcome): string { + if (outcome.vias === 0) return 'roadtrip.track.already'; + return outcome.capped ? 'roadtrip.track.capped' : 'roadtrip.track.done'; +} + +const NOTE = 'font-geist text-caption leading-snug'; + +/** + * The stage card's way into the sheet, on a large screen only: the track the day follows, + * or the offer to follow one. Absent on the phone, for a day not stored yet, for a trip + * without tracks (its only answer would be "no tracks") and for a reader who may not edit + * days, since following a track writes via points onto the day. + */ +export function MRtTrackEntry({ planner, dayId }: { planner: MTripSheetsProps['planner']; dayId: number }) { + const { t } = useTranslation(); + const large = useIsWide(); + const follow = planner.followTrack; + if (!large || dayId < 0 || !follow.available || !planner.can('day_edit', planner.trip)) return null; + const name = follow.namesByDay[dayId]; + return ( + + ); +} + +export default function MRtTrackSheet({ planner }: MTripSheetsProps) { + const { t } = useTranslation(); + const unit = useSettingsStore((s) => s.settings.distance_unit); + const large = useIsWide(); + const follow = planner.followTrack; + const { dayId, busy, round, error, outcome, tracks, viaCount, current } = follow; + // The pick belongs to the day it was made on: a track picked on one day means nothing + // on the next, so it is kept together with that day and read only while they match. + const [pick, setPick] = useState<{ dayId: number; trackId: number } | null>(null); + const picked = pick && pick.dayId === dayId ? pick.trackId : null; + const chosen = tracks.find((track) => track.id === picked) ?? null; + const open = large && dayId !== null; + const dayNumber = planner.roadtripRoutes.days.find((d) => d.dayId === dayId)?.dayNumber ?? 0; + + return ( + +
+ } + title={{t('roadtrip.track.title', { number: dayNumber })}} + sub={current ? t('roadtrip.track.current', { name: current.name }) : undefined} + subWrap + onClose={follow.close} + closeLabel={t('common.close')} + /> +
+ +
+ {tracks.length === 0 ? ( +
+
{t('roadtrip.track.empty')}
+

{t('roadtrip.track.emptyHint')}

+
+ ) : ( +
    + {tracks.map((track) => { + const on = track.id === picked; + return ( +
  • + +
  • + ); + })} +
+ )} + + {/* What the pick will do, before it is done: a track becomes via points on the day. */} + {chosen &&

{t('roadtrip.track.explain')}

} + + {busy && ( +

+ + {t('roadtrip.track.working', { round })} +

+ )} + + {error && ( +

+ + {t(error === 'route' ? 'roadtrip.track.errorRoute' : 'roadtrip.track.errorSave')} +

+ )} + + {outcome && !busy && !error && ( +

+ + {t(outcomeKey(outcome), { count: outcome.vias, distance: formatDistance(outcome.strayKm, unit) })} +

+ )} +
+ +
+ {/* Whenever the day carries vias, whoever put them there: back to the router's way. */} + {viaCount > 0 && ( + + )} + +
+
+ ); +} diff --git a/client/src/mobile/screens/trip/sheets/MTripSheets.tsx b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx index d0b3b9087..cee5a923b 100644 --- a/client/src/mobile/screens/trip/sheets/MTripSheets.tsx +++ b/client/src/mobile/screens/trip/sheets/MTripSheets.tsx @@ -29,6 +29,7 @@ import MRtStopSheet from '../roadtrip/MRtStopSheet' import MRtStaySheet from '../roadtrip/MRtStaySheet' import MRtKindSheet from '../roadtrip/MRtKindSheet' import MRtInfoSheet from '../roadtrip/MRtInfoSheet' +import MRtTrackSheet from '../roadtrip/MRtTrackSheet' import MRtCorridorSheet from '../roadtrip/MRtCorridorSheet' import MRtDraftSheet from '../roadtrip/MRtDraftSheet' import type { MTripSheetsProps } from '../MTripShell' @@ -109,6 +110,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) { + {/* Before the draft sheet, not after: both sit at the same z, so the one mounted later paints on top, and taking a hit onto the trip opens the draft OVER the search it came from. */} diff --git a/client/tests/helpers/mobileTrip.ts b/client/tests/helpers/mobileTrip.ts index 9ede7f51e..37eb71438 100644 --- a/client/tests/helpers/mobileTrip.ts +++ b/client/tests/helpers/mobileTrip.ts @@ -232,6 +232,14 @@ export function buildPlanner(overrides: Partial = {}): TripPlanner totalDistance: 0, totalDuration: 0, totalStops: 0, loading: false, }, + // Following an imported track, closed. The sheets mount the track sheet on every + // render, so its closed shape belongs in the base. + followTrack: { + dayId: null, open: vi.fn(), close: vi.fn(), tracks: [], busy: false, round: 0, error: null, + outcome: null, apply: vi.fn(async () => {}), clear: vi.fn(async () => {}), viaCount: 0, + current: null, namesByDay: {}, available: false, + }, + // The corridor search, idle. Both the search bar over the stage and the search // sheet read it on every render of the road trip tab, addon on or off, so the // empty-but-valid shape belongs here rather than in one suite's overrides. diff --git a/client/tests/unit/mobile/trip/MRtTrackSheet.test.tsx b/client/tests/unit/mobile/trip/MRtTrackSheet.test.tsx new file mode 100644 index 000000000..56c570352 --- /dev/null +++ b/client/tests/unit/mobile/trip/MRtTrackSheet.test.tsx @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FollowTrack } from '../../../../src/components/Roadtrip/useFollowTrack'; +import { _pinSizeClass, type SizeClass } from '../../../../src/hooks/sizeClass'; +import type { MTripShellApi, TripPlanner } from '../../../../src/mobile/screens/trip/MTripShell'; +import MRtTrackSheet, { MRtTrackEntry } from '../../../../src/mobile/screens/trip/roadtrip/MRtTrackSheet'; +import { buildPlanner, buildShell } from '../../../helpers/mobileTrip'; +import { fireEvent, render, screen } from '../../../helpers/render'; +import { resetAllStores } from '../../../helpers/store'; + +// FE-MOB-RTTRACK-001 to FE-MOB-RTTRACK-010 +// +// The track a day follows, as a sheet on an iPad or an unfolded Duo, over the +// planner's followTrack (the desktop's RoadtripTrackModal runs on the same hook). + +const COAST = { id: 11, name: 'Coast road', color: '#3366ff', lengthKm: 120, gapKm: 0.5 }; +const PASS = { id: 12, name: '', color: '', lengthKm: 48, gapKm: 30 }; + +function follow(overrides: Partial = {}): FollowTrack { + return { + dayId: 5, + open: vi.fn(), + close: vi.fn(), + tracks: [COAST, PASS], + busy: false, + round: 0, + error: null, + outcome: null, + apply: vi.fn(async () => {}), + clear: vi.fn(async () => {}), + viaCount: 0, + current: null, + namesByDay: {}, + available: true, + ...overrides, + } as FollowTrack; +} + +function planner(track: FollowTrack, overrides: Record = {}): TripPlanner { + return buildPlanner({ + followTrack: track, + roadtripRoutes: { ...buildPlanner().roadtripRoutes, days: [{ dayId: 5, dayNumber: 3 }] }, + ...overrides, + } as unknown as Partial); +} + +function renderSheet(track: FollowTrack, size: SizeClass = 'wide') { + _pinSizeClass(size); + const shell = buildShell({} as Partial); + render(); +} + +describe('MRtTrackSheet', () => { + beforeEach(() => { + resetAllStores(); + }); + + afterEach(() => { + _pinSizeClass(null); + }); + + it('FE-MOB-RTTRACK-001: opens for the day the planner names, on a large screen', () => { + renderSheet(follow()); + expect(screen.getByRole('dialog', { name: 'Day 3 follows a track' })).toBeInTheDocument(); + expect(screen.getByText('Coast road')).toBeInTheDocument(); + expect(screen.getByText('Unnamed track')).toBeInTheDocument(); + expect(screen.getByText(/along this day/)).toBeInTheDocument(); + }); + + it('FE-MOB-RTTRACK-002: stays closed on the phone', () => { + renderSheet(follow(), 'compact'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('FE-MOB-RTTRACK-003: stays closed while no day is open', () => { + renderSheet(follow({ dayId: null })); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('FE-MOB-RTTRACK-004: a track is picked, explained, then followed', () => { + const track = follow(); + renderSheet(track); + const action = screen.getByRole('button', { name: 'Follow this track' }); + expect(action).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: /Coast road/ })); + expect(screen.getByRole('button', { name: /Coast road/ })).toHaveAttribute('aria-pressed', 'true'); + expect(action).toBeEnabled(); + fireEvent.click(action); + expect(track.apply).toHaveBeenCalledWith(11); + }); + + it('FE-MOB-RTTRACK-005: dropping the vias is offered only when the day carries some', () => { + const track = follow({ viaCount: 4 }); + renderSheet(track); + fireEvent.click(screen.getByRole('button', { name: /Drop 4/ })); + expect(track.clear).toHaveBeenCalled(); + }); + + it('FE-MOB-RTTRACK-006: says what the day follows today, and an empty trip says so', () => { + renderSheet(follow({ current: { name: 'Coast road', strayKm: 1 }, tracks: [] })); + expect(screen.getByText('Currently following Coast road')).toBeInTheDocument(); + expect(screen.getByText('No tracks in this trip')).toBeInTheDocument(); + }); + + it('FE-MOB-RTTRACK-007: tells the round while it works', () => { + renderSheet(follow({ busy: true, round: 2 })); + expect(screen.getByText('Fitting the drive to the track, round 2')).toBeInTheDocument(); + }); + + it('FE-MOB-RTTRACK-008: a routing failure is an alert', () => { + renderSheet(follow({ error: 'route' })); + expect(screen.getByRole('alert')).toHaveTextContent('The routing service did not answer.'); + }); + + it('FE-MOB-RTTRACK-009: a fit that placed nothing says the drive already followed the track', () => { + renderSheet(follow({ outcome: { vias: 0, strayKm: 0, capped: false } })); + expect(screen.getByText('The drive already followed this track.')).toBeInTheDocument(); + }); +}); + +describe('MRtTrackEntry', () => { + afterEach(() => { + _pinSizeClass(null); + }); + + it('FE-MOB-RTTRACK-010: offers the sheet on a large screen to an editor of a trip with tracks only', () => { + const track = follow({ namesByDay: { 5: 'Coast road' } }); + _pinSizeClass('split'); + const { unmount } = render(); + fireEvent.click(screen.getByRole('button', { name: 'Currently following Coast road' })); + expect(track.open).toHaveBeenCalledWith(5); + unmount(); + + const hidden: [SizeClass, TripPlanner][] = [ + ['wide', planner(follow({ available: false }))], + ['compact', planner(follow())], + ['wide', planner(follow(), { can: vi.fn(() => false) })], + ]; + for (const [size, p] of hidden) { + _pinSizeClass(size); + const view = render(); + expect(view.container).toBeEmptyDOMElement(); + view.unmount(); + } + }); +});