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