diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json
index 74d26cc5f..3d3ec7809 100644
--- a/client/scripts/feature-pairs.json
+++ b/client/scripts/feature-pairs.json
@@ -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"],
diff --git a/client/src/components/Planner/DayPlanPlaceSubline.tsx b/client/src/components/Planner/DayPlanPlaceSubline.tsx
index 2a63720aa..7fe30f007 100644
--- a/client/src/components/Planner/DayPlanPlaceSubline.tsx
+++ b/client/src/components/Planner/DayPlanPlaceSubline.tsx
@@ -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.
- {place.place_time && (
+ {place.place_time && dayRowTour && (
+ // TIME-02: a tour's end, typed or worked out from its start and its time.
+
+
+
+ )}
+ {place.place_time && !dayRowTour && (
{formatTime(place.place_time, locale, timeFormat)}
{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
diff --git a/client/src/components/Planner/DayPlanSidebar.tours.test.tsx b/client/src/components/Planner/DayPlanSidebar.tours.test.tsx
index 6f327caef..b50a2e2b5 100644
--- a/client/src/components/Planner/DayPlanSidebar.tours.test.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tours.test.tsx
@@ -394,7 +394,12 @@ function walkTour(place: { id: number; name: string }, overrides: Partial void }) {
+function walkDayProps(extra: {
+ reservations?: Reservation[];
+ withCafe?: boolean;
+ onPlanTransitLeg?: () => void;
+ firstWalk?: Partial;
+}) {
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();
+
+ 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();
+
+ 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');
+ });
});
diff --git a/client/src/components/Planner/DayPlanSidebar.tsx b/client/src/components/Planner/DayPlanSidebar.tsx
index f02c09473..0c04bc74b 100644
--- a/client/src/components/Planner/DayPlanSidebar.tsx
+++ b/client/src/components/Planner/DayPlanSidebar.tsx
@@ -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
)}
+ {/* TIME-02: the tour's end runs past the start of the day's next timed item. */}
+ {dayRowTour && }
{assignment.notes && (
// Day-specific note on this stop (#2163) — one muted
// caption line so the timeline shows the note exists
diff --git a/client/src/components/Planner/PlaceFormModal.tsx b/client/src/components/Planner/PlaceFormModal.tsx
index 4fb97c966..fa33afb50 100644
--- a/client/src/components/Planner/PlaceFormModal.tsx
+++ b/client/src/components/Planner/PlaceFormModal.tsx
@@ -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}
/>
@@ -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
/>
+ a.id === assignmentId)} tour={tour} />
{hasTimeError && (
diff --git a/client/src/components/Tours/TourDayTimeParts.test.tsx b/client/src/components/Tours/TourDayTimeParts.test.tsx
new file mode 100644
index 000000000..a1c385c06
--- /dev/null
+++ b/client/src/components/Tours/TourDayTimeParts.test.tsx
@@ -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
) => `${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();
+ 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();
+ 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();
+ 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();
+ 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();
+ 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();
+ expect(container).toBeEmptyDOMElement();
+ unmount();
+ render();
+ 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();
+ expect(screen.getByTestId('tour-day-end-hint')).toHaveTextContent('12:00');
+ });
+});
diff --git a/client/src/components/Tours/TourDayTimeParts.tsx b/client/src/components/Tours/TourDayTimeParts.tsx
new file mode 100644
index 000000000..aefd55e3b
--- /dev/null
+++ b/client/src/components/Tours/TourDayTimeParts.tsx
@@ -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;
+
+/** A total as said beside a worked-out end: `≈` for an estimate, `≥` where a stay is not known yet. */
+function totalText(time: Pick): 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)} → `}
+
+
+ {`${mark}${clock(time.end)}${time.nextDay ? ' +1' : ''}`}
+
+
+ >
+ );
+}
+
+/**
+ * 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 (
+
+
+ {said}
+
+ );
+}
+
+/**
+ * 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 | 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 (
+
+ {hint}
+
+ );
+}
diff --git a/client/src/components/Tours/tourDayTime.test.ts b/client/src/components/Tours/tourDayTime.test.ts
new file mode 100644
index 000000000..9b87606b8
--- /dev/null
+++ b/client/src/components/Tours/tourDayTime.test.ts
@@ -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[0])).toEqual({
+ start: '09:00',
+ end: '10:00',
+ stageKey: null,
+ });
+ });
+});
+
+const placeItem = (id: number, name: string, time: string | null, extra: Record = {}): 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();
+ });
+});
diff --git a/client/src/components/Tours/tourDayTime.ts b/client/src/components/Tours/tourDayTime.ts
new file mode 100644
index 000000000..7506fc1e5
--- /dev/null
+++ b/client/src/components/Tours/tourDayTime.ts
@@ -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
+): 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 | 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));
+}
diff --git a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx
index 38b5ea645..1198396a4 100644
--- a/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx
+++ b/client/src/mobile/screens/trip/plan/MPlanTimeline.tsx
@@ -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)}
diff --git a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tours.test.tsx b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tours.test.tsx
index 0e5b7001d..7695da98f 100644
--- a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tours.test.tsx
+++ b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tours.test.tsx
@@ -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();
+ // 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();
diff --git a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx
index 4bafa7a4b..2b0ea7819 100644
--- a/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx
+++ b/client/src/mobile/screens/trip/plan/MPlanTimelineRows.tsx
@@ -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 &&
}
{(time || sub) && (
- {time && {time}}
+ {time && {tour ? : time}}
{sub && {sub}}
{bookingLines[0]?.code && {bookingLines[0].code}}
@@ -225,6 +229,7 @@ export function PlaceRow({ assignment, fullPlace, tour, linkedReservations, chro
{assignment.notes}
)}
+
{chrome.editing && (
diff --git a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts
index d8fa847a5..1c88767e3 100644
--- a/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts
+++ b/client/src/mobile/screens/trip/plan/useMPlanTimeline.ts
@@ -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,
}
}
diff --git a/client/src/mobile/screens/trip/sheets/PlTimeFields.tsx b/client/src/mobile/screens/trip/sheets/PlTimeFields.tsx
index 6e63f8b26..cb5e8c180 100644
--- a/client/src/mobile/screens/trip/sheets/PlTimeFields.tsx
+++ b/client/src/mobile/screens/trip/sheets/PlTimeFields.tsx
@@ -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 (
@@ -55,6 +59,7 @@ export default function PlTimeFields({
)}
+
{hasTimeError && (
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index fd28cb067..87c791f83 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -831,7 +831,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
/>
)}
-
{ 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} />
+ { 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} />
setShowTripForm(false)}
diff --git a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx
index fe1599d42..e49008147 100644
--- a/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx
+++ b/client/tests/unit/mobile/trip/MPlanTimeline.test.tsx
@@ -124,6 +124,7 @@ function buildTl(over: Record = {}): 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', () => {
diff --git a/client/tests/unit/mobile/trip/PlTimeFields.test.tsx b/client/tests/unit/mobile/trip/PlTimeFields.test.tsx
index 7bfa12667..815f60fb5 100644
--- a/client/tests/unit/mobile/trip/PlTimeFields.test.tsx
+++ b/client/tests/unit/mobile/trip/PlTimeFields.test.tsx
@@ -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(
+ ,
+ )
+ }
+
+ 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()
+ })
})
diff --git a/client/tests/unit/mobile/trip/useMPlanTimeline.transit.test.tsx b/client/tests/unit/mobile/trip/useMPlanTimeline.transit.test.tsx
index 6cfdb74cb..9e04c6eb4 100644
--- a/client/tests/unit/mobile/trip/useMPlanTimeline.transit.test.tsx
+++ b/client/tests/unit/mobile/trip/useMPlanTimeline.transit.test.tsx
@@ -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()
+ })
})
diff --git a/shared/src/i18n/ar/tours.ts b/shared/src/i18n/ar/tours.ts
index 0d547eda5..e91f94ecc 100644
--- a/shared/src/i18n/ar/tours.ts
+++ b/shared/src/i18n/ar/tours.ts
@@ -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;
diff --git a/shared/src/i18n/az/tours.ts b/shared/src/i18n/az/tours.ts
index ed1fa43f1..85c2bf5c8 100644
--- a/shared/src/i18n/az/tours.ts
+++ b/shared/src/i18n/az/tours.ts
@@ -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;
diff --git a/shared/src/i18n/br/tours.ts b/shared/src/i18n/br/tours.ts
index d269719a3..d6d62307c 100644
--- a/shared/src/i18n/br/tours.ts
+++ b/shared/src/i18n/br/tours.ts
@@ -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;
diff --git a/shared/src/i18n/ca/tours.ts b/shared/src/i18n/ca/tours.ts
index d2b422495..9b5f921b2 100644
--- a/shared/src/i18n/ca/tours.ts
+++ b/shared/src/i18n/ca/tours.ts
@@ -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;
diff --git a/shared/src/i18n/cs/tours.ts b/shared/src/i18n/cs/tours.ts
index 6ecc24212..d5823517a 100644
--- a/shared/src/i18n/cs/tours.ts
+++ b/shared/src/i18n/cs/tours.ts
@@ -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;
diff --git a/shared/src/i18n/de/tours.ts b/shared/src/i18n/de/tours.ts
index 51b05cf58..8f275aa96 100644
--- a/shared/src/i18n/de/tours.ts
+++ b/shared/src/i18n/de/tours.ts
@@ -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;
diff --git a/shared/src/i18n/en/tours.ts b/shared/src/i18n/en/tours.ts
index f79c43309..52c21a41d 100644
--- a/shared/src/i18n/en/tours.ts
+++ b/shared/src/i18n/en/tours.ts
@@ -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;
diff --git a/shared/src/i18n/es/tours.ts b/shared/src/i18n/es/tours.ts
index 81c6909a6..48fdc1d24 100644
--- a/shared/src/i18n/es/tours.ts
+++ b/shared/src/i18n/es/tours.ts
@@ -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;
diff --git a/shared/src/i18n/et/tours.ts b/shared/src/i18n/et/tours.ts
index daaef2522..341f7403a 100644
--- a/shared/src/i18n/et/tours.ts
+++ b/shared/src/i18n/et/tours.ts
@@ -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;
diff --git a/shared/src/i18n/fr/tours.ts b/shared/src/i18n/fr/tours.ts
index 416c81179..f2f46a173 100644
--- a/shared/src/i18n/fr/tours.ts
+++ b/shared/src/i18n/fr/tours.ts
@@ -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;
diff --git a/shared/src/i18n/gr/tours.ts b/shared/src/i18n/gr/tours.ts
index bbe937af5..60cb9e5e5 100644
--- a/shared/src/i18n/gr/tours.ts
+++ b/shared/src/i18n/gr/tours.ts
@@ -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;
diff --git a/shared/src/i18n/hu/tours.ts b/shared/src/i18n/hu/tours.ts
index 7bc13fae7..4cc9f2f63 100644
--- a/shared/src/i18n/hu/tours.ts
+++ b/shared/src/i18n/hu/tours.ts
@@ -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;
diff --git a/shared/src/i18n/id/tours.ts b/shared/src/i18n/id/tours.ts
index a73ad0d4f..eb3107934 100644
--- a/shared/src/i18n/id/tours.ts
+++ b/shared/src/i18n/id/tours.ts
@@ -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;
diff --git a/shared/src/i18n/it/tours.ts b/shared/src/i18n/it/tours.ts
index 54f6d02da..6cb24e79d 100644
--- a/shared/src/i18n/it/tours.ts
+++ b/shared/src/i18n/it/tours.ts
@@ -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;
diff --git a/shared/src/i18n/ja/tours.ts b/shared/src/i18n/ja/tours.ts
index 6d48601ff..6ffbb758e 100644
--- a/shared/src/i18n/ja/tours.ts
+++ b/shared/src/i18n/ja/tours.ts
@@ -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;
diff --git a/shared/src/i18n/ko/tours.ts b/shared/src/i18n/ko/tours.ts
index 603b5c965..b0f5f3f14 100644
--- a/shared/src/i18n/ko/tours.ts
+++ b/shared/src/i18n/ko/tours.ts
@@ -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;
diff --git a/shared/src/i18n/nl/tours.ts b/shared/src/i18n/nl/tours.ts
index ca08dd44c..d3057064b 100644
--- a/shared/src/i18n/nl/tours.ts
+++ b/shared/src/i18n/nl/tours.ts
@@ -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;
diff --git a/shared/src/i18n/pl/tours.ts b/shared/src/i18n/pl/tours.ts
index 5816666ff..616b6a801 100644
--- a/shared/src/i18n/pl/tours.ts
+++ b/shared/src/i18n/pl/tours.ts
@@ -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;
diff --git a/shared/src/i18n/ru/tours.ts b/shared/src/i18n/ru/tours.ts
index c701a7099..ad5d9915b 100644
--- a/shared/src/i18n/ru/tours.ts
+++ b/shared/src/i18n/ru/tours.ts
@@ -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;
diff --git a/shared/src/i18n/sk/tours.ts b/shared/src/i18n/sk/tours.ts
index 22ed453a1..7f454ddf9 100644
--- a/shared/src/i18n/sk/tours.ts
+++ b/shared/src/i18n/sk/tours.ts
@@ -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;
diff --git a/shared/src/i18n/sv/tours.ts b/shared/src/i18n/sv/tours.ts
index 2acfa6aaf..a4ad4fe14 100644
--- a/shared/src/i18n/sv/tours.ts
+++ b/shared/src/i18n/sv/tours.ts
@@ -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;
diff --git a/shared/src/i18n/th/tours.ts b/shared/src/i18n/th/tours.ts
index 736f0fcde..f2b34d114 100644
--- a/shared/src/i18n/th/tours.ts
+++ b/shared/src/i18n/th/tours.ts
@@ -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;
diff --git a/shared/src/i18n/tours-locales.spec.ts b/shared/src/i18n/tours-locales.spec.ts
index 6ac0e97d0..4ce8227f6 100644
--- a/shared/src/i18n/tours-locales.spec.ts
+++ b/shared/src/i18n/tours-locales.spec.ts
@@ -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,
},
{
diff --git a/shared/src/i18n/tr/tours.ts b/shared/src/i18n/tr/tours.ts
index 72fc35315..1c1cbb125 100644
--- a/shared/src/i18n/tr/tours.ts
+++ b/shared/src/i18n/tr/tours.ts
@@ -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;
diff --git a/shared/src/i18n/uk/tours.ts b/shared/src/i18n/uk/tours.ts
index 624c43861..358cf7469 100644
--- a/shared/src/i18n/uk/tours.ts
+++ b/shared/src/i18n/uk/tours.ts
@@ -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;
diff --git a/shared/src/i18n/vi/tours.ts b/shared/src/i18n/vi/tours.ts
index 6f051f920..950f62595 100644
--- a/shared/src/i18n/vi/tours.ts
+++ b/shared/src/i18n/vi/tours.ts
@@ -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;
diff --git a/shared/src/i18n/zh-TW/tours.ts b/shared/src/i18n/zh-TW/tours.ts
index 039448b33..9d1c3770f 100644
--- a/shared/src/i18n/zh-TW/tours.ts
+++ b/shared/src/i18n/zh-TW/tours.ts
@@ -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;
diff --git a/shared/src/i18n/zh/tours.ts b/shared/src/i18n/zh/tours.ts
index e04470bb6..a89417981 100644
--- a/shared/src/i18n/zh/tours.ts
+++ b/shared/src/i18n/zh/tours.ts
@@ -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;