mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +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:
@@ -586,6 +586,12 @@
|
||||
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "A tour's start and end in the day plan, its end worked out from its time, and an end that runs into the next timed item",
|
||||
"hooks": ["src/components/Tours/tourDayTime.ts"],
|
||||
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
|
||||
},
|
||||
{
|
||||
"feature": "Reservation form incl. hotel accommodation creation",
|
||||
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -208,6 +208,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
onOpen={() => openPlace(row.assignment)}
|
||||
onEdit={canEditPlaces ? () => tl.editAssignment(row.assignment) : undefined}
|
||||
onRemove={() => tl.removeAssignment(row.assignment)}
|
||||
overrun={planner.toursEnabled ? tl.tourOverrun(row.item) : null}
|
||||
/>
|
||||
{dayScheduleFor('assignment', row.assignment.id)}
|
||||
</Fragment>
|
||||
|
||||
@@ -73,6 +73,20 @@ describe('RC-01 mobile PLAN Tour rows', () => {
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TIME-02-019: a tour with a start shows its end worked out in the time chip, and its overrun below', () => {
|
||||
const assignment = buildAssignment({
|
||||
id: 9,
|
||||
day_id: 1,
|
||||
assignment_time: '09:00',
|
||||
place: { ...place, place_time: '09:00' },
|
||||
});
|
||||
const overrun = { name: 'Lunch', time: '10:00', end: '10:03' };
|
||||
render(<PlaceRow {...props} assignment={assignment} tour={tour} overrun={overrun} />);
|
||||
// 53 min walking and 10 min of breaks.
|
||||
expect(screen.getByTestId('tour-day-end-worked-out')).toHaveTextContent('10:03');
|
||||
expect(screen.getByTestId('tour-day-overrun')).toHaveTextContent('Ends at 10:03, after Lunch starts at 10:00');
|
||||
});
|
||||
|
||||
it('CW-03: a hike on a day with marks shows no progress', () => {
|
||||
useTourVisitStore.setState({ tripId: '1', byAssignment: { 9: { 'stop-01': 'visited' } } });
|
||||
render(<PlaceRow {...props} tour={tour} />);
|
||||
|
||||
@@ -20,6 +20,8 @@ import type { PluginDayScheduleItem } from '../../../../api/client'
|
||||
import type { TourListItem } from '@trek/shared'
|
||||
import TourDayRowFacts, { TourDayRowIcon } from '../../../../components/Tours/TourDayRowFacts'
|
||||
import { WalkLateLine } from '../../../../components/Tours/walk/WalkTransitNote'
|
||||
import { TourDayTimeText, TourOverrunLine } from '../../../../components/Tours/TourDayTimeParts'
|
||||
import { tourDayEntryOf, type TourOverrun } from '../../../../components/Tours/tourDayTime'
|
||||
import type { WalkLate } from '../../../../components/Tours/walk/walkTransit'
|
||||
|
||||
/**
|
||||
@@ -124,10 +126,12 @@ const ROW_CODE = 'flex-none whitespace-nowrap font-geist text-[0.71875rem] text-
|
||||
|
||||
// ── b3) Place row ────────────────────────────────────────────────────────────
|
||||
|
||||
export function PlaceRow({ assignment, fullPlace, tour, linkedReservations, chrome, reorder, drag, onOpen, onEdit, onRemove }: {
|
||||
export function PlaceRow({ assignment, fullPlace, tour, linkedReservations, chrome, reorder, drag, onOpen, onEdit, onRemove, overrun }: {
|
||||
assignment: Assignment
|
||||
fullPlace: Place | undefined
|
||||
tour?: TourListItem
|
||||
/** TIME-02: the day's next timed item this tour runs into, or null. */
|
||||
overrun?: TourOverrun | null
|
||||
linkedReservations: Reservation[]
|
||||
chrome: RowChrome
|
||||
reorder: ReactNode
|
||||
@@ -205,7 +209,7 @@ export function PlaceRow({ assignment, fullPlace, tour, linkedReservations, chro
|
||||
{tour && <div className="mt-[2px]"><TourDayRowFacts tour={tour} assignmentId={assignment.id} mobile /></div>}
|
||||
{(time || sub) && (
|
||||
<div className="mt-[2px] flex min-w-0 items-center gap-1.5">
|
||||
{time && <span className={TIME_CHIP}>{time}</span>}
|
||||
{time && <span className={TIME_CHIP}>{tour ? <TourDayTimeText entry={tourDayEntryOf(assignment)} tour={tour} mobile /> : time}</span>}
|
||||
{sub && <MarkdownText clamp className="min-w-0 font-geist text-[0.71875rem] text-m-muted">{sub}</MarkdownText>}
|
||||
{bookingLines[0]?.code && <BlurredCode interactive={false} className={ROW_CODE}>{bookingLines[0].code}</BlurredCode>}
|
||||
</div>
|
||||
@@ -225,6 +229,7 @@ export function PlaceRow({ assignment, fullPlace, tour, linkedReservations, chro
|
||||
<MarkdownText clamp className="min-w-0 font-geist text-[0.65625rem] text-m-faint">{assignment.notes}</MarkdownText>
|
||||
</div>
|
||||
)}
|
||||
<TourOverrunLine overrun={overrun} />
|
||||
</div>
|
||||
{chrome.editing && (
|
||||
<span className="flex flex-none items-center gap-1.5">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getDisplayTimeForDay, getMergedItems, getTransportForDay, hasCarrierEnd
|
||||
import { dayCoMapsUrl, dayExportStops, dayGoogleMapsUrl, optimizeDayOrder, type DayCarrier } from '../../../../components/Planner/dayRoute'
|
||||
import type { PlannedTransitLeg, TransitLeg } from '../../../../components/Planner/transitLeg'
|
||||
import { useTransitLegPlanner } from '../../../../components/Planner/useTransitLegPlanner'
|
||||
import { dayItemOverrun } from '../../../../components/Tours/tourDayTime'
|
||||
import {
|
||||
buildPlanRows, breaksChronology, findUpNext, hotelChipsForDay, hotelLegsForDay, itemHasTime,
|
||||
type HotelLegs, type PlanRow, type TransportEntry,
|
||||
@@ -377,6 +378,9 @@ export function useMPlanTimeline(planner: TripPlanner) {
|
||||
// A saved journey that now arrives after the walk it leads to starts (CW-08).
|
||||
const journeyLate = useCallback((item: MergedItem) =>
|
||||
(day ? lateBefore(day, item.data, merged.slice(merged.indexOf(item) + 1)) : null), [day, lateBefore, merged])
|
||||
// A tour whose end runs past the start of the day's next timed item (TIME-02).
|
||||
const tourOverrun = useCallback((item: MergedItem) =>
|
||||
(day ? dayItemOverrun(merged, merged.indexOf(item), day.id, tourOf) : null), [day, merged, tourOf])
|
||||
|
||||
return {
|
||||
day, rows, hotelLegs, merged, hotelChips, weather, weatherTemp, upNext,
|
||||
@@ -387,7 +391,7 @@ export function useMPlanTimeline(planner: TripPlanner) {
|
||||
moveRowTo,
|
||||
addPlace, addBooking, addTransport,
|
||||
optimize, canExportRoute, exportGoogleMaps, exportCoMaps, renameDay, fullPlaceOf,
|
||||
routeModeOptions, setLegMode, transitLegFor, planTransitLeg, journeyLate,
|
||||
routeModeOptions, setLegMode, transitLegFor, planTransitLeg, journeyLate, tourOverrun,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Eyebrow } from './PlSheetChrome'
|
||||
import CustomTimePicker from '../../../../components/shared/CustomTimePicker'
|
||||
import { timeCollisions } from '../../../../components/Planner/PlaceFormModal.helpers'
|
||||
import type { Assignment } from '../../../../types'
|
||||
import { TourEndHint } from '../../../../components/Tours/TourDayTimeParts'
|
||||
import type { TripPlanner } from '../MTripShell'
|
||||
|
||||
interface PlTimeFieldsProps {
|
||||
@@ -39,6 +40,9 @@ export default function PlTimeFields({
|
||||
() => timeCollisions(assignmentId, dayAssignments, startTime, endTime),
|
||||
[assignmentId, dayAssignments, startTime, endTime],
|
||||
)
|
||||
// TIME-02: a tour's end follows from its start and its time while none is typed.
|
||||
const assignment = dayAssignments.find(a => a.id === assignmentId)
|
||||
const tour = assignment ? planner.tours.find(item => item.place_id === assignment.place_id) : undefined
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
@@ -55,6 +59,7 @@ export default function PlTimeFields({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TourEndHint start={startTime} end={endTime} assignment={assignment} tour={tour} mobile />
|
||||
{hasTimeError && (
|
||||
<div className={WARNING_CLS}>
|
||||
<AlertTriangle size={13} strokeWidth={2} className="mt-px flex-none" />
|
||||
|
||||
@@ -831,7 +831,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
/>
|
||||
</LazyPanel>
|
||||
)}
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null); setServiceStopForm(false) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} serviceStop={serviceStopMode} roadtripActive={roadtripActive} />
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null); setServiceStopForm(false) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} isMobile={isMobile} onOpenExpense={openBookingExpense} serviceStop={serviceStopMode} roadtripActive={roadtripActive} tour={editingPlace ? tours.find(item => item.place_id === editingPlace.id) ?? null : null} />
|
||||
<TripFormModal
|
||||
isOpen={showTripForm}
|
||||
onClose={() => setShowTripForm(false)}
|
||||
|
||||
@@ -124,6 +124,7 @@ function buildTl(over: Record<string, unknown> = {}): MPlanTimelineController {
|
||||
transitLegFor: vi.fn(() => null),
|
||||
planTransitLeg: vi.fn(),
|
||||
journeyLate: vi.fn(() => null),
|
||||
tourOverrun: vi.fn(() => null),
|
||||
...over,
|
||||
} as unknown as MPlanTimelineController
|
||||
}
|
||||
@@ -391,6 +392,14 @@ describe('MPlanTimeline', () => {
|
||||
renderTimeline({ rows: [row], merged: [item], upNext: null }, { toursEnabled: false })
|
||||
expect(screen.queryByText('tours.dayRoute.endpointUnknown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('TIME-02-020: a tour row says when its end runs into the next timed item, read from the shared timeline logic', () => {
|
||||
const validRow: PlanRow = { ...row, invalidTour: false }
|
||||
const tourOverrun = vi.fn(() => ({ name: 'Lunch', time: '12:00', end: '13:00' }))
|
||||
renderTimeline({ rows: [validRow], merged: [item], upNext: null, tourOverrun }, { toursEnabled: true, tours: [] })
|
||||
expect(tourOverrun).toHaveBeenCalledWith(item)
|
||||
expect(screen.getByTestId('tour-day-overrun')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-segment travel mode', () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { seedStore } from '../../../helpers/store'
|
||||
import { buildSettings } from '../../../helpers/factories'
|
||||
import { useSettingsStore } from '../../../../src/store/settingsStore'
|
||||
|
||||
// FE-MOB-PLTIME-001 to FE-MOB-PLTIME-015
|
||||
// FE-MOB-PLTIME-001 to FE-MOB-PLTIME-017
|
||||
|
||||
const planner = buildPlanner()
|
||||
|
||||
@@ -171,4 +171,40 @@ describe('PlTimeFields', () => {
|
||||
setup()
|
||||
expect(screen.queryByText('roadtrip.stop.endIsLeave')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// TIME-02: an imported hike of 12 km, 300 m up and 500 m down, estimated at 4 h.
|
||||
const hike = {
|
||||
place_id: 1, name: 'Museum ridge', tour_type: 'hike', distance: 12, elevation_gain: 300, elevation_loss: 500,
|
||||
duration: null, planned_duration_minutes: null, break_additional_minutes: null, difficulty: null, wanderer_ref: null,
|
||||
match_confidence: 1, max_hiking_difficulty: null, planned: true, caution: false,
|
||||
}
|
||||
|
||||
function renderTour(startTime: string, endTime: string) {
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ time_format: '24h' }) })
|
||||
return render(
|
||||
<PlTimeFields
|
||||
planner={buildPlanner({ tours: [hike] } as never)}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
onChange={vi.fn()}
|
||||
assignmentId={1}
|
||||
dayAssignments={[SELF]}
|
||||
hasTimeError={false}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
it('FE-MOB-PLTIME-016: a tour without a typed end says the end its start and its time work out to', () => {
|
||||
renderTour('09:00', '')
|
||||
expect(screen.getByTestId('tour-day-end-hint')).toHaveTextContent('13:00')
|
||||
expect(screen.getByTestId('tour-day-end-hint')).toHaveTextContent('≈ 4 h')
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTIME-017: before a start it says one will set the end, and a typed end needs no hint', () => {
|
||||
const view = renderTour('', '')
|
||||
expect(screen.getByTestId('tour-day-end-hint')).toHaveTextContent('≈ 4 h')
|
||||
view.unmount()
|
||||
renderTour('09:00', '15:00')
|
||||
expect(screen.queryByTestId('tour-day-end-hint')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -115,4 +115,19 @@ describe('useMPlanTimeline, public transit between two city walks (CW-08)', () =
|
||||
if (!item) throw new Error('no journey on the day')
|
||||
expect(result.current.journeyLate(item)).toEqual({ walk: 'Harbour', time: '13:00' })
|
||||
})
|
||||
|
||||
it('FE-MOB-PLTL-HOOK-005: says a walk whose end runs past the next walk start, the desktop rule (TIME-02)', () => {
|
||||
const late = buildPlanner({
|
||||
...walkPlanner(),
|
||||
tours: [{ ...walk(oldTown), planned_duration_minutes: 240 }, walk(harbour)],
|
||||
} as never)
|
||||
const { result } = renderHook(() => useMPlanTimeline(late))
|
||||
|
||||
const [first, second] = result.current.merged
|
||||
expect(result.current.tourOverrun(first)).toEqual({ name: 'Harbour', time: '13:00', end: '14:00' })
|
||||
expect(result.current.tourOverrun(second)).toBeNull()
|
||||
// In time, nothing is said.
|
||||
const { result: inTime } = renderHook(() => useMPlanTimeline(walkPlanner()))
|
||||
expect(inTime.current.tourOverrun(inTime.current.merged[0])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1370,5 +1370,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.noHeights': 'لا يحتوي المسار على ارتفاعات، لذا لا يمكن تقدير وقته.',
|
||||
'tours.estimate.basisDin': 'محسوب من المسافة والصعود والنزول وفق قاعدة لافتات المشي (DIN 33466)، دون استراحات.',
|
||||
'tours.estimate.basisPace': 'محسوب من المسافة بالوتيرة المُدخلة.',
|
||||
'tours.dayTime.workedOut': 'ينتهي عند {time}، محسوبًا من البداية ومدة الجولة {total}',
|
||||
'tours.dayTime.workedOutNextDay': 'ينتهي في اليوم التالي عند {time}، محسوبًا من البداية ومدة الجولة {total}',
|
||||
'tours.dayTime.overrun': 'ينتهي عند {end}، بعد بدء {name} عند {time}',
|
||||
'tours.dayTime.nextItem': 'العنصر التالي',
|
||||
'tours.dayTime.formHint': 'إذا تُرك فارغًا تُحسب النهاية: {time}، أي البداية مضافًا إليها مدة الجولة {total}.',
|
||||
'tours.dayTime.formNoStart': 'مع وقت البداية تُحسب النهاية من مدة الجولة {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1311,5 +1311,12 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Məsafə, qalxma və enmədən piyada nişanları qaydası ilə (DIN 33466) hesablanıb, fasilələr daxil deyil.',
|
||||
'tours.estimate.basisPace': 'Məsafədən daxil edilmiş templə hesablanıb.',
|
||||
'tours.dayTime.workedOut': '{time} radələrində bitir, başlanğıc və {total} tur vaxtından hesablanıb',
|
||||
'tours.dayTime.workedOutNextDay':
|
||||
'Növbəti gün {time} radələrində bitir, başlanğıc və {total} tur vaxtından hesablanıb',
|
||||
'tours.dayTime.overrun': '{end} radələrində bitir, {name} {time} radələrində başladıqdan sonra',
|
||||
'tours.dayTime.nextItem': 'növbəti element',
|
||||
'tours.dayTime.formHint': 'Boş qalsa, son hesablanır: {time}, başlanğıc üstəgəl {total} tur vaxtı.',
|
||||
'tours.dayTime.formNoStart': 'Başlanğıc vaxtı ilə son {total} tur vaxtından hesablanır.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1324,5 +1324,12 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Calculado pela distância, subida e descida segundo a regra das placas de trilha (DIN 33466), sem pausas.',
|
||||
'tours.estimate.basisPace': 'Calculado pela distância no ritmo informado.',
|
||||
'tours.dayTime.workedOut': 'Termina às {time}, calculado pelo início e pela duração de {total}',
|
||||
'tours.dayTime.workedOutNextDay':
|
||||
'Termina no dia seguinte às {time}, calculado pelo início e pela duração de {total}',
|
||||
'tours.dayTime.overrun': 'Termina às {end}, depois que {name} começa às {time}',
|
||||
'tours.dayTime.nextItem': 'o próximo item',
|
||||
'tours.dayTime.formHint': 'Se ficar vazio, o fim é calculado: {time}, o início mais a duração de {total}.',
|
||||
'tours.dayTime.formNoStart': 'Com um horário de início, o fim é calculado pela duração de {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1330,5 +1330,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Calculat a partir de la distància, la pujada i la baixada segons la regla dels senyals excursionistes (DIN 33466), sense pauses.',
|
||||
'tours.estimate.basisPace': 'Calculat a partir de la distància al ritme introduït.',
|
||||
'tours.dayTime.workedOut': 'Acaba a les {time}, calculat a partir de l’inici i la durada de {total}',
|
||||
'tours.dayTime.workedOutNextDay': 'Acaba l’endemà a les {time}, calculat a partir de l’inici i la durada de {total}',
|
||||
'tours.dayTime.overrun': 'Acaba a les {end}, després que {name} comenci a les {time}',
|
||||
'tours.dayTime.nextItem': 'l’element següent',
|
||||
'tours.dayTime.formHint': 'Si es deixa buit, el final es calcula: {time}, l’inici més la durada de {total}.',
|
||||
'tours.dayTime.formNoStart': 'Amb una hora d’inici, el final es calcula a partir de la durada de {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1315,5 +1315,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Vypočteno ze vzdálenosti, stoupání a klesání podle pravidla turistických rozcestníků (DIN 33466), bez přestávek.',
|
||||
'tours.estimate.basisPace': 'Vypočteno ze vzdálenosti při zadaném tempu.',
|
||||
'tours.dayTime.workedOut': 'Končí v {time}, vypočteno ze začátku a doby túry {total}',
|
||||
'tours.dayTime.workedOutNextDay': 'Končí další den v {time}, vypočteno ze začátku a doby túry {total}',
|
||||
'tours.dayTime.overrun': 'Končí v {end}, až po začátku {name} v {time}',
|
||||
'tours.dayTime.nextItem': 'další položka',
|
||||
'tours.dayTime.formHint': 'Když zůstane prázdný, konec se vypočte: {time}, začátek plus doba túry {total}.',
|
||||
'tours.dayTime.formNoStart': 'Se začátkem se konec vypočte z doby túry {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1342,6 +1342,13 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Aus Strecke, Auf- und Abstieg nach der Wegweiser-Regel (DIN 33466) berechnet, ohne Pausen.',
|
||||
'tours.estimate.basisPace': 'Aus der Strecke beim eingegebenen Tempo berechnet.',
|
||||
'tours.dayTime.workedOut': 'Endet um {time}, berechnet aus dem Start und der Tourzeit von {total}',
|
||||
'tours.dayTime.workedOutNextDay':
|
||||
'Endet am nächsten Tag um {time}, berechnet aus dem Start und der Tourzeit von {total}',
|
||||
'tours.dayTime.overrun': 'Endet um {end}, nach dem Start von {name} um {time}',
|
||||
'tours.dayTime.nextItem': 'dem nächsten Eintrag',
|
||||
'tours.dayTime.formHint': 'Leer gelassen, wird das Ende berechnet: {time}, der Start plus die Tourzeit von {total}.',
|
||||
'tours.dayTime.formNoStart': 'Mit einer Startzeit wird das Ende aus der Tourzeit von {total} berechnet.',
|
||||
};
|
||||
|
||||
export default tours;
|
||||
|
||||
@@ -1297,5 +1297,12 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Worked out from distance, ascent and descent by the hiking signpost rule (DIN 33466), without breaks.',
|
||||
'tours.estimate.basisPace': 'Worked out from the distance at the pace entered.',
|
||||
'tours.dayTime.workedOut': 'Ends at {time}, worked out from the start and the tour time of {total}',
|
||||
'tours.dayTime.workedOutNextDay':
|
||||
'Ends at {time} on the next day, worked out from the start and the tour time of {total}',
|
||||
'tours.dayTime.overrun': 'Ends at {end}, after {name} starts at {time}',
|
||||
'tours.dayTime.nextItem': 'the next item',
|
||||
'tours.dayTime.formHint': 'Left empty, the end is worked out: {time}, the start plus the tour time of {total}.',
|
||||
'tours.dayTime.formNoStart': 'With a start time, the end is worked out from the tour time of {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1323,5 +1323,12 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Calculado a partir de la distancia, el ascenso y el descenso según la regla de las señales de senderismo (DIN 33466), sin pausas.',
|
||||
'tours.estimate.basisPace': 'Calculado a partir de la distancia al ritmo introducido.',
|
||||
'tours.dayTime.workedOut': 'Termina a las {time}, calculado a partir del inicio y la duración de {total}',
|
||||
'tours.dayTime.workedOutNextDay':
|
||||
'Termina al día siguiente a las {time}, calculado a partir del inicio y la duración de {total}',
|
||||
'tours.dayTime.overrun': 'Termina a las {end}, después de que {name} empiece a las {time}',
|
||||
'tours.dayTime.nextItem': 'el siguiente elemento',
|
||||
'tours.dayTime.formHint': 'Si se deja vacío, el final se calcula: {time}, el inicio más la duración de {total}.',
|
||||
'tours.dayTime.formNoStart': 'Con una hora de inicio, el final se calcula a partir de la duración de {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1306,5 +1306,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Arvutatud vahemaa, tõusu ja laskumise järgi matkaviitade reegli (DIN 33466) alusel, pausideta.',
|
||||
'tours.estimate.basisPace': 'Arvutatud vahemaast sisestatud tempoga.',
|
||||
'tours.dayTime.workedOut': 'Lõpeb kell {time}, arvutatud algusest ja matka ajast {total}',
|
||||
'tours.dayTime.workedOutNextDay': 'Lõpeb järgmisel päeval kell {time}, arvutatud algusest ja matka ajast {total}',
|
||||
'tours.dayTime.overrun': 'Lõpeb kell {end}, pärast seda kui {name} algab kell {time}',
|
||||
'tours.dayTime.nextItem': 'järgmine punkt',
|
||||
'tours.dayTime.formHint': 'Tühjaks jättes arvutatakse lõpp: {time}, algus pluss matka aeg {total}.',
|
||||
'tours.dayTime.formNoStart': 'Algusajaga arvutatakse lõpp matka ajast {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1350,5 +1350,12 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Calculé à partir de la distance, du dénivelé positif et négatif selon la règle des panneaux de randonnée (DIN 33466), sans pauses.',
|
||||
'tours.estimate.basisPace': 'Calculé à partir de la distance à l’allure saisie.',
|
||||
'tours.dayTime.workedOut': 'Se termine à {time}, calculé à partir du départ et de la durée de {total}',
|
||||
'tours.dayTime.workedOutNextDay':
|
||||
'Se termine le lendemain à {time}, calculé à partir du départ et de la durée de {total}',
|
||||
'tours.dayTime.overrun': 'Se termine à {end}, après le début de {name} à {time}',
|
||||
'tours.dayTime.nextItem': 'l’élément suivant',
|
||||
'tours.dayTime.formHint': 'Laissée vide, la fin est calculée : {time}, le départ plus la durée de {total}.',
|
||||
'tours.dayTime.formNoStart': 'Avec une heure de départ, la fin est calculée à partir de la durée de {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1352,5 +1352,12 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Υπολογισμένος από απόσταση, ανάβαση και κατάβαση με τον κανόνα των πινακίδων πεζοπορίας (DIN 33466), χωρίς διαλείμματα.',
|
||||
'tours.estimate.basisPace': 'Υπολογισμένος από την απόσταση με τον ρυθμό που δόθηκε.',
|
||||
'tours.dayTime.workedOut': 'Τελειώνει στις {time}, υπολογισμένο από την έναρξη και τη διάρκεια {total}',
|
||||
'tours.dayTime.workedOutNextDay':
|
||||
'Τελειώνει την επόμενη μέρα στις {time}, υπολογισμένο από την έναρξη και τη διάρκεια {total}',
|
||||
'tours.dayTime.overrun': 'Τελειώνει στις {end}, μετά την έναρξη του {name} στις {time}',
|
||||
'tours.dayTime.nextItem': 'το επόμενο στοιχείο',
|
||||
'tours.dayTime.formHint': 'Αν μείνει κενό, το τέλος υπολογίζεται: {time}, η έναρξη συν τη διάρκεια {total}.',
|
||||
'tours.dayTime.formNoStart': 'Με ώρα έναρξης, το τέλος υπολογίζεται από τη διάρκεια {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1329,5 +1329,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'A távból, emelkedésből és lejtésből számolva a turistaútjelzők szabálya szerint (DIN 33466), szünetek nélkül.',
|
||||
'tours.estimate.basisPace': 'A távból számolva a megadott tempóval.',
|
||||
'tours.dayTime.workedOut': 'Vége: {time}, a kezdésből és a túra {total} idejéből számolva',
|
||||
'tours.dayTime.workedOutNextDay': 'Vége másnap {time}-kor, a kezdésből és a túra {total} idejéből számolva',
|
||||
'tours.dayTime.overrun': 'Vége: {end}, miután {name} {time}-kor kezdődik',
|
||||
'tours.dayTime.nextItem': 'a következő tétel',
|
||||
'tours.dayTime.formHint': 'Üresen hagyva a vég kiszámolódik: {time}, a kezdés plusz a túra {total} ideje.',
|
||||
'tours.dayTime.formNoStart': 'Kezdési időponttal a vég a túra {total} idejéből számolódik.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1303,5 +1303,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Dihitung dari jarak, tanjakan, dan turunan menurut aturan papan penunjuk pendakian (DIN 33466), tanpa istirahat.',
|
||||
'tours.estimate.basisPace': 'Dihitung dari jarak dengan kecepatan yang dimasukkan.',
|
||||
'tours.dayTime.workedOut': 'Selesai pukul {time}, dihitung dari mulai dan waktu tur {total}',
|
||||
'tours.dayTime.workedOutNextDay': 'Selesai keesokan harinya pukul {time}, dihitung dari mulai dan waktu tur {total}',
|
||||
'tours.dayTime.overrun': 'Selesai pukul {end}, setelah {name} dimulai pukul {time}',
|
||||
'tours.dayTime.nextItem': 'item berikutnya',
|
||||
'tours.dayTime.formHint': 'Jika dibiarkan kosong, akhir dihitung: {time}, waktu mulai ditambah waktu tur {total}.',
|
||||
'tours.dayTime.formNoStart': 'Dengan waktu mulai, akhir dihitung dari waktu tur {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1337,5 +1337,12 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Calcolato da distanza, salita e discesa secondo la regola dei cartelli escursionistici (DIN 33466), senza pause.',
|
||||
'tours.estimate.basisPace': 'Calcolato dalla distanza al ritmo inserito.',
|
||||
'tours.dayTime.workedOut': 'Finisce alle {time}, calcolato dalla partenza e dalla durata di {total}',
|
||||
'tours.dayTime.workedOutNextDay':
|
||||
'Finisce il giorno dopo alle {time}, calcolato dalla partenza e dalla durata di {total}',
|
||||
'tours.dayTime.overrun': 'Finisce alle {end}, dopo l’inizio di {name} alle {time}',
|
||||
'tours.dayTime.nextItem': 'la voce successiva',
|
||||
'tours.dayTime.formHint': 'Se lasciata vuota, la fine viene calcolata: {time}, la partenza più la durata di {total}.',
|
||||
'tours.dayTime.formNoStart': 'Con un orario di partenza, la fine viene calcolata dalla durata di {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1271,5 +1271,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.noHeights': 'トラックに標高データがないため、時間を推定できません。',
|
||||
'tours.estimate.basisDin': '距離・登り・下りから登山道標識の基準(DIN 33466)で算出、休憩は含みません。',
|
||||
'tours.estimate.basisPace': '距離と入力したペースから算出。',
|
||||
'tours.dayTime.workedOut': '{time}に終了(開始時刻とツアー時間{total}から算出)',
|
||||
'tours.dayTime.workedOutNextDay': '翌日{time}に終了(開始時刻とツアー時間{total}から算出)',
|
||||
'tours.dayTime.overrun': '{end}に終了、{name}の開始{time}より後です',
|
||||
'tours.dayTime.nextItem': '次の項目',
|
||||
'tours.dayTime.formHint': '空欄のままなら終了時刻を算出します:{time}(開始時刻+ツアー時間{total})。',
|
||||
'tours.dayTime.formNoStart': '開始時刻を入れると、ツアー時間{total}から終了時刻を算出します。',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1268,5 +1268,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'거리, 오르막, 내리막으로 등산로 표지 규칙(DIN 33466)에 따라 계산했으며 휴식은 포함하지 않습니다.',
|
||||
'tours.estimate.basisPace': '거리와 입력한 페이스로 계산했습니다.',
|
||||
'tours.dayTime.workedOut': '{time}에 종료, 시작 시각과 투어 시간 {total}으로 계산',
|
||||
'tours.dayTime.workedOutNextDay': '다음 날 {time}에 종료, 시작 시각과 투어 시간 {total}으로 계산',
|
||||
'tours.dayTime.overrun': '{end}에 종료, {name}이(가) {time}에 시작한 뒤입니다',
|
||||
'tours.dayTime.nextItem': '다음 항목',
|
||||
'tours.dayTime.formHint': '비워 두면 종료 시각을 계산합니다: {time}, 시작 시각에 투어 시간 {total}을 더한 값입니다.',
|
||||
'tours.dayTime.formNoStart': '시작 시각을 입력하면 투어 시간 {total}으로 종료 시각을 계산합니다.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1333,5 +1333,12 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Berekend uit afstand, stijging en daling volgens de regel van de wandelwegwijzers (DIN 33466), zonder pauzes.',
|
||||
'tours.estimate.basisPace': 'Berekend uit de afstand bij het ingevoerde tempo.',
|
||||
'tours.dayTime.workedOut': 'Eindigt om {time}, berekend uit de start en de tourtijd van {total}',
|
||||
'tours.dayTime.workedOutNextDay':
|
||||
'Eindigt de volgende dag om {time}, berekend uit de start en de tourtijd van {total}',
|
||||
'tours.dayTime.overrun': 'Eindigt om {end}, nadat {name} om {time} begint',
|
||||
'tours.dayTime.nextItem': 'het volgende onderdeel',
|
||||
'tours.dayTime.formHint': 'Leeg gelaten wordt het einde berekend: {time}, de start plus de tourtijd van {total}.',
|
||||
'tours.dayTime.formNoStart': 'Met een starttijd wordt het einde berekend uit de tourtijd van {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1366,5 +1366,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Wyliczony z dystansu, podejść i zejść według reguły drogowskazów turystycznych (DIN 33466), bez przerw.',
|
||||
'tours.estimate.basisPace': 'Wyliczony z dystansu przy podanym tempie.',
|
||||
'tours.dayTime.workedOut': 'Kończy się o {time}, wyliczone ze startu i czasu trasy {total}',
|
||||
'tours.dayTime.workedOutNextDay': 'Kończy się następnego dnia o {time}, wyliczone ze startu i czasu trasy {total}',
|
||||
'tours.dayTime.overrun': 'Kończy się o {end}, po rozpoczęciu {name} o {time}',
|
||||
'tours.dayTime.nextItem': 'następny punkt',
|
||||
'tours.dayTime.formHint': 'Puste pole: koniec zostanie wyliczony, {time}, czyli start plus czas trasy {total}.',
|
||||
'tours.dayTime.formNoStart': 'Po podaniu startu koniec zostanie wyliczony z czasu trasy {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1372,5 +1372,12 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Рассчитано по расстоянию, набору и сбросу высоты по правилу туристических указателей (DIN 33466), без перерывов.',
|
||||
'tours.estimate.basisPace': 'Рассчитано по расстоянию при введённом темпе.',
|
||||
'tours.dayTime.workedOut': 'Заканчивается в {time}, рассчитано по началу и времени тура {total}',
|
||||
'tours.dayTime.workedOutNextDay':
|
||||
'Заканчивается на следующий день в {time}, рассчитано по началу и времени тура {total}',
|
||||
'tours.dayTime.overrun': 'Заканчивается в {end}, после начала {name} в {time}',
|
||||
'tours.dayTime.nextItem': 'следующий пункт',
|
||||
'tours.dayTime.formHint': 'Если оставить пустым, конец рассчитывается: {time}, начало плюс время тура {total}.',
|
||||
'tours.dayTime.formNoStart': 'Со временем начала конец рассчитывается по времени тура {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1319,5 +1319,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Vypočítané zo vzdialenosti, stúpania a klesania podľa pravidla turistických smerovníkov (DIN 33466), bez prestávok.',
|
||||
'tours.estimate.basisPace': 'Vypočítané zo vzdialenosti pri zadanom tempe.',
|
||||
'tours.dayTime.workedOut': 'Končí o {time}, vypočítané zo začiatku a času túry {total}',
|
||||
'tours.dayTime.workedOutNextDay': 'Končí na druhý deň o {time}, vypočítané zo začiatku a času túry {total}',
|
||||
'tours.dayTime.overrun': 'Končí o {end}, až po začiatku {name} o {time}',
|
||||
'tours.dayTime.nextItem': 'ďalšia položka',
|
||||
'tours.dayTime.formHint': 'Ak zostane prázdny, koniec sa vypočíta: {time}, začiatok plus čas túry {total}.',
|
||||
'tours.dayTime.formNoStart': 'So začiatkom sa koniec vypočíta z času túry {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1302,5 +1302,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Beräknad från sträcka, stigning och nedstigning enligt vandringsskyltarnas regel (DIN 33466), utan pauser.',
|
||||
'tours.estimate.basisPace': 'Beräknad från sträckan i angivet tempo.',
|
||||
'tours.dayTime.workedOut': 'Slutar {time}, beräknat från starten och turens tid på {total}',
|
||||
'tours.dayTime.workedOutNextDay': 'Slutar nästa dag {time}, beräknat från starten och turens tid på {total}',
|
||||
'tours.dayTime.overrun': 'Slutar {end}, efter att {name} börjar {time}',
|
||||
'tours.dayTime.nextItem': 'nästa punkt',
|
||||
'tours.dayTime.formHint': 'Lämnas tomt beräknas slutet: {time}, starten plus turens tid på {total}.',
|
||||
'tours.dayTime.formNoStart': 'Med en starttid beräknas slutet från turens tid på {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1246,5 +1246,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.noHeights': 'เส้นทางไม่มีข้อมูลความสูง จึงประมาณเวลาไม่ได้',
|
||||
'tours.estimate.basisDin': 'คำนวณจากระยะทาง ระยะขึ้นและลงตามกฎป้ายบอกทางเดินป่า (DIN 33466) ไม่รวมเวลาพัก',
|
||||
'tours.estimate.basisPace': 'คำนวณจากระยะทางตามเพซที่กรอกไว้',
|
||||
'tours.dayTime.workedOut': 'สิ้นสุดเวลา {time} คำนวณจากเวลาเริ่มและเวลาทัวร์ {total}',
|
||||
'tours.dayTime.workedOutNextDay': 'สิ้นสุดวันถัดไปเวลา {time} คำนวณจากเวลาเริ่มและเวลาทัวร์ {total}',
|
||||
'tours.dayTime.overrun': 'สิ้นสุดเวลา {end} หลังจาก {name} เริ่มเวลา {time}',
|
||||
'tours.dayTime.nextItem': 'รายการถัดไป',
|
||||
'tours.dayTime.formHint': 'หากเว้นว่างไว้ ระบบจะคำนวณเวลาสิ้นสุด: {time} คือเวลาเริ่มบวกเวลาทัวร์ {total}',
|
||||
'tours.dayTime.formNoStart': 'เมื่อมีเวลาเริ่ม ระบบจะคำนวณเวลาสิ้นสุดจากเวลาทัวร์ {total}',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -20,7 +20,7 @@ const toursModules = import.meta.glob<{ default: TranslationStrings }>('./*/tour
|
||||
const TOUR_DOMAINS: { file: string; generalKeyCount: number; modules: DomainModules }[] = [
|
||||
{
|
||||
file: 'tours',
|
||||
generalKeyCount: 1183,
|
||||
generalKeyCount: 1189,
|
||||
modules: toursModules,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1309,5 +1309,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Mesafe, tırmanış ve inişten yürüyüş tabelası kuralına (DIN 33466) göre hesaplandı, molalar hariç.',
|
||||
'tours.estimate.basisPace': 'Mesafeden, girilen tempoyla hesaplandı.',
|
||||
'tours.dayTime.workedOut': '{time} saatinde biter, başlangıç ve {total} tur süresinden hesaplandı',
|
||||
'tours.dayTime.workedOutNextDay': 'Ertesi gün {time} saatinde biter, başlangıç ve {total} tur süresinden hesaplandı',
|
||||
'tours.dayTime.overrun': '{end} saatinde biter, {name} {time} saatinde başladıktan sonra',
|
||||
'tours.dayTime.nextItem': 'sonraki öğe',
|
||||
'tours.dayTime.formHint': 'Boş bırakılırsa bitiş hesaplanır: {time}, başlangıç artı {total} tur süresi.',
|
||||
'tours.dayTime.formNoStart': 'Bir başlangıç saatiyle bitiş, {total} tur süresinden hesaplanır.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1358,5 +1358,12 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Розраховано за відстанню, набором і скиданням висоти за правилом туристичних вказівників (DIN 33466), без перерв.',
|
||||
'tours.estimate.basisPace': 'Розраховано за відстанню при введеному темпі.',
|
||||
'tours.dayTime.workedOut': 'Закінчується о {time}, розраховано за початком і часом туру {total}',
|
||||
'tours.dayTime.workedOutNextDay':
|
||||
'Закінчується наступного дня о {time}, розраховано за початком і часом туру {total}',
|
||||
'tours.dayTime.overrun': 'Закінчується о {end}, після початку {name} о {time}',
|
||||
'tours.dayTime.nextItem': 'наступний пункт',
|
||||
'tours.dayTime.formHint': 'Якщо залишити порожнім, кінець розраховується: {time}, початок плюс час туру {total}.',
|
||||
'tours.dayTime.formNoStart': 'З часом початку кінець розраховується за часом туру {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1291,5 +1291,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.basisDin':
|
||||
'Tính từ quãng đường, độ cao lên và xuống theo quy tắc biển chỉ dẫn leo núi (DIN 33466), không tính nghỉ.',
|
||||
'tours.estimate.basisPace': 'Tính từ quãng đường theo tốc độ đã nhập.',
|
||||
'tours.dayTime.workedOut': 'Kết thúc lúc {time}, tính từ giờ bắt đầu và thời gian tour {total}',
|
||||
'tours.dayTime.workedOutNextDay': 'Kết thúc lúc {time} ngày hôm sau, tính từ giờ bắt đầu và thời gian tour {total}',
|
||||
'tours.dayTime.overrun': 'Kết thúc lúc {end}, sau khi {name} bắt đầu lúc {time}',
|
||||
'tours.dayTime.nextItem': 'mục tiếp theo',
|
||||
'tours.dayTime.formHint': 'Để trống thì giờ kết thúc được tính: {time}, giờ bắt đầu cộng thời gian tour {total}.',
|
||||
'tours.dayTime.formNoStart': 'Khi có giờ bắt đầu, giờ kết thúc được tính từ thời gian tour {total}.',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1209,5 +1209,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.noHeights': '軌跡沒有海拔資料,無法估算時間。',
|
||||
'tours.estimate.basisDin': '依健行路標規則(DIN 33466)由距離、爬升與下降計算,不含休息。',
|
||||
'tours.estimate.basisPace': '依距離與輸入的配速計算。',
|
||||
'tours.dayTime.workedOut': '{time} 結束,依開始時間與行程時間 {total} 推算',
|
||||
'tours.dayTime.workedOutNextDay': '隔天 {time} 結束,依開始時間與行程時間 {total} 推算',
|
||||
'tours.dayTime.overrun': '{end} 結束,晚於 {name} 的開始時間 {time}',
|
||||
'tours.dayTime.nextItem': '下一項',
|
||||
'tours.dayTime.formHint': '留空時會推算結束時間:{time},即開始時間加上行程時間 {total}。',
|
||||
'tours.dayTime.formNoStart': '填入開始時間後,會依行程時間 {total} 推算結束時間。',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
@@ -1210,5 +1210,11 @@ const tours: TranslationStrings = {
|
||||
'tours.estimate.noHeights': '轨迹没有海拔数据,无法估算用时。',
|
||||
'tours.estimate.basisDin': '按徒步路标规则(DIN 33466)根据距离、爬升和下降计算,不含休息。',
|
||||
'tours.estimate.basisPace': '按距离和输入的配速计算。',
|
||||
'tours.dayTime.workedOut': '{time} 结束,根据开始时间和行程用时 {total} 推算',
|
||||
'tours.dayTime.workedOutNextDay': '次日 {time} 结束,根据开始时间和行程用时 {total} 推算',
|
||||
'tours.dayTime.overrun': '{end} 结束,晚于 {name} 的开始时间 {time}',
|
||||
'tours.dayTime.nextItem': '下一项',
|
||||
'tours.dayTime.formHint': '留空时会推算结束时间:{time},即开始时间加上行程用时 {total}。',
|
||||
'tours.dayTime.formNoStart': '填写开始时间后,会根据行程用时 {total} 推算结束时间。',
|
||||
};
|
||||
export default tours;
|
||||
|
||||
Reference in New Issue
Block a user