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