refactor(tours): bring the tour time parts, collection rows and flyover back under the lint baseline

Move workedOutEndHint and totalText into tourDayTimeHint.ts so TourDayTimeParts only exports components, render the collection row's type icon without a component made during render, and pass the chase camera's lens and scene as one view object.
This commit is contained in:
Maurice
2026-10-20 21:11:43 +02:00
parent dc19807126
commit 93e1156aa4
5 changed files with 56 additions and 41 deletions
@@ -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(<TourOverrunLine overrun={{ name: '', time: '12:00', end: '13:00' }} />);
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'
);
});
});
@@ -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, unknown>) => string;
/** A total as said beside a worked-out end: `≈` for an estimate, `≥` where a stay is not known yet. */
function totalText(time: Pick<TourDayTime, 'totalMinutes' | 'estimated' | 'atLeast'>): 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.
@@ -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({
<div className="li">
<div className="t">{tour.name}</div>
<div className="s">
<TypeIcon size={11} />
{createElement(tourDayRowIcon(tour.tour_type), { size: 11 })}
<span>{t(`tourTypes.${tour.tour_type}`)}</span>
<Tooltip label={sourceText(tour, t)}>
<span className="col-lrow-from">
@@ -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<number>,
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) {
@@ -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, unknown>) => 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<TourDayTime, 'totalMinutes' | 'estimated' | 'atLeast'>): 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 });
}