mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
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.
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<MRtTrackEntry planner={planner} dayId={stage.dayId} />
|
||||
</section>
|
||||
|
||||
{rt.upNext && <UpNext planner={planner} shell={shell} rt={rt} stageDayId={stage.dayId} onOpen={openStop} />}
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => follow.open(dayId)}
|
||||
className="mt-2.5 flex min-h-10 w-full items-center gap-2 rounded-[13px] bg-[color:var(--m-ic)] px-3 py-2 text-start"
|
||||
>
|
||||
<Spline size={15} strokeWidth={1.9} className="flex-none text-m-muted" aria-hidden />
|
||||
<span dir="auto" className="min-w-0 flex-1 truncate text-caption font-semibold text-m-ink">
|
||||
{name ? t('roadtrip.track.current', { name }) : t('roadtrip.track.hint')}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<MSheet
|
||||
open={open}
|
||||
onClose={follow.close}
|
||||
variant="bottom"
|
||||
material="glass"
|
||||
ariaLabel={t('roadtrip.track.title', { number: dayNumber })}
|
||||
>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<Spline size={19} strokeWidth={1.8} />}
|
||||
title={<span className="truncate">{t('roadtrip.track.title', { number: dayNumber })}</span>}
|
||||
sub={current ? t('roadtrip.track.current', { name: current.name }) : undefined}
|
||||
subWrap
|
||||
onClose={follow.close}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-3 pt-[14px]">
|
||||
{tracks.length === 0 ? (
|
||||
<div className="rounded-[16px] bg-[color:var(--m-ic)] px-4 py-5 text-center">
|
||||
<div className="text-body font-semibold text-m-ink">{t('roadtrip.track.empty')}</div>
|
||||
<p className={`mt-1 ${NOTE} text-m-faint`}>{t('roadtrip.track.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul
|
||||
className="m-0 flex list-none flex-col gap-1 rounded-[16px] bg-[color:var(--m-ic)] p-1.5"
|
||||
data-testid="rt-track-list"
|
||||
>
|
||||
{tracks.map((track) => {
|
||||
const on = track.id === picked;
|
||||
return (
|
||||
<li key={track.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
aria-pressed={on}
|
||||
onClick={() => setPick(on || dayId === null ? null : { dayId, trackId: track.id })}
|
||||
className={`flex min-h-12 w-full items-center gap-3 rounded-[12px] px-3 py-2 text-start disabled:opacity-50 ${
|
||||
on ? 'bg-[color:var(--m-card)] ring-1 ring-[color:var(--m-cbr)]' : ''
|
||||
}`}
|
||||
>
|
||||
{/* The line's own colour as a short stroke: it is a line on the map. */}
|
||||
<span
|
||||
className="h-1 w-6 flex-none rounded-full"
|
||||
style={{ background: track.color || 'var(--m-faint)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span dir="auto" className="truncate text-body font-semibold text-m-ink">
|
||||
{track.name || t('roadtrip.track.untitled')}
|
||||
</span>
|
||||
<span className={`${NOTE} text-m-faint`}>
|
||||
{t('roadtrip.track.length', { distance: formatDistance(track.lengthKm, unit) })}
|
||||
{' · '}
|
||||
{track.gapKm <= ON_THIS_DAY_KM
|
||||
? t('roadtrip.track.onDay')
|
||||
: t('roadtrip.track.gap', { distance: formatDistance(track.gapKm, unit) })}
|
||||
</span>
|
||||
</span>
|
||||
{on && <Check size={16} className="flex-none text-m-ink" aria-hidden />}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* What the pick will do, before it is done: a track becomes via points on the day. */}
|
||||
{chosen && <p className={`mt-2.5 ${NOTE} text-m-muted`}>{t('roadtrip.track.explain')}</p>}
|
||||
|
||||
{busy && (
|
||||
<p role="status" className={`mt-2.5 flex items-center gap-2 ${NOTE} text-m-muted`}>
|
||||
<RotateCw size={13} className="flex-none animate-spin" aria-hidden />
|
||||
{t('roadtrip.track.working', { round })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className={`mt-2.5 flex items-start gap-1.5 ${NOTE} text-danger`}>
|
||||
<AlertTriangle size={13} className="mt-0.5 flex-none" aria-hidden />
|
||||
{t(error === 'route' ? 'roadtrip.track.errorRoute' : 'roadtrip.track.errorSave')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{outcome && !busy && !error && (
|
||||
<p role="status" className={`mt-2.5 flex items-start gap-1.5 ${NOTE} text-m-muted`}>
|
||||
<Check size={13} className="mt-0.5 flex-none" aria-hidden />
|
||||
{t(outcomeKey(outcome), { count: outcome.vias, distance: formatDistance(outcome.strayKm, unit) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-none flex-wrap items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
{/* Whenever the day carries vias, whoever put them there: back to the router's way. */}
|
||||
{viaCount > 0 && (
|
||||
<button type="button" disabled={busy} onClick={() => void follow.clear()} className={SHEET_SECONDARY}>
|
||||
{t('roadtrip.track.clear', { count: viaCount })}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!chosen || busy}
|
||||
onClick={() => {
|
||||
if (chosen) void follow.apply(chosen.id);
|
||||
}}
|
||||
className={`ms-auto ${SHEET_PRIMARY}`}
|
||||
>
|
||||
{t('roadtrip.track.action')}
|
||||
</button>
|
||||
</div>
|
||||
</MSheet>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
<MRtStaySheet planner={planner} shell={shell} />
|
||||
<MRtKindSheet planner={planner} shell={shell} />
|
||||
<MRtInfoSheet planner={planner} shell={shell} />
|
||||
<MRtTrackSheet planner={planner} shell={shell} />
|
||||
{/* 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. */}
|
||||
|
||||
@@ -232,6 +232,14 @@ export function buildPlanner(overrides: Partial<TripPlanner> = {}): 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.
|
||||
|
||||
@@ -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> = {}): 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<string, unknown> = {}): TripPlanner {
|
||||
return buildPlanner({
|
||||
followTrack: track,
|
||||
roadtripRoutes: { ...buildPlanner().roadtripRoutes, days: [{ dayId: 5, dayNumber: 3 }] },
|
||||
...overrides,
|
||||
} as unknown as Partial<TripPlanner>);
|
||||
}
|
||||
|
||||
function renderSheet(track: FollowTrack, size: SizeClass = 'wide') {
|
||||
_pinSizeClass(size);
|
||||
const shell = buildShell({} as Partial<MTripShellApi>);
|
||||
render(<MRtTrackSheet planner={planner(track)} shell={shell} />);
|
||||
}
|
||||
|
||||
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(<MRtTrackEntry planner={planner(track)} dayId={5} />);
|
||||
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(<MRtTrackEntry planner={p} dayId={5} />);
|
||||
expect(view.container).toBeEmptyDOMElement();
|
||||
view.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user