mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +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" />
|
||||
|
||||
Reference in New Issue
Block a user