diff --git a/client/src/components/Tours/TourDayTimeParts.test.tsx b/client/src/components/Tours/TourDayTimeParts.test.tsx index c21dac725..260da2868 100644 --- a/client/src/components/Tours/TourDayTimeParts.test.tsx +++ b/client/src/components/Tours/TourDayTimeParts.test.tsx @@ -4,7 +4,8 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { render, screen } from '../../../tests/helpers/render'; import { seedStore } from '../../../tests/helpers/store'; import { useSettingsStore } from '../../store/settingsStore'; -import { TourDayTimeText, TourEndHint, TourOverrunLine, workedOutEndHint } from './TourDayTimeParts'; +import { TourDayTimeText, TourEndHint, TourOverrunLine } from './TourDayTimeParts'; +import { workedOutEndHint } from './tourDayTimeHint'; const tour: TourListItem = { place_id: 7, @@ -107,7 +108,9 @@ describe('TourOverrunLine', () => { it('TIME-02-014b: an item without a name is called the next item', () => { render(); - expect(screen.getByTestId('tour-day-overrun')).toHaveTextContent('Ends at 13:00, after the next item starts at 12:00'); + expect(screen.getByTestId('tour-day-overrun')).toHaveTextContent( + 'Ends at 13:00, after the next item starts at 12:00' + ); }); }); diff --git a/client/src/components/Tours/TourDayTimeParts.tsx b/client/src/components/Tours/TourDayTimeParts.tsx index f9829eda5..6798135c6 100644 --- a/client/src/components/Tours/TourDayTimeParts.tsx +++ b/client/src/components/Tours/TourDayTimeParts.tsx @@ -7,22 +7,13 @@ import { formatTime } from '../../utils/formatters'; import { fs } from '../shared/DialogShell'; import { Tooltip } from '../shared/Tooltip'; import { tourDayTimeOf, type TourDayEntry, type TourDayTime, type TourOverrun } from './tourDayTime'; -import { formatPlannedTourDuration, tourDayMinutes, tourPlannedTimes } from './tourPresentation'; +import { totalText, workedOutEndHint, type Translate } from './tourDayTimeHint'; /** * TIME-02: how the day plan says a tour's time on a day, on the desktop and * the phone alike; each only places it in its own badge or chip. */ -type Translate = (key: string, params?: Record) => string; - -/** A total as said beside a worked-out end: `≈` for an estimate, `≥` where a stay is not known yet. */ -function totalText(time: Pick): string { - const total = formatPlannedTourDuration(time.totalMinutes ?? 0); - if (time.estimated) return `≈ ${total}`; - return time.atLeast ? `≥ ${total}` : total; -} - /** The words a worked-out end is explained by: when it ends and the total it follows from. */ function workedOutLabel(time: TourDayTime, t: Translate, clock: (value: string) => string): string { const params = { time: clock(time.end ?? ''), total: totalText(time) }; @@ -97,29 +88,6 @@ export function TourOverrunLine({ overrun }: { overrun: TourOverrun | null | und ); } -/** - * TIME-02: what the time fields of a tour's day entry say below them, while - * no end is typed: the end the start and the tour's time work out to, or, - * before a start is picked, that one will. Nothing where the tour has no - * known time or the day holds one stage of a multi-day tour. - */ -export function workedOutEndHint( - entry: TourDayEntry, - tour: TourListItem | null | undefined, - t: Translate, - clock: (value: string) => string -): string | null { - if (!tour || entry.stageKey || (entry.end ?? '') !== '') return null; - const time = tourDayTimeOf(entry, tour); - if (time?.workedOut && time.end) { - return t('tours.dayTime.formHint', { time: clock(time.end), total: totalText(time) }); - } - const total = tourDayMinutes(tourPlannedTimes(tour)); - if (time || !total) return null; - const said = totalText({ totalMinutes: total.minutes, estimated: total.estimated, atLeast: total.atLeast }); - return t('tours.dayTime.formNoStart', { total: said }); -} - /** * TIME-02: the hint below the time fields of a tour's day entry, on the * desktop form and the phone sheet alike, read while the fields are typed in. diff --git a/client/src/components/Tours/collection/CollectionToursSection.tsx b/client/src/components/Tours/collection/CollectionToursSection.tsx index 7c5be10d5..e6b30484a 100644 --- a/client/src/components/Tours/collection/CollectionToursSection.tsx +++ b/client/src/components/Tours/collection/CollectionToursSection.tsx @@ -1,6 +1,6 @@ import type { CollectionTourItem } from '@trek/shared'; import { AlertTriangle, CopyPlus, Gauge, Moon, Route, Trash2, Waypoints, WifiOff } from 'lucide-react'; -import type { MouseEvent } from 'react'; +import { createElement, type MouseEvent } from 'react'; import { useTranslation } from '../../../i18n'; import { useSettingsStore } from '../../../store/settingsStore'; import { WhiteBadge } from '../../Planner/planParts'; @@ -68,7 +68,6 @@ function KeptTourRow({ }) { const { t } = useTranslation(); const tile = { ...tour, has_waypoints: tour.waypoint_count > 0 }; - const TypeIcon = tourDayRowIcon(tour.tour_type); const points = t('tours.collection.points', { count: tour.waypoint_count }); const stop = (act: () => void) => (event: MouseEvent) => { event.stopPropagation(); @@ -94,7 +93,7 @@ function KeptTourRow({
{tour.name}
- + {createElement(tourDayRowIcon(tour.tour_type), { size: 11 })} {t(`tourTypes.${tour.tour_type}`)} diff --git a/client/src/components/Tours/terrain/useTerrainReplay.ts b/client/src/components/Tours/terrain/useTerrainReplay.ts index 609741b17..76f398223 100644 --- a/client/src/components/Tours/terrain/useTerrainReplay.ts +++ b/client/src/components/Tours/terrain/useTerrainReplay.ts @@ -70,14 +70,19 @@ function followGestures(map: MapLibreMap): () => void { }; } +/** How the chase camera frames the route: the lens the user picked and the scene it looks into. */ +interface ChaseView { + lens: ChaseLens; + scene: TerrainScene; +} + /** Moves the chase camera to `distance` along the route and returns where that is, if anywhere. */ function chase( map: MapLibreMap, samples: DistanceIndexedProfileSample[], distance: number, bearing: RefObject, - lens: ChaseLens, - scene: TerrainScene + { lens, scene }: ChaseView ): RouteProfileFocus | null { const here = focusRouteProfileAtDistance(samples, distance); if (!here) return null; @@ -155,7 +160,10 @@ export function useTerrainReplay({ gl, ready, samples, focus, onFocusChange, sce totalMeters, distanceRef.current + replayMetresPerSecond(totalMeters) * speed * dt ); - const here = chase(map, samples, distanceRef.current, bearingRef, lensRef.current, scene.current); + const here = chase(map, samples, distanceRef.current, bearingRef, { + lens: lensRef.current, + scene: scene.current, + }); if (here) { riderRef.current?.setLngLat([here.lng, here.lat]).addTo(map); if (now - lastFocus >= FOCUS_INTERVAL_MS || distanceRef.current >= totalMeters) { diff --git a/client/src/components/Tours/tourDayTimeHint.ts b/client/src/components/Tours/tourDayTimeHint.ts new file mode 100644 index 000000000..dd3bd2108 --- /dev/null +++ b/client/src/components/Tours/tourDayTimeHint.ts @@ -0,0 +1,37 @@ +import type { TourListItem } from '@trek/shared'; +import { tourDayTimeOf, type TourDayEntry, type TourDayTime } from './tourDayTime'; +import { formatPlannedTourDuration, tourDayMinutes, tourPlannedTimes } from './tourPresentation'; + +/** TIME-02: the words the day plan says a tour's worked-out time in, apart from the components showing them. */ + +export type Translate = (key: string, params?: Record) => string; + +/** A total as said beside a worked-out end: `≈` for an estimate, `≥` where a stay is not known yet. */ +export function totalText(time: Pick): string { + const total = formatPlannedTourDuration(time.totalMinutes ?? 0); + if (time.estimated) return `≈ ${total}`; + return time.atLeast ? `≥ ${total}` : total; +} + +/** + * TIME-02: what the time fields of a tour's day entry say below them, while + * no end is typed: the end the start and the tour's time work out to, or, + * before a start is picked, that one will. Nothing where the tour has no + * known time or the day holds one stage of a multi-day tour. + */ +export function workedOutEndHint( + entry: TourDayEntry, + tour: TourListItem | null | undefined, + t: Translate, + clock: (value: string) => string +): string | null { + if (!tour || entry.stageKey || (entry.end ?? '') !== '') return null; + const time = tourDayTimeOf(entry, tour); + if (time?.workedOut && time.end) { + return t('tours.dayTime.formHint', { time: clock(time.end), total: totalText(time) }); + } + const total = tourDayMinutes(tourPlannedTimes(tour)); + if (time || !total) return null; + const said = totalText({ totalMinutes: total.minutes, estimated: total.estimated, atLeast: total.atLeast }); + return t('tours.dayTime.formNoStart', { total: said }); +}