mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
feat(tours): give a tour its start and end in the day plan
A tour on a day had no place in the day's timeline: its time was not tied to the slot it was planned in, so several hikes on one day could not be lined up. A tour now sits there the way a place does, on the times of its day entry, one model for every tour type. With a start and a known time (the planned total, else the estimate of an import) its end follows as the start plus that time; it is shown fainter, marked with an approximate sign where it comes from an estimate, and says in its tooltip how it was worked out. An end typed by hand wins. A day that holds one stage of a multi-day tour gets no end from the whole route. When the end runs past the start of the day's next timed item, the row says so in one quiet line, as a late connection between two city walks does; nothing is moved. The existing time fields of the day entry, on the desktop form and the phone sheet, say what the end works out to while none is typed. City walks keep their arrivals per station. The logic lives in one module both day plans run on.
This commit is contained in:
@@ -9,6 +9,7 @@ import { fs } from '../shared/DialogShell';
|
||||
import { stageNumberOf } from '../Tours/stages/stageNames';
|
||||
import { TourStageDayPills } from '../Tours/stages/TourStageDayPills';
|
||||
import { TourDayRowPills } from '../Tours/TourDayRowFacts';
|
||||
import { TourDayTimeText } from '../Tours/TourDayTimeParts';
|
||||
import { movingTimeLabel } from '../Tours/tourFacts';
|
||||
import { formatPlannedTourDuration, plannedTotalText, type TourPlannedTimes } from '../Tours/tourPresentation';
|
||||
import { WalkProgressPill } from '../Tours/walk/WalkStations';
|
||||
@@ -120,7 +121,16 @@ export function DayPlanPlaceSubline({
|
||||
// One line like the row's name: when the hover controls come out, the description
|
||||
// gives way and the pills keep their place instead of wrapping onto a second line.
|
||||
<div className="trek-fade-end mt-0.5 flex min-w-0 flex-nowrap items-center gap-1.5 overflow-hidden">
|
||||
{place.place_time && (
|
||||
{place.place_time && dayRowTour && (
|
||||
// TIME-02: a tour's end, typed or worked out from its start and its time.
|
||||
<TimePill quiet>
|
||||
<TourDayTimeText
|
||||
entry={{ start: place.place_time, end: place.end_time, stageKey: tourStageKey }}
|
||||
tour={dayRowTour}
|
||||
/>
|
||||
</TimePill>
|
||||
)}
|
||||
{place.place_time && !dayRowTour && (
|
||||
<TimePill quiet>
|
||||
{formatTime(place.place_time, locale, timeFormat)}
|
||||
{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
|
||||
|
||||
@@ -394,7 +394,12 @@ function walkTour(place: { id: number; name: string }, overrides: Partial<TourLi
|
||||
} as TourListItem;
|
||||
}
|
||||
|
||||
function walkDayProps(extra: { reservations?: Reservation[]; withCafe?: boolean; onPlanTransitLeg?: () => void }) {
|
||||
function walkDayProps(extra: {
|
||||
reservations?: Reservation[];
|
||||
withCafe?: boolean;
|
||||
onPlanTransitLeg?: () => void;
|
||||
firstWalk?: Partial<TourListItem>;
|
||||
}) {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' });
|
||||
const assignments = {
|
||||
'10': [
|
||||
@@ -415,7 +420,7 @@ function walkDayProps(extra: { reservations?: Reservation[]; withCafe?: boolean;
|
||||
...(extra.withCafe ? [buildAssignment({ id: 203, day_id: 10, order_index: 2, place: cafe })] : []),
|
||||
],
|
||||
};
|
||||
const tours = [walkTour(oldTown), walkTour(harbour)];
|
||||
const tours = [walkTour(oldTown, extra.firstWalk), walkTour(harbour)];
|
||||
return makeDefaultProps({
|
||||
days: [day],
|
||||
places: [oldTown, harbour, cafe],
|
||||
@@ -559,4 +564,20 @@ describe('DayPlanSidebar, public transit between two city walks (CW-08)', () =>
|
||||
expect(screen.getByTestId('transit-walk-late')).toHaveTextContent('Arrives after Harbour starts at 13:00');
|
||||
expect(onPlanTransitLeg).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TIME-02-017: a tour with a start shows its end worked out from its time, and nothing runs over', () => {
|
||||
render(<DayPlanSidebar {...walkDayProps({})} />);
|
||||
|
||||
const ends = screen.getAllByTestId('tour-day-end-worked-out');
|
||||
expect(ends[0]).toHaveTextContent('12:15');
|
||||
expect(ends[0]).toHaveAccessibleName('Ends at 12:15, worked out from the start and the tour time of 2 h 15 min');
|
||||
expect(screen.queryByTestId('tour-day-overrun')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('TIME-02-018: an end that runs past the start of the next timed item is said under the tour, nothing moved', () => {
|
||||
render(<DayPlanSidebar {...walkDayProps({ firstWalk: { planned_duration_minutes: 240 } })} />);
|
||||
|
||||
expect(screen.getAllByTestId('tour-day-end-worked-out')[0]).toHaveTextContent('14:00');
|
||||
expect(screen.getByTestId('tour-day-overrun')).toHaveTextContent('Ends at 14:00, after Harbour starts at 13:00');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ import { ChevronDown, ChevronRight, ChevronUp, Compass, RotateCcw, ExternalLink,
|
||||
import type { PlannedTransitLeg } from './transitLeg'
|
||||
import { useTransitLegPlanner } from './useTransitLegPlanner'
|
||||
import { WalkLateLine } from '../Tours/walk/WalkTransitNote'
|
||||
import { TourOverrunLine } from '../Tours/TourDayTimeParts'
|
||||
import { dayItemOverrun } from '../Tours/tourDayTime'
|
||||
import { optimizeRoute, generateGoogleMapsUrl, generateCoMapsUrl } from '../Map/RouteCalculator'
|
||||
import { dayViaLookup, type ViasByDay } from '../../utils/dayRouteVias'
|
||||
import { routeLeg, type LegEnd } from '../Map/dayRouteLeg'
|
||||
@@ -1414,8 +1416,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
|
||||
// The extra connector-menu entry (#1281 follow-up): public transit for this leg while a handler is wired (day has
|
||||
// dates); between two city walks from the end of the one it leaves, `originId`, as two can end at one spot (CW-08).
|
||||
const tourOfPlace = (placeId: number) => props.plannedTourDurations?.get(placeId)?.tour
|
||||
const transitLegs = useTransitLegPlanner({ tripId, assignments, accommodations, reservations, places, toursEnabled,
|
||||
onPlan: onPlanTransitLeg, tourOf: placeId => props.plannedTourDurations?.get(placeId)?.tour })
|
||||
onPlan: onPlanTransitLeg, tourOf: tourOfPlace })
|
||||
const transitLegPlanner = (dayId: number, seg?: RouteSegment, originId?: number) => {
|
||||
const leg = transitLegs.legFor(dayId, seg)
|
||||
return leg ? () => transitLegs.plan(dayId, leg, originId) : undefined
|
||||
@@ -2034,6 +2037,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
)}
|
||||
</div>
|
||||
<DayPlanPlaceSubline place={place} cat={cat} tourTimes={tourTimes} isTourPlace={!!isTourPlace} timeFormat={timeFormat} assignmentId={assignment.id} tourStageKey={assignment.tour_stage_key} />
|
||||
{/* TIME-02: the tour's end runs past the start of the day's next timed item. */}
|
||||
{dayRowTour && <TourOverrunLine overrun={dayItemOverrun(merged, idx, day.id, tourOfPlace)} />}
|
||||
{assignment.notes && (
|
||||
// Day-specific note on this stop (#2163) — one muted
|
||||
// caption line so the timeline shows the note exists
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback, useId, type ReactNode } from 'react'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
import type { RoadtripStopType, TourListItem } from '@trek/shared'
|
||||
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import { EditorField, GRID_2, INPUT, LABEL, PANEL, PillSelect, TEXTAREA } from '../shared/dialogParts'
|
||||
import { SoftPill, tintOf } from './planParts'
|
||||
import { WHITE_BUTTON } from './placeDialogParts'
|
||||
import { TourEndHint } from '../Tours/TourDayTimeParts'
|
||||
|
||||
// The submit payload mirrors the form, but lat/lng are parsed to numbers and
|
||||
// category_id is normalised, plus any files chosen before the place existed.
|
||||
@@ -85,6 +86,8 @@ interface PlaceFormModalProps {
|
||||
serviceStop?: ServiceStopMode | null
|
||||
/** Road trip mode is on, where a visit's End is when the drive leaves it. */
|
||||
roadtripActive?: boolean
|
||||
/** TIME-02: the tour this place is, whose end the time fields work out from its start; absent for a plain place. */
|
||||
tour?: TourListItem | null
|
||||
}
|
||||
|
||||
|
||||
@@ -843,6 +846,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
dayAssignments={S.dayAssignments}
|
||||
hasTimeError={hasTimeError}
|
||||
endIsLeave={!!props.roadtripActive}
|
||||
tour={props.tour}
|
||||
t={t}
|
||||
/>
|
||||
<EditorField label={t('places.assignmentNotes')} htmlFor={`${fieldId}-day-notes`}>
|
||||
@@ -1166,10 +1170,11 @@ interface TimeSectionProps {
|
||||
/** On a road trip the End is when the drive leaves, which the field says. In Days it
|
||||
* stays the plain label it has always been: nothing is scheduled off it there. */
|
||||
endIsLeave: boolean
|
||||
tour?: TourListItem | null
|
||||
t: Translate
|
||||
}
|
||||
|
||||
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, endIsLeave, t }: TimeSectionProps) {
|
||||
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, endIsLeave, tour, t }: TimeSectionProps) {
|
||||
const collisions = useMemo(
|
||||
() => timeCollisions(assignmentId, dayAssignments, form.place_time, form.end_time),
|
||||
[assignmentId, dayAssignments, form.place_time, form.end_time],
|
||||
@@ -1196,6 +1201,7 @@ function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTime
|
||||
/>
|
||||
</EditorField>
|
||||
</div>
|
||||
<TourEndHint start={form.place_time} end={form.end_time} assignment={dayAssignments.find(a => a.id === assignmentId)} tour={tour} />
|
||||
{hasTimeError && (
|
||||
<div id={errorId} className={WARNING_BANNER} style={fs(12, 'body')}>
|
||||
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// TIME-02-011 to TIME-02-016: how the day plan says a tour's time, its overrun and the hint below its time fields.
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
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';
|
||||
|
||||
const tour: TourListItem = {
|
||||
place_id: 7,
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
distance: 8,
|
||||
elevation_gain: 300,
|
||||
elevation_loss: 300,
|
||||
duration: 150,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: 30,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: true,
|
||||
caution: false,
|
||||
};
|
||||
const imported: TourListItem = {
|
||||
...tour,
|
||||
distance: 12,
|
||||
elevation_loss: 500,
|
||||
duration: null,
|
||||
break_additional_minutes: null,
|
||||
};
|
||||
const t = (key: string, params?: Record<string, unknown>) => `${key} ${JSON.stringify(params ?? {})}`;
|
||||
const clock = (value: string) => value;
|
||||
|
||||
beforeEach(() => {
|
||||
seedStore(useSettingsStore, { settings: { time_format: '24h' } });
|
||||
});
|
||||
|
||||
describe('TourDayTimeText', () => {
|
||||
it('TIME-02-011: a worked-out end is set apart and says how it was worked out', () => {
|
||||
render(<TourDayTimeText entry={{ start: '09:00', end: null }} tour={tour} />);
|
||||
const end = screen.getByTestId('tour-day-end-worked-out');
|
||||
expect(end).toHaveTextContent('12:00');
|
||||
expect(end).toHaveClass('text-content-faint');
|
||||
expect(end).toHaveAccessibleName('Ends at 12:00, worked out from the start and the tour time of 3 h');
|
||||
});
|
||||
|
||||
it('TIME-02-012: an estimate is marked with ≈, a phone row uses its own tone, and past midnight says +1', () => {
|
||||
const { unmount } = render(<TourDayTimeText entry={{ start: '09:00', end: null }} tour={imported} mobile />);
|
||||
expect(screen.getByTestId('tour-day-end-worked-out')).toHaveTextContent('≈ 13:00');
|
||||
expect(screen.getByTestId('tour-day-end-worked-out')).toHaveClass('text-m-faint');
|
||||
unmount();
|
||||
render(<TourDayTimeText entry={{ start: '22:30', end: null }} tour={tour} />);
|
||||
expect(screen.getByTestId('tour-day-end-worked-out')).toHaveTextContent('01:30 +1');
|
||||
});
|
||||
|
||||
it('TIME-02-013: an end typed by hand shows as typed, and without an end only the start shows', () => {
|
||||
const { container, unmount } = render(<TourDayTimeText entry={{ start: '09:00', end: '15:00' }} tour={tour} />);
|
||||
expect(container).toHaveTextContent('09:00 → 15:00');
|
||||
expect(screen.queryByTestId('tour-day-end-worked-out')).not.toBeInTheDocument();
|
||||
unmount();
|
||||
const unknown = { ...imported, elevation_gain: null, elevation_loss: null };
|
||||
const plain = render(<TourDayTimeText entry={{ start: '09:00', end: null }} tour={unknown} />);
|
||||
expect(plain.container.textContent).toBe('09:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TourOverrunLine', () => {
|
||||
it('TIME-02-014: names the item the end runs into, and says nothing without one', () => {
|
||||
const { container, unmount } = render(<TourOverrunLine overrun={null} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
unmount();
|
||||
render(<TourOverrunLine overrun={{ name: 'Lunch', time: '12:00', end: '13:00' }} />);
|
||||
expect(screen.getByTestId('tour-day-overrun')).toHaveTextContent('Ends at 13:00, after Lunch starts at 12:00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workedOutEndHint', () => {
|
||||
it('TIME-02-015: says the worked-out end while none is typed, and that a start will set one', () => {
|
||||
expect(workedOutEndHint({ start: '09:00', end: '' }, imported, t, clock)).toBe(
|
||||
'tours.dayTime.formHint {"time":"13:00","total":"≈ 4 h"}'
|
||||
);
|
||||
expect(workedOutEndHint({ start: '', end: '' }, tour, t, clock)).toBe('tours.dayTime.formNoStart {"total":"3 h"}');
|
||||
});
|
||||
|
||||
it('TIME-02-016: says nothing for a typed end, a stage day, a plain place or a tour without a known time', () => {
|
||||
expect(workedOutEndHint({ start: '09:00', end: '15:00' }, tour, t, clock)).toBeNull();
|
||||
expect(workedOutEndHint({ start: '09:00', end: '', stageKey: 'stop-2' }, tour, t, clock)).toBeNull();
|
||||
expect(workedOutEndHint({ start: '09:00', end: '' }, null, t, clock)).toBeNull();
|
||||
const unknown = { ...imported, elevation_gain: null, elevation_loss: null };
|
||||
expect(workedOutEndHint({ start: '09:00', end: '' }, unknown, t, clock)).toBeNull();
|
||||
expect(workedOutEndHint({ start: '', end: '' }, unknown, t, clock)).toBeNull();
|
||||
render(<TourEndHint start="09:00" end="" assignment={{ tour_stage_key: null }} tour={tour} />);
|
||||
expect(screen.getByTestId('tour-day-end-hint')).toHaveTextContent('12:00');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { Assignment } from '../../types';
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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) };
|
||||
return t(time.nextDay ? 'tours.dayTime.workedOutNextDay' : 'tours.dayTime.workedOut', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* TIME-02: a tour's time on its day as the row shows it: the start, then the
|
||||
* end. An end worked out from the start and the total is fainter, marked `≈`
|
||||
* for an estimate or `≥` where a stay is not known yet, `+1` past midnight,
|
||||
* and says in its tooltip how it was worked out. An end typed by hand shows
|
||||
* as typed.
|
||||
*/
|
||||
export function TourDayTimeText({
|
||||
entry,
|
||||
tour,
|
||||
mobile = false,
|
||||
}: {
|
||||
/** The day entry's times; `tourDayEntryOf` reads them off an assignment. */
|
||||
entry: TourDayEntry;
|
||||
tour: TourListItem;
|
||||
mobile?: boolean;
|
||||
}) {
|
||||
const { t, locale } = useTranslation();
|
||||
const timeFormat = useSettingsStore((state) => state.settings.time_format) ?? '24h';
|
||||
const time = tourDayTimeOf(entry, tour);
|
||||
if (!time) return null;
|
||||
const clock = (value: string) => formatTime(value, locale, timeFormat);
|
||||
if (!time.end) return <>{clock(time.start)}</>;
|
||||
if (!time.workedOut) return <>{`${clock(time.start)} → ${clock(time.end)}`}</>;
|
||||
const label = workedOutLabel(time, t, clock);
|
||||
const mark = time.estimated ? '≈ ' : time.atLeast ? '≥ ' : '';
|
||||
return (
|
||||
<>
|
||||
{`${clock(time.start)} → `}
|
||||
<Tooltip label={label}>
|
||||
<span
|
||||
data-testid="tour-day-end-worked-out"
|
||||
aria-label={label}
|
||||
className={mobile ? 'text-m-faint' : 'text-content-faint'}
|
||||
>
|
||||
{`${mark}${clock(time.end)}${time.nextDay ? ' +1' : ''}`}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* TIME-02: a tour whose end runs past the start of the next timed item of
|
||||
* its day, said on one line that wraps, as a connection that arrives late is
|
||||
* (CW-08). Nothing is moved or changed.
|
||||
*/
|
||||
export function TourOverrunLine({ overrun }: { overrun: TourOverrun | null | undefined }) {
|
||||
const { t, locale } = useTranslation();
|
||||
const timeFormat = useSettingsStore((state) => state.settings.time_format) ?? '24h';
|
||||
if (!overrun) return null;
|
||||
const clock = (value: string) => formatTime(value, locale, timeFormat);
|
||||
const said = t('tours.dayTime.overrun', {
|
||||
end: clock(overrun.end),
|
||||
name: overrun.name || t('tours.dayTime.nextItem'),
|
||||
time: clock(overrun.time),
|
||||
});
|
||||
// A clock, not a warning sign: the plan is not wrong, its times only meet.
|
||||
return (
|
||||
<p data-testid="tour-day-overrun" className="m-0 mt-1 flex items-start gap-1 text-warning" style={fs(11, 'body')}>
|
||||
<Clock size={12} strokeWidth={2.2} aria-hidden="true" className="mt-[2px] flex-none" />
|
||||
<span className="min-w-0 break-words">{said}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function TourEndHint({
|
||||
start,
|
||||
end,
|
||||
assignment,
|
||||
tour,
|
||||
mobile = false,
|
||||
}: {
|
||||
start: string;
|
||||
end: string;
|
||||
/** The day entry the times are typed for, which says the stage of a multi-day tour it holds. */
|
||||
assignment: Pick<Assignment, 'tour_stage_key'> | undefined;
|
||||
tour: TourListItem | null | undefined;
|
||||
mobile?: boolean;
|
||||
}) {
|
||||
const { t, locale } = useTranslation();
|
||||
const timeFormat = useSettingsStore((state) => state.settings.time_format) ?? '24h';
|
||||
const entry = { start, end, stageKey: assignment?.tour_stage_key ?? null };
|
||||
const hint = workedOutEndHint(entry, tour, t, (value) => formatTime(value, locale, timeFormat));
|
||||
if (!hint) return null;
|
||||
return (
|
||||
<p
|
||||
data-testid="tour-day-end-hint"
|
||||
className={`m-0 ${mobile ? 'mt-[5px] font-geist text-m-faint' : 'text-content-muted'}`}
|
||||
style={fs(11, 'body')}
|
||||
>
|
||||
{hint}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// TIME-02-001 to TIME-02-010: a tour's start and end on its day, and an end that runs into the next timed item.
|
||||
import type { TourListItem } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { MergedItem } from '../../utils/dayMerge';
|
||||
import { dayItemOverrun, tourDayEntryOf, tourDayTimeOf, tourOverrunOf } from './tourDayTime';
|
||||
|
||||
const planned: TourListItem = {
|
||||
place_id: 7,
|
||||
name: 'Ridge',
|
||||
tour_type: 'hike',
|
||||
distance: 8,
|
||||
elevation_gain: 300,
|
||||
elevation_loss: 300,
|
||||
duration: 150,
|
||||
planned_duration_minutes: null,
|
||||
break_additional_minutes: 30,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
planned: true,
|
||||
caution: false,
|
||||
};
|
||||
|
||||
// 12 km, 300 m up and 500 m down, never timed by the planner: estimated at 4 h.
|
||||
const imported: TourListItem = {
|
||||
...planned,
|
||||
distance: 12,
|
||||
elevation_loss: 500,
|
||||
duration: null,
|
||||
break_additional_minutes: null,
|
||||
max_hiking_difficulty: null,
|
||||
};
|
||||
|
||||
describe('tourDayTimeOf', () => {
|
||||
it('TIME-02-001: the end follows as the start plus the planned total, marked as worked out', () => {
|
||||
expect(tourDayTimeOf({ start: '09:00', end: null }, planned)).toEqual({
|
||||
start: '09:00',
|
||||
end: '12:00',
|
||||
endMinutes: 720,
|
||||
workedOut: true,
|
||||
estimated: false,
|
||||
atLeast: false,
|
||||
nextDay: false,
|
||||
totalMinutes: 180,
|
||||
});
|
||||
});
|
||||
|
||||
it('TIME-02-002: a total entered by hand is the total, and an import is worked out with its estimate', () => {
|
||||
const entered = { ...planned, planned_duration_minutes: 300 };
|
||||
expect(tourDayTimeOf({ start: '09:00', end: '' }, entered)?.end).toBe('14:00');
|
||||
expect(tourDayTimeOf({ start: '9:30', end: null }, imported)).toMatchObject({
|
||||
end: '13:30',
|
||||
workedOut: true,
|
||||
estimated: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('TIME-02-003: an end typed by hand wins and is no worked-out end; one before the start is on the next day', () => {
|
||||
expect(tourDayTimeOf({ start: '09:00', end: '15:15' }, planned)).toMatchObject({
|
||||
end: '15:15',
|
||||
endMinutes: 915,
|
||||
workedOut: false,
|
||||
totalMinutes: null,
|
||||
});
|
||||
expect(tourDayTimeOf({ start: '22:00', end: '01:00' }, planned)).toMatchObject({
|
||||
endMinutes: 1500,
|
||||
nextDay: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('TIME-02-004: without a start there is no time, and without a known total no end', () => {
|
||||
expect(tourDayTimeOf({ start: null, end: '12:00' }, planned)).toBeNull();
|
||||
expect(tourDayTimeOf({ start: 'later', end: null }, planned)).toBeNull();
|
||||
const unknown = { ...imported, elevation_gain: null, elevation_loss: null };
|
||||
expect(tourDayTimeOf({ start: '09:00', end: null }, unknown)).toMatchObject({ end: null, workedOut: false });
|
||||
});
|
||||
|
||||
it('TIME-02-005: past midnight it ends on the next day', () => {
|
||||
expect(tourDayTimeOf({ start: '22:30', end: null }, planned)).toMatchObject({
|
||||
end: '01:30',
|
||||
endMinutes: 1530,
|
||||
nextDay: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('TIME-02-006: a day holding one stage of a multi-day tour gets no end worked out from the whole route', () => {
|
||||
expect(tourDayTimeOf({ start: '09:00', end: null, stageKey: 'stop-2' }, planned)).toMatchObject({
|
||||
end: null,
|
||||
workedOut: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("TIME-02-007: a day entry's own times come before the place's", () => {
|
||||
const place = { id: 7, name: 'Ridge', place_time: '08:00', end_time: '10:00' };
|
||||
const entry = { assignment_time: '09:00', assignment_end_time: null, tour_stage_key: null, place };
|
||||
expect(tourDayEntryOf(entry as unknown as Parameters<typeof tourDayEntryOf>[0])).toEqual({
|
||||
start: '09:00',
|
||||
end: '10:00',
|
||||
stageKey: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const placeItem = (id: number, name: string, time: string | null, extra: Record<string, unknown> = {}): MergedItem => ({
|
||||
type: 'place',
|
||||
sortKey: id,
|
||||
data: { id, place_id: id, place: { id, name, place_time: time, end_time: null }, ...extra },
|
||||
});
|
||||
const note = (time: string | null, text: string): MergedItem => ({ type: 'note', sortKey: 0, data: { text, time } });
|
||||
const train = (time: string, title: string): MergedItem => ({
|
||||
type: 'transport',
|
||||
sortKey: 0,
|
||||
data: { title, day_id: 3, reservation_time: `2026-07-10T${time}` },
|
||||
});
|
||||
|
||||
describe('tourOverrunOf', () => {
|
||||
it('TIME-02-008: names the next timed item the end runs past, passing over the untimed ones between', () => {
|
||||
const end = { endMinutes: 13 * 60 };
|
||||
expect(tourOverrunOf([note(null, 'Snack'), placeItem(2, 'Museum', '12:30')], 3, end)).toEqual({
|
||||
name: 'Museum',
|
||||
time: '12:30',
|
||||
end: '13:00',
|
||||
});
|
||||
expect(tourOverrunOf([train('12:45', 'Train home')], 3, end)).toEqual({
|
||||
name: 'Train home',
|
||||
time: '12:45',
|
||||
end: '13:00',
|
||||
});
|
||||
expect(tourOverrunOf([note('12:00', 'Call')], 3, end)).toMatchObject({ name: 'Call' });
|
||||
});
|
||||
|
||||
it('TIME-02-009: says nothing when the end is in time, at the next start, unknown, or nothing timed follows', () => {
|
||||
const atTheStart = [placeItem(2, 'Museum', '13:00'), placeItem(3, 'Cafe', '12:00')];
|
||||
expect(tourOverrunOf(atTheStart, 3, { endMinutes: 780 })).toBeNull();
|
||||
expect(tourOverrunOf([placeItem(2, 'Museum', '12:00')], 3, { endMinutes: null })).toBeNull();
|
||||
expect(tourOverrunOf([placeItem(2, 'Museum', '12:00')], 3, null)).toBeNull();
|
||||
expect(tourOverrunOf([placeItem(2, 'Museum', null)], 3, { endMinutes: 780 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dayItemOverrun', () => {
|
||||
it('TIME-02-010: reads the tour of a day item off the trip tours, and a plain place never runs over', () => {
|
||||
const tourItem = placeItem(7, 'Ridge', '10:00', { tour_place_id: 7, assignment_time: '10:00' });
|
||||
const items = [tourItem, placeItem(2, 'Lunch', '12:00')];
|
||||
const tourOf = (placeId: number) => (placeId === 7 ? planned : undefined);
|
||||
expect(dayItemOverrun(items, 0, 3, tourOf)).toEqual({ name: 'Lunch', time: '12:00', end: '13:00' });
|
||||
expect(dayItemOverrun(items, 1, 3, tourOf)).toBeNull();
|
||||
expect(dayItemOverrun(items, 0, 3, () => undefined)).toBeNull();
|
||||
expect(dayItemOverrun([note('09:00', 'Wake'), ...items], 0, 3, tourOf)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { clockOf, minutesOf, type TourListItem } from '@trek/shared';
|
||||
import type { Assignment } from '../../types';
|
||||
import { getDisplayTimeForDay, parseTimeToMinutes, type MergedItem } from '../../utils/dayMerge';
|
||||
import { tourDayMinutes, tourPlannedTimes } from './tourPresentation';
|
||||
|
||||
/**
|
||||
* TIME-02: a tour in the day's timeline, the way a place sits there. Its
|
||||
* start and end that day are the times of its day entry, the same fields a
|
||||
* place's visit keeps, so every tour type, a city walk among them, has one
|
||||
* model. With a start and a known total (the planned total, else the
|
||||
* estimate of an import, TIME-01) the end follows as the start plus that
|
||||
* total and is marked as worked out; an end typed by hand wins over it.
|
||||
* Nothing here is saved: a changed total moves the end the next time it is read.
|
||||
*/
|
||||
|
||||
const DAY_MINUTES = 24 * 60;
|
||||
|
||||
/** The times a day holds a tour by, as its entry carries them. */
|
||||
export interface TourDayEntry {
|
||||
start: string | null | undefined;
|
||||
end: string | null | undefined;
|
||||
/** MDT-02: the stage of a multi-day tour the day holds; the tour's total is not that stage's time. */
|
||||
stageKey?: string | null;
|
||||
}
|
||||
|
||||
/** When a tour starts and ends on a day. */
|
||||
export interface TourDayTime {
|
||||
/** `HH:MM` it starts. */
|
||||
start: string;
|
||||
/** `HH:MM` it ends; null where no end is typed and none can be worked out. */
|
||||
end: string | null;
|
||||
/** The end in minutes after that day's midnight, past 1440 when it ends on the next day. */
|
||||
endMinutes: number | null;
|
||||
/** The end is the start plus the total, not a time somebody typed. */
|
||||
workedOut: boolean;
|
||||
/** The total it was worked out with is an estimate (TIME-01). */
|
||||
estimated: boolean;
|
||||
/** The total leaves out a stay nobody entered yet, so it ends then at the earliest. */
|
||||
atLeast: boolean;
|
||||
/** It ends after midnight. */
|
||||
nextDay: boolean;
|
||||
/** The total the end was worked out with, in minutes; null for a typed end or none. */
|
||||
totalMinutes: number | null;
|
||||
}
|
||||
|
||||
/** The times of a day entry as the plan shows them: the entry's own before the place's. */
|
||||
export function tourDayEntryOf(
|
||||
assignment: Pick<Assignment, 'assignment_time' | 'assignment_end_time' | 'place' | 'tour_stage_key'>
|
||||
): TourDayEntry {
|
||||
return {
|
||||
start: assignment.assignment_time ?? assignment.place?.place_time,
|
||||
end: assignment.assignment_end_time ?? assignment.place?.end_time,
|
||||
stageKey: assignment.tour_stage_key ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* When a tour starts and ends on a day, or null without a start. A typed end
|
||||
* before the start runs into the next day. A stage of a multi-day tour gets
|
||||
* no worked-out end: the tour's total is the whole route's, not the stage's.
|
||||
*/
|
||||
export function tourDayTimeOf(entry: TourDayEntry, tour: TourListItem): TourDayTime | null {
|
||||
const start = minutesOf(entry.start);
|
||||
if (start === null) return null;
|
||||
const at = (end: number | null, total: { minutes: number; estimated: boolean; atLeast: boolean } | null) => ({
|
||||
start: clockOf(start),
|
||||
end: end === null ? null : clockOf(end),
|
||||
endMinutes: end,
|
||||
workedOut: total !== null,
|
||||
estimated: total?.estimated ?? false,
|
||||
atLeast: total?.atLeast ?? false,
|
||||
nextDay: end !== null && end >= DAY_MINUTES,
|
||||
totalMinutes: total?.minutes ?? null,
|
||||
});
|
||||
const typed = minutesOf(entry.end);
|
||||
if (typed !== null) return at(typed < start ? typed + DAY_MINUTES : typed, null);
|
||||
const total = entry.stageKey ? null : tourDayMinutes(tourPlannedTimes(tour));
|
||||
return total ? at(start + total.minutes, total) : at(null, null);
|
||||
}
|
||||
|
||||
/** The next timed item of the day a tour runs into: its name, the `HH:MM` it starts and the `HH:MM` the tour ends. */
|
||||
export interface TourOverrun {
|
||||
name: string;
|
||||
time: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
/** When an item of the day starts, in minutes after midnight, as the timeline orders it; null without a time. */
|
||||
function itemStartOf(item: MergedItem, dayId: number): number | null {
|
||||
if (item.type === 'place') return parseTimeToMinutes(item.data?.place?.place_time);
|
||||
if (item.type === 'note') return parseTimeToMinutes(item.data?.time);
|
||||
return parseTimeToMinutes(getDisplayTimeForDay(item.data, dayId));
|
||||
}
|
||||
|
||||
function itemNameOf(item: MergedItem): string {
|
||||
if (item.type === 'place') return item.data?.place?.name ?? '';
|
||||
if (item.type === 'note') return item.data?.text ?? '';
|
||||
return item.data?.title ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* TIME-02: the next timed item of the day when the tour's end runs past its
|
||||
* start, or null. Only the next item with a time counts, the way the plan
|
||||
* reads a day: items without one lie between and are passed over. An end
|
||||
* after midnight runs past every later start of that day. Said while the plan
|
||||
* is looked at and never written, as a late connection is (CW-08).
|
||||
*/
|
||||
export function tourOverrunOf(
|
||||
following: readonly MergedItem[],
|
||||
dayId: number,
|
||||
time: Pick<TourDayTime, 'endMinutes'> | null
|
||||
): TourOverrun | null {
|
||||
const end = time?.endMinutes;
|
||||
if (end == null) return null;
|
||||
for (const item of following) {
|
||||
const start = itemStartOf(item, dayId);
|
||||
if (start === null) continue;
|
||||
return end > start ? { name: itemNameOf(item), time: clockOf(start), end: clockOf(end) } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* TIME-02: the overrun of the tour a day's item is, read off the items after
|
||||
* it, or null for an item that is no tour, a tour without a start and one
|
||||
* that ends in time. `tourOf` names the tour a place is, as the trip's tour
|
||||
* list knows it.
|
||||
*/
|
||||
export function dayItemOverrun(
|
||||
items: readonly MergedItem[],
|
||||
index: number,
|
||||
dayId: number,
|
||||
tourOf: (placeId: number) => TourListItem | undefined
|
||||
): TourOverrun | null {
|
||||
const item = items[index];
|
||||
if (item?.type !== 'place') return null;
|
||||
const assignment: Assignment = item.data;
|
||||
const tour = assignment.tour_place_id === assignment.place_id ? tourOf(assignment.place_id) : undefined;
|
||||
if (!tour) return null;
|
||||
return tourOverrunOf(items.slice(index + 1), dayId, tourDayTimeOf(tourDayEntryOf(assignment), tour));
|
||||
}
|
||||
Reference in New Issue
Block a user