fix(tours): keep unknown actual tracks unknown and name every track a link replaces (ACT-01)

A tour detail that cannot be read (offline without a cached copy, or a server
error) no longer shows "No actual track yet" while the trip list names tracks
of the tour; the section says they could not be loaded and offers no link over
them.

The day a track was recorded on is the viewer's calendar day of its first time
stamp, not the UTC one, in the tour detail, the link dialog and the GPX
preview alike, through one helper.

Taking a tour off a day keeps its actual track without a day, so "no day" can
hold several. A change now starts on the track it was asked for, and the link
dialog names every track it replaces, as the server clears all of them. The
migration, repository, service and MCP texts say so.

The refusal for linking a tour to itself or a track with tracks of its own now
reads "This track cannot be linked to this tour", which is true of both, and
the offline helper of the tour cache sits above the doc of replaceTripTours
again.
This commit is contained in:
Maurice
2026-10-20 12:50:38 +02:00
parent d0cc6fc2f4
commit a8be1c5b45
46 changed files with 243 additions and 100 deletions
@@ -5,7 +5,6 @@ import { OFM_POSITRON } from '../../constants/mapDefaults';
import { useTileUrl } from '../../hooks/useTileUrl';
import { useTranslation } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore';
import { formatDate } from '../../utils/formatters';
import { formatDistance, formatElevation } from '../../utils/units';
import { MapViewAuto } from '../Map/MapViewAuto';
import { SoftPill } from '../Planner/planParts';
@@ -20,7 +19,7 @@ import {
fs,
NEUTRAL_TINT,
} from '../shared/DialogShell';
import { recordedMinutes } from './activity/tourActivityModel';
import { recordedDay, recordedMinutes } from './activity/tourActivityModel';
import { importFields, importMissing, useGpxImportChoices } from './gpxImportChoices';
import { GpxImportTypeSection } from './GpxImportTypeSection';
import { TourNotice } from './tourParts';
@@ -34,11 +33,12 @@ const BADGE =
function RecordedBadge({ track }: { track: GpxPreviewFile['tracks'][number] }) {
const { locale } = useTranslation();
const minutes = recordedMinutes(track.started_at, track.ended_at);
if (minutes === null || !track.started_at) return null;
const day = recordedDay(track.started_at, locale);
if (minutes === null || !day) return null;
return (
<span className={`${BADGE} gap-1`} data-testid="gpx-preview-recorded">
<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />
{formatDate(track.started_at.slice(0, 10), locale)}
{day}
<span className="text-content-faint">{formatPlannedTourDuration(minutes)}</span>
</span>
);
@@ -37,7 +37,7 @@ function assignment(overrides: Partial<TrackAssignment> = {}): TrackAssignment {
selected: 20,
choose: vi.fn(),
choice: watch,
replaces: null,
replaces: [],
gpxImport: {
importing: false,
preview: null,
@@ -93,7 +93,7 @@ describe('TourActivityDialog (ACT-01)', () => {
});
it('FE-ACT-DIALOG-003: nothing to link until a free recording is chosen; what it replaces is said', () => {
hook.assignment = assignment({ ready: false, choice: null, selected: null, replaces: 'Old export' });
hook.assignment = assignment({ ready: false, choice: null, selected: null, replaces: ['Old export'] });
const current = state();
show(current);
expect(screen.getByTestId('tour-activity-confirm')).toBeDisabled();
@@ -23,7 +23,7 @@ import { PillSelect } from '../../shared/dialogParts';
import TourGpxPreviewDialog from '../TourGpxPreviewDialog';
import { CardAction, TourMetricFields, TourNotice } from '../tourParts';
import { formatPlannedTourDuration, hikeSourceBadgeLabel } from '../tourPresentation';
import { recordedMinutes, type TourExecution, type TrackCandidate } from './tourActivityModel';
import { recordedDay, recordedMinutes, type TourExecution, type TrackCandidate } from './tourActivityModel';
import type { TourActivities } from './useTourActivities';
import { useTrackAssignment, type TrackAssignment } from './useTrackAssignment';
@@ -44,7 +44,7 @@ function CandidateRow({
const unit = useSettingsStore((store) => store.settings.distance_unit) ?? 'metric';
const { tour, takenBy } = candidate;
const minutes = recordedMinutes(tour.track_started_at, tour.track_ended_at);
const recordedOn = tour.track_started_at ? formatDate(tour.track_started_at.slice(0, 10), locale) : null;
const recordedOn = recordedDay(tour.track_started_at, locale);
return (
<button
type="button"
@@ -269,9 +269,9 @@ export default function TourActivityDialog({
>
<div className="flex flex-col gap-2">
<CandidateList assignment={assignment} />
{assignment.replaces && (
{assignment.replaces.length > 0 && (
<TourNotice tone="warning" icon={<AlertTriangle size={13} strokeWidth={2} />}>
{t('tours.activity.replaces', { name: assignment.replaces })}
{t('tours.activity.replaces', { name: assignment.replaces.join(', ') })}
</TourNotice>
)}
</div>
@@ -1,4 +1,4 @@
// FE-ACT-SECTION-001 to FE-ACT-SECTION-005
// FE-ACT-SECTION-001 to FE-ACT-SECTION-006
// ACT-01 in the tour's detail, rendered without the translation provider so
// every text reads as its key: the actual tracks with their own values marked
// as such, a time only where the track measured one, and the actions only for
@@ -45,6 +45,7 @@ function activity(overrides: Partial<TourActivity['metrics']> = {}, assignmentId
function state(overrides: Partial<TourActivities> = {}): TourActivities {
return {
activities: [activity()],
unavailable: false,
canEdit: true,
busy: false,
assigning: null,
@@ -94,7 +95,7 @@ describe('TourActivitySection (ACT-01)', () => {
fireEvent.click(screen.getByRole('button', { name: /tours.activity.assign$/ }));
expect(current.openAssign).toHaveBeenCalledWith();
fireEvent.click(screen.getByRole('button', { name: /tours.activity.change/ }));
expect(current.openAssign).toHaveBeenCalledWith({ assignmentId: 11 });
expect(current.openAssign).toHaveBeenCalledWith({ assignmentId: 11, activityId: 4 });
fireEvent.click(screen.getByRole('button', { name: /tours.activity.remove/ }));
expect(current.askRemove).toHaveBeenCalledWith(current.activities?.[0]);
@@ -132,4 +133,17 @@ describe('TourActivitySection (ACT-01)', () => {
const track = show(true, { ...tour, activity_of: 3 });
expect(track.container).toBeEmptyDOMElement();
});
it('FE-ACT-SECTION-006: tracks that could not be read are said to be unavailable, never shown as none, and nothing is linked over them', () => {
hook.state = state({ activities: [], unavailable: true });
const { unmount } = show();
expect(screen.getByRole('status')).toHaveTextContent('tours.activity.unavailable');
expect(screen.queryByTestId('tour-activity-empty')).toBeNull();
expect(screen.getByRole('button', { name: /tours.activity.assign$/ })).toBeDisabled();
unmount();
hook.state = state({ activities: [], unavailable: true, canEdit: false });
show(false);
expect(screen.getByText('tours.activity.unavailable')).toBeInTheDocument();
});
});
@@ -1,10 +1,9 @@
import type { TourActivity, TourListItem } from '@trek/shared';
import { CalendarDays, Clock, FileDown, Footprints, Plus, RefreshCw, Unlink } from 'lucide-react';
import { CalendarDays, Clock, CloudOff, FileDown, Footprints, Plus, RefreshCw, Unlink } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
import type { AssignmentsMap, Day } from '../../../types';
import { formatDate } from '../../../utils/formatters';
import { BOX } from '../../Planner/bookings/bookingParts';
import { SoftPill } from '../../Planner/planParts';
import ConfirmDialog from '../../shared/ConfirmDialog';
@@ -12,10 +11,10 @@ import { DialogSection, fs } from '../../shared/DialogShell';
import ElevationProfile from '../../shared/ElevationProfile';
import { Tooltip } from '../../shared/Tooltip';
import { dayMenuLabel } from '../tourDays';
import { CardAction, TourMetricFields, type TourMetricField } from '../tourParts';
import { CardAction, TourMetricFields, TourNotice, type TourMetricField } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import TourActivityDialog from './TourActivityDialog';
import { analysisOfLine, executionOf, executionsOf, type TourExecution } from './tourActivityModel';
import { analysisOfLine, executionOf, executionsOf, recordedDay, type TourExecution } from './tourActivityModel';
import { useTourActivities, type TourActivities } from './useTourActivities';
type Translate = (key: string, params?: Record<string, unknown>) => string;
@@ -50,7 +49,7 @@ function ActivityBadges({ activity, execution }: { activity: TourActivity; execu
const { t, locale } = useTranslation();
const dayLabel = execution.day ? t('dayplan.dayN', { n: execution.index + 1 }) : t('tours.activity.noDay');
const dayTitle = execution.day ? dayMenuLabel(execution.day, execution.index, t, locale) : dayLabel;
const recordedOn = activity.metrics.started_at ? formatDate(activity.metrics.started_at.slice(0, 10), locale) : null;
const recordedOn = recordedDay(activity.metrics.started_at, locale);
const source =
activity.track.file_name ?? t(activity.track.origin === 'wanderer' ? 'tours.badge.wanderer' : 'tours.badge.gpx');
return (
@@ -122,7 +121,7 @@ function ActivityCard({
<footer className="flex flex-wrap gap-1.5 border-t border-edge-faint px-2.5 py-2">
<CardAction
icon={<RefreshCw size={12} strokeWidth={2.2} />}
onClick={() => state.openAssign({ assignmentId: activity.assignment_id })}
onClick={() => state.openAssign({ assignmentId: activity.assignment_id, activityId: activity.id })}
disabled={state.busy}
>
{t('tours.activity.change')}
@@ -157,6 +156,32 @@ function NoActivity() {
);
}
/** What the section holds: the tracks, the empty place for one, or word that the tour's tracks could not be read. */
function ActivityList({ state, executions }: { state: TourActivities; executions: readonly TourExecution[] }) {
const { t } = useTranslation();
const activities = state.activities ?? [];
if (state.unavailable) {
return (
<TourNotice role="status" icon={<CloudOff size={13} strokeWidth={2} />}>
{t('tours.activity.unavailable')}
</TourNotice>
);
}
if (activities.length === 0) return <NoActivity />;
return (
<div className="flex flex-col gap-2">
{activities.map((activity) => (
<ActivityCard
key={activity.id}
activity={activity}
execution={executionOf(executions, activity.assignment_id)}
state={state}
/>
))}
</div>
);
}
interface TourActivitySectionProps {
tripId: number;
tour: TourListItem;
@@ -184,8 +209,10 @@ export default function TourActivitySection({
const { t } = useTranslation();
const executions = useMemo(() => executionsOf(tour.place_id, days, assignments), [assignments, days, tour.place_id]);
const state = useTourActivities({ tripId, tour, executions, canEdit, onToursChanged });
const { activities } = state;
if (activities === null || tour.activity_of != null || (activities.length === 0 && !canEdit)) return null;
const { activities, unavailable } = state;
if (activities === null || tour.activity_of != null || (activities.length === 0 && !canEdit && !unavailable)) {
return null;
}
return (
<DialogSection
className="flex-none"
@@ -197,7 +224,7 @@ export default function TourActivitySection({
<CardAction
icon={<Plus size={12} strokeWidth={2.2} />}
onClick={() => state.openAssign()}
disabled={state.busy}
disabled={state.busy || unavailable}
>
{t('tours.activity.assign')}
</CardAction>
@@ -206,20 +233,7 @@ export default function TourActivitySection({
)
}
>
{activities.length === 0 ? (
<NoActivity />
) : (
<div className="flex flex-col gap-2">
{activities.map((activity) => (
<ActivityCard
key={activity.id}
activity={activity}
execution={executionOf(executions, activity.assignment_id)}
state={state}
/>
))}
</div>
)}
<ActivityList state={state} executions={executions} />
{state.assigning && (
<TourActivityDialog
tripId={tripId}
@@ -1,9 +1,10 @@
// FE-ACT-MODEL-001 to FE-ACT-MODEL-008
// FE-ACT-MODEL-001 to FE-ACT-MODEL-009
// ACT-01, the pure half of a tour's actual tracks: its executions, the
// recordings that can be linked, the GPX preset, the time and the refusals.
import type { TourActivity, TourListItem } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import type { Assignment, AssignmentsMap, Day } from '../../../types';
import { formatDate } from '../../../utils/formatters';
import {
analysisOfLine,
executionOf,
@@ -11,6 +12,7 @@ import {
importFieldsFor,
inDayOrder,
isRecording,
recordedDay,
recordedMinutes,
refusalOf,
trackCandidates,
@@ -144,4 +146,14 @@ describe('measures', () => {
expect(refusalOf(new Error('offline'))).toBeNull();
expect(refusalOf(null)).toBeNull();
});
it('FE-ACT-MODEL-009: the day recorded is the viewer’s calendar day of the first time, not the UTC one', () => {
// 22:30 UTC on the 1st is the morning of the 2nd in Tokyo and still the afternoon of the 1st in Los Angeles.
expect(recordedDay('2026-07-01T22:30:00Z', 'en', 'Asia/Tokyo')).toBe(formatDate('2026-07-02', 'en'));
expect(recordedDay('2026-07-01T22:30:00Z', 'en', 'America/Los_Angeles')).toBe(formatDate('2026-07-01', 'en'));
// 01:30 UTC on the 2nd is still the evening of the 1st in New York.
expect(recordedDay('2026-07-02T01:30:00Z', 'en', 'America/New_York')).toBe(formatDate('2026-07-01', 'en'));
expect(recordedDay(null, 'en')).toBeNull();
expect(recordedDay('nonsense', 'en')).toBeNull();
});
});
@@ -6,6 +6,7 @@ import {
type TourListItem,
} from '@trek/shared';
import type { AssignmentsMap, Day } from '../../../types';
import { formatDate } from '../../../utils/formatters';
import { analyzeRouteGeometry, type RouteGeometryAnalysis } from '../../../utils/routeGeometry';
/**
@@ -119,6 +120,21 @@ export function recordedMinutes(startedAt?: string | null, endedAt?: string | nu
return Number.isFinite(start) && Number.isFinite(end) && end > start ? Math.round((end - start) / 60_000) : null;
}
/**
* The day a track was recorded on, as the viewer's calendar reads its first
* time stamp: a hike started at 07:00 in Tokyo is that morning, not the UTC
* evening before. `timeZone` is only there for tests; null without a time.
*/
export function recordedDay(startedAt: string | null | undefined, locale: string, timeZone?: string): string | null {
const instant = startedAt ? new Date(startedAt) : null;
if (!instant || Number.isNaN(instant.getTime())) return null;
// en-CA writes the calendar day as YYYY-MM-DD, the form formatDate reads.
const day = new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(
instant
);
return formatDate(day, locale);
}
/** Why the server refused an assignment, when it named a reason this build knows; null for anything else. */
export function refusalOf(error: unknown): TourActivityRefusal | null {
const code = (error as { response?: { data?: { code?: unknown } } } | null)?.response?.data?.code;
@@ -1,4 +1,4 @@
// FE-ACT-HOOK-001 to FE-ACT-HOOK-005
// FE-ACT-HOOK-001 to FE-ACT-HOOK-006
// ACT-01: the actual tracks read with the tour's detail, linked from the
// dialog and unlinked after confirming, each write telling the lists and the
// user, and a refusal named in the user's words without changing anything.
@@ -59,10 +59,24 @@ describe('useTourActivities', () => {
await waitFor(() => expect(repo.detail).toHaveBeenCalledTimes(2));
});
it('FE-ACT-HOOK-002: a detail that cannot be had shows none', async () => {
it('FE-ACT-HOOK-002: a detail that cannot be had shows none while the list names none either', async () => {
repo.detail.mockRejectedValueOnce(new Error('offline'));
const { result } = render();
await waitFor(() => expect(result.current.activities).toEqual([]));
expect(result.current.unavailable).toBe(false);
});
it('FE-ACT-HOOK-006: a detail that cannot be had while the list names tracks says they are unavailable, not that there are none', async () => {
repo.detail.mockRejectedValueOnce(new Error('offline'));
const linked = { ...tour, activity_tracks: [{ id: 3, track_place_id: 22, assignment_id: null }] } as TourListItem;
const { result } = renderHook(() =>
useTourActivities({ tripId: 1, tour: linked, executions, canEdit: true, onToursChanged: vi.fn() })
);
await waitFor(() => expect(result.current.activities).toEqual([]));
expect(result.current.unavailable).toBe(true);
// Nothing can be replaced that could not be read.
act(() => result.current.openAssign());
expect(result.current.assigning).toBeNull();
});
it('FE-ACT-HOOK-003: links the confirmed track, takes the answer in, tells the lists and closes the dialog', async () => {
@@ -15,9 +15,14 @@ interface TourActivitiesOptions {
onToursChanged?: (placeIds: number[]) => Promise<void> | void;
}
/** Which execution the assignment dialog is open for: `undefined` lets it pick the first free one. */
/**
* Which execution the assignment dialog is open for: `undefined` lets it pick
* the first free one. `activityId` is the track a change starts from, as "no
* day" can hold several once the days they were on are gone.
*/
export interface AssignTarget {
assignmentId?: number | null;
activityId?: number;
}
/** The refs a list item names, so the detail is read again when another client changed them. */
@@ -31,14 +36,16 @@ function refsKey(tour: TourListItem): string {
* the two changes the user can make: open the assignment for an execution,
* and take a recording off after confirming it. Both writes are online only,
* as the GPX import a recording comes through is; a refused one says why and
* changes nothing.
* changes nothing. A detail that cannot be read is not taken for an empty one:
* while the list names tracks of the tour, they are said to be unavailable.
*/
export function useTourActivities({ tripId, tour, executions, canEdit, onToursChanged }: TourActivitiesOptions) {
const { t } = useTranslation();
const toast = useToast();
const placeId = tour.place_id;
const refs = refsKey(tour);
const [loaded, setLoaded] = useState<{ placeId: number; activities: TourActivity[] } | null>(null);
// `activities: null` is a detail that could not be read, apart from one without tracks.
const [loaded, setLoaded] = useState<{ placeId: number; activities: TourActivity[] | null } | null>(null);
const [assigning, setAssigning] = useState<AssignTarget | null>(null);
const [removing, setRemoving] = useState<TourActivity | null>(null);
const [busy, setBusy] = useState(false);
@@ -50,17 +57,20 @@ export function useTourActivities({ tripId, tour, executions, canEdit, onToursCh
if (!controller.signal.aborted) setLoaded({ placeId, activities: detail.activities ?? [] });
},
() => {
if (!controller.signal.aborted) setLoaded({ placeId, activities: [] });
if (!controller.signal.aborted) setLoaded({ placeId, activities: null });
}
);
return () => controller.abort();
// `refs` is the list's word that the recordings changed elsewhere.
}, [tripId, placeId, refs]);
const current = loaded?.placeId === placeId ? loaded : null;
const activities = useMemo(
() => (loaded?.placeId === placeId ? inDayOrder(loaded.activities, executions) : null),
[executions, loaded, placeId]
() => (current ? inDayOrder(current.activities ?? [], executions) : null),
[current, executions]
);
// The list knows the tour has tracks, the detail that would show them did not come.
const unavailable = current !== null && current.activities === null && (tour.activity_tracks?.length ?? 0) > 0;
/** One write, its answer taken in, the lists told and the user too; false when it was refused. */
const apply = useCallback(
@@ -109,13 +119,15 @@ export function useTourActivities({ tripId, tour, executions, canEdit, onToursCh
}, [apply, busy, canEdit, placeId, removing, tripId]);
return {
/** Null while the detail loads. */
/** Null while the detail loads; empty, with `unavailable`, when it could not be read. */
activities,
/** The tour has tracks the detail could not be read for, so none can be shown or replaced. */
unavailable,
canEdit,
busy,
assigning,
openAssign: (target: AssignTarget = {}) => {
if (canEdit) setAssigning(target);
if (canEdit && !unavailable) setAssigning(target);
},
closeAssign: () => {
if (!busy) setAssigning(null);
@@ -1,4 +1,4 @@
// FE-ACT-ASSIGN-001 to FE-ACT-ASSIGN-004
// FE-ACT-ASSIGN-001 to FE-ACT-ASSIGN-005
// ACT-01, the link dialog's state: the day it starts on, the recordings of the
// trip to choose from, what a choice replaces, and a recording imported here
// chosen for the user but never linked by itself.
@@ -6,6 +6,7 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import type { TourActivity, TourListItem } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TourExecution } from './tourActivityModel';
import type { AssignTarget } from './useTourActivities';
import { useTrackAssignment } from './useTrackAssignment';
const repo = vi.hoisted(() => ({ list: vi.fn() }));
@@ -41,7 +42,7 @@ beforeEach(() => {
repo.list.mockResolvedValue({ tours: [tour, recording(20, 'Watch'), recording(21, 'Phone', { activity_of: 99 })] });
});
function render(activities: TourActivity[], target: { assignmentId?: number | null } = {}, onToursChanged = vi.fn()) {
function render(activities: TourActivity[], target: AssignTarget = {}, onToursChanged = vi.fn()) {
return renderHook(() =>
useTrackAssignment({ tripId: 1, tour, executions, activities, target, canEdit: true, onToursChanged })
);
@@ -56,7 +57,7 @@ describe('useTrackAssignment', () => {
expect(asked.result.current.assignmentId).toBe(31);
// A change starts on the track the day has, which therefore replaces nothing.
expect(asked.result.current.selected).toBe(21);
expect(asked.result.current.replaces).toBeNull();
expect(asked.result.current.replaces).toEqual([]);
await waitFor(() => expect(asked.result.current.candidates).not.toBeNull());
});
@@ -79,10 +80,20 @@ describe('useTrackAssignment', () => {
const { result } = render([linked(11, 21, 'Phone')], { assignmentId: 11 });
await waitFor(() => expect(result.current.candidates).not.toBeNull());
act(() => result.current.choose(20));
expect(result.current.replaces).toBe('Phone');
expect(result.current.replaces).toEqual(['Phone']);
act(() => result.current.chooseExecution(null));
expect(result.current.execution.day).toBeNull();
expect(result.current.replaces).toBeNull();
expect(result.current.replaces).toEqual([]);
});
it('FE-ACT-ASSIGN-005: "no day" left with several tracks once their days went: a change starts on the one asked for and names every one it replaces', async () => {
const noDay = [linked(null, 20, 'Watch'), linked(null, 21, 'Phone'), linked(11, 22, 'Bike computer')];
const { result } = render(noDay, { assignmentId: null, activityId: 21 });
await waitFor(() => expect(result.current.candidates).not.toBeNull());
expect(result.current.selected).toBe(21);
expect(result.current.replaces).toEqual(['Watch']);
act(() => result.current.choose(40));
expect(result.current.replaces).toEqual(['Watch', 'Phone']);
});
it('FE-ACT-ASSIGN-004: a recording imported here is chosen and the list read again; the lists are told', async () => {
@@ -9,7 +9,7 @@ interface TrackAssignmentOptions {
tripId: number;
tour: TourListItem;
executions: readonly TourExecution[];
/** The recordings the tour has now, to preset a change and to say what a choice replaces. */
/** The recordings the tour has now, to preset a change and to name what a choice replaces. */
activities: readonly TourActivity[];
target: AssignTarget;
canEdit: boolean;
@@ -46,8 +46,11 @@ export function useTrackAssignment({
const [assignmentId, setAssignmentId] = useState<number | null>(() =>
startingExecution(executions, activities, target)
);
const current = activities.find((activity) => activity.assignment_id === assignmentId) ?? null;
const [selected, setSelected] = useState<number | null>(() => current?.track.place_id ?? null);
const onExecution = activities.filter((activity) => activity.assignment_id === assignmentId);
const [selected, setSelected] = useState<number | null>(() => {
const changed = onExecution.find((activity) => activity.id === target.activityId) ?? onExecution[0];
return changed?.track.place_id ?? null;
});
const [tours, setTours] = useState<TourListItem[] | null>(null);
const [reads, setReads] = useState(0);
@@ -80,8 +83,11 @@ export function useTrackAssignment({
const candidates = useMemo(() => (tours ? trackCandidates(tours, tour.place_id) : null), [tour.place_id, tours]);
const choice = candidates?.find((candidate) => candidate.tour.place_id === selected) ?? null;
// A recording the execution has now, other than the one chosen, gives way to it.
const replaces = current && current.track.place_id !== selected ? current.track.name : null;
// Every recording the execution has now, other than the one chosen, gives way to it: a day has
// one, "no day" can have several once the days they were on are gone, and the server clears them all.
const replaces = onExecution
.filter((activity) => activity.track.place_id !== selected)
.map((activity) => activity.track.name);
return {
executions,
@@ -93,6 +99,7 @@ export function useTrackAssignment({
selected,
choose: setSelected,
choice,
/** The names of the tracks linking replaces; empty when it replaces none. */
replaces,
gpxImport,
importFields: importFieldsFor(tour),
+6 -6
View File
@@ -464,12 +464,6 @@ export async function replaceTripRows<T extends { id: number }>(table: TripScope
});
}
/**
* {@link replaceTripRows} for the Tours facet, which is keyed on `place_id`.
* A tour's cached control points survive the refresh only while its route
* reads the same: the list carries no waypoints, and a route edited elsewhere
* must not open offline with the old ones.
*/
/** The recorded tracks a cached detail holds, while the list still names the same ones (ACT-01). */
function keptActivities(previous: CachedTour | undefined, tour: TourListItem): Pick<CachedTour, 'activities'> {
const refs = (item: Pick<TourListItem, 'activity_tracks'>) =>
@@ -477,6 +471,12 @@ function keptActivities(previous: CachedTour | undefined, tour: TourListItem): P
return previous?.activities && refs(previous) === refs(tour) ? { activities: previous.activities } : {}
}
/**
* {@link replaceTripRows} for the Tours facet, which is keyed on `place_id`.
* A tour's cached control points survive the refresh only while its route
* reads the same: the list carries no waypoints, and a route edited elsewhere
* must not open offline with the old ones.
*/
export async function replaceTripTours(tripId: number, tours: TourListItem[]): Promise<void> {
await offlineDb.transaction('rw', offlineDb.tours, async () => {
const cached = new Map((await offlineDb.tours.where('trip_id').equals(tripId).toArray()).map(t => [t.place_id, t]));
@@ -7,11 +7,12 @@ import { Migration } from '@mikro-orm/migrations';
* name and provenance, and the planned tour keeps its line, measures and times
* untouched. `assignment_id` is the execution, the day assignment of the tour
* the recording belongs to; NULL for a tour done without a day. One recording
* documents one tour (`track_place_id UNIQUE`) and an execution has one
* recording (the partial unique index); an execution without a day is held to
* one by the service, which also refuses a tour as its own recording. Taking
* the tour off a day keeps the recording and only forgets the day; deleting
* either tour takes the assignment with it.
* documents one tour (`track_place_id UNIQUE`) and a day's execution has one
* recording (the partial unique index). Without a day there can be several:
* taking the tour off a day keeps its recording and only forgets the day, so
* it joins any that had none. Linking a recording to "no day" replaces all of
* them, and the app names each before it does. Deleting either tour takes the
* assignment with it.
* `assigned_by` names who assigned it, kept as unknown when that account goes.
*
* Not to be confused with `tours.recorded_route_geometry`: that is the line an
@@ -112,7 +112,8 @@ export class TourActivitiesRepository extends TrekRepository<TourActivities> {
/**
* Clears the way for a new assignment of `track_place_id` to this execution
* of the tour: the recording the execution had, and the execution this
* of the tour: the recordings the execution had (a day has one; "no day" can
* have several once the days they were on are gone), and the execution this
* recording had on the same tour. Inside the caller's transaction, before
* {@link insertActivity}.
*/
+1 -1
View File
@@ -56,7 +56,7 @@ export class TourActivitiesMcp {
@Tool({
name: 'assign_tour_track',
description:
'Assign a recorded track to a saved tour, as the way the tour was actually done, once the user has chosen the track and confirmed it. The track is another tour of the same trip that came in through the GPX import or from wanderer and was not planned over (list_tours: origin gpx or wanderer, has_waypoints false); it keeps its own name and source. assignment_id is the execution, one of the day assignments of the tour (find_tours_for_day and get_tour_plan show its days), or null for none; an execution that already has a track gets this one instead, and the track moves there if it was on another execution of the same tour. A track documents one tour only. The planned line, measures and times of the tour stay exactly as they are; what the recording measures (distance, ascent, descent, and the time from its first to its last point, only where the file carried times) is answered under activities and read again with get_tour_plan. Never assign a track the user did not pick.',
'Assign a recorded track to a saved tour, as the way the tour was actually done, once the user has chosen the track and confirmed it. The track is another tour of the same trip that came in through the GPX import or from wanderer and was not planned over (list_tours: origin gpx or wanderer, has_waypoints false); it keeps its own name and source. assignment_id is the execution, one of the day assignments of the tour (find_tours_for_day and get_tour_plan show its days), or null for none; an execution that already has a track gets this one instead (null replaces every track without a day, of which there can be several once their days are gone), and the track moves there if it was on another execution of the same tour. A track documents one tour only. The planned line, measures and times of the tour stay exactly as they are; what the recording measures (distance, ascent, descent, and the time from its first to its last point, only where the file carried times) is answered under activities and read again with get_tour_plan. Never assign a track the user did not pick.',
inputSchema: {
tripId: idSchema,
placeId: idSchema.describe('The place id of the planned tour, as list_tours gives it'),
@@ -39,8 +39,9 @@ export class TourActivitiesService {
/**
* The recording becomes the track this execution of the tour was done with,
* in place of the one it had; a recording already on another execution of
* the same tour moves here. Answers the tour as the detail reads it.
* in place of the ones it had (one for a day; "no day" can hold several once
* the days they were on are gone); a recording already on another execution
* of the same tour moves here. Answers the tour as the detail reads it.
*/
async assign(
tripId: string,
@@ -168,4 +168,16 @@ describe('TourActivitiesRepository', () => {
await deleteRows(t, Places, { id: watch.id });
expect(await countRows(t, TourActivities, { place: hike.id })).toBe(1);
});
it('ACT-REPO-008: a day removed beside a track without a day leaves two without one, and linking one to no day replaces both', async () => {
const { trip, hike, onDay1, watch, phone } = await fixture();
await makeTourActivity(t, hike.id, watch.id, { assignment: onDay1.id });
await makeTourActivity(t, hike.id, phone.id);
await deleteRows(t, DayAssignments, { id: onDay1.id });
expect(await repo.listForPlace(trip.id, hike.id)).toHaveLength(2);
// The link dialog names both as replaced, and the server clears both.
await repo.clearFor(hike.id, 999_999, null);
expect(await repo.listForPlace(trip.id, hike.id)).toEqual([]);
});
});
+2 -1
View File
@@ -1215,6 +1215,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'إضافة المسار الفعلي',
'tours.activity.assignHint': 'اربط المسار الذي سلكته فعلًا، من هذه الرحلة أو من ملف GPX',
'tours.activity.empty': 'لا يوجد مسار فعلي بعد',
'tours.activity.unavailable': 'تعذر تحميل المسارات الفعلية',
'tours.activity.badge': 'فعلي',
'tours.activity.badgeHint': 'قيم المسار الذي سلكته فعلًا، لا قيم الخطة',
'tours.activity.isTrackHint': 'المسار الفعلي لجولة في هذه الرحلة',
@@ -1242,7 +1243,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'ربط',
'tours.activity.refused.activity_track_not_recorded': 'لا يمكن ربط إلا ملف مستورد لم يُعَد تخطيطه',
'tours.activity.refused.activity_track_in_use': 'هذا المسار مرتبط بالفعل بجولة أخرى',
'tours.activity.refused.activity_track_is_tour': 'لهذا المسار مسارات فعلية خاصة به',
'tours.activity.refused.activity_track_is_tour': 'لا يمكن ربط هذا المسار بهذه الجولة',
'tours.activity.refused.activity_tour_is_track': 'هذه الجولة هي نفسها المسار الفعلي لجولة أخرى',
'tours.activity.refused.activity_assignment_not_of_tour': 'لم تعد الجولة في ذلك اليوم',
};
+2 -1
View File
@@ -1183,6 +1183,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Faktiki iz əlavə et',
'tours.activity.assignHint': 'Həqiqətən keçdiyin izi bu səyahətdən və ya GPX faylından bağla',
'tours.activity.empty': 'Hələ faktiki iz yoxdur',
'tours.activity.unavailable': 'Faktiki izlər yüklənə bilmədi',
'tours.activity.badge': 'Faktiki',
'tours.activity.badgeHint': 'Planın deyil, həqiqətən keçdiyin izin göstəriciləri',
'tours.activity.isTrackHint': 'Bu səyahətdəki turun faktiki izi',
@@ -1210,7 +1211,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'Bağla',
'tours.activity.refused.activity_track_not_recorded': 'Yalnız yenidən planlaşdırılmamış idxal bağlana bilər',
'tours.activity.refused.activity_track_in_use': 'Bu iz artıq başqa tura aiddir',
'tours.activity.refused.activity_track_is_tour': 'Bu izin öz faktiki izləri var',
'tours.activity.refused.activity_track_is_tour': 'Bu iz bu tura bağlana bilməz',
'tours.activity.refused.activity_tour_is_track': 'Bu tur özü başqa turun faktiki izidir',
'tours.activity.refused.activity_assignment_not_of_tour': 'Tur artıq o gündə deyil',
};
+2 -1
View File
@@ -1195,6 +1195,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Adicionar trilha real',
'tours.activity.assignHint': 'Vincule a trilha que você realmente fez, desta viagem ou de um arquivo GPX',
'tours.activity.empty': 'Ainda sem trilha real',
'tours.activity.unavailable': 'Não foi possível carregar as trilhas reais',
'tours.activity.badge': 'Real',
'tours.activity.badgeHint': 'Valores da trilha que você realmente fez, não do plano',
'tours.activity.isTrackHint': 'Trilha real de um passeio desta viagem',
@@ -1222,7 +1223,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'Vincular',
'tours.activity.refused.activity_track_not_recorded': 'Só uma importação que não foi replanejada pode ser vinculada',
'tours.activity.refused.activity_track_in_use': 'Esta trilha já pertence a outro passeio',
'tours.activity.refused.activity_track_is_tour': 'Esta trilha tem trilhas reais próprias',
'tours.activity.refused.activity_track_is_tour': 'Esta trilha não pode ser vinculada a este passeio',
'tours.activity.refused.activity_tour_is_track': 'Este passeio é a trilha real de outro passeio',
'tours.activity.refused.activity_assignment_not_of_tour': 'O passeio não está mais nesse dia',
};
+2 -1
View File
@@ -1201,6 +1201,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Afegeix el traç real',
'tours.activity.assignHint': 'Vincula el traç que vas fer realment, d’aquest viatge o d’un fitxer GPX',
'tours.activity.empty': 'Encara no hi ha traç real',
'tours.activity.unavailable': 'No s’han pogut carregar els traços reals',
'tours.activity.badge': 'Real',
'tours.activity.badgeHint': 'Valors del traç que vas fer realment, no del pla',
'tours.activity.isTrackHint': 'Traç real d’una ruta d’aquest viatge',
@@ -1229,7 +1230,7 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_not_recorded':
'Només es pot vincular una importació que no s’hagi replanificat',
'tours.activity.refused.activity_track_in_use': 'Aquest traç ja pertany a una altra ruta',
'tours.activity.refused.activity_track_is_tour': 'Aquest traç té traços reals propis',
'tours.activity.refused.activity_track_is_tour': 'Aquest traç no es pot vincular a aquesta ruta',
'tours.activity.refused.activity_tour_is_track': 'Aquesta ruta és alhora el traç real d’una altra ruta',
'tours.activity.refused.activity_assignment_not_of_tour': 'La ruta ja no és en aquell dia',
};
+2 -1
View File
@@ -1181,6 +1181,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Přidat skutečnou trasu',
'tours.activity.assignHint': 'Propoj trasu, kterou jsi opravdu ušel, z této cesty nebo ze souboru GPX',
'tours.activity.empty': 'Zatím žádná skutečná trasa',
'tours.activity.unavailable': 'Skutečné trasy se nepodařilo načíst',
'tours.activity.badge': 'Skutečnost',
'tours.activity.badgeHint': 'Hodnoty skutečně ušlé trasy, ne plánu',
'tours.activity.isTrackHint': 'Skutečná trasa túry této cesty',
@@ -1208,7 +1209,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'Propojit',
'tours.activity.refused.activity_track_not_recorded': 'Propojit lze jen import, který nebyl přeplánován',
'tours.activity.refused.activity_track_in_use': 'Tato trasa už patří k jiné túře',
'tours.activity.refused.activity_track_is_tour': 'Tato trasa má vlastní skutečné trasy',
'tours.activity.refused.activity_track_is_tour': 'Tuto trasu nelze propojit s touto túrou',
'tours.activity.refused.activity_tour_is_track': 'Tato túra je sama skutečnou trasou jiné túry',
'tours.activity.refused.activity_assignment_not_of_tour': 'Túra už v tento den není',
};
+2 -1
View File
@@ -1207,6 +1207,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Ist-Track hinzufügen',
'tours.activity.assignHint': 'Den tatsächlich gegangenen Track verknüpfen, aus dieser Reise oder einer GPX-Datei',
'tours.activity.empty': 'Noch kein Ist-Track',
'tours.activity.unavailable': 'Ist-Tracks konnten nicht geladen werden',
'tours.activity.badge': 'Ist',
'tours.activity.badgeHint': 'Werte des tatsächlich gegangenen Tracks, nicht der Planung',
'tours.activity.isTrackHint': 'Ist-Track einer Tour dieser Reise',
@@ -1235,7 +1236,7 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_not_recorded':
'Nur ein Import, der nicht überplant wurde, kann verknüpft werden',
'tours.activity.refused.activity_track_in_use': 'Dieser Track gehört bereits zu einer anderen Tour',
'tours.activity.refused.activity_track_is_tour': 'Dieser Track hat selbst Ist-Tracks',
'tours.activity.refused.activity_track_is_tour': 'Dieser Track kann nicht mit dieser Tour verknüpft werden',
'tours.activity.refused.activity_tour_is_track': 'Diese Tour ist selbst der Ist-Track einer anderen Tour',
'tours.activity.refused.activity_assignment_not_of_tour': 'Die Tour ist nicht mehr an diesem Tag',
};
+2 -1
View File
@@ -1170,6 +1170,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Add actual track',
'tours.activity.assignHint': 'Link the track you actually took, from this trip or a GPX file',
'tours.activity.empty': 'No actual track yet',
'tours.activity.unavailable': 'Actual tracks could not be loaded',
'tours.activity.badge': 'Actual',
'tours.activity.badgeHint': 'Values of the track you actually took, not of the plan',
'tours.activity.isTrackHint': 'Actual track of a tour in this trip',
@@ -1197,7 +1198,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'Link',
'tours.activity.refused.activity_track_not_recorded': 'Only an import that was not planned over can be linked',
'tours.activity.refused.activity_track_in_use': 'This track already belongs to another tour',
'tours.activity.refused.activity_track_is_tour': 'This track has actual tracks of its own',
'tours.activity.refused.activity_track_is_tour': 'This track cannot be linked to this tour',
'tours.activity.refused.activity_tour_is_track': 'This tour is itself the actual track of another tour',
'tours.activity.refused.activity_assignment_not_of_tour': 'The tour is no longer on that day',
};
+2 -1
View File
@@ -1194,6 +1194,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Añadir track real',
'tours.activity.assignHint': 'Vincula el track que realmente hiciste, de este viaje o de un archivo GPX',
'tours.activity.empty': 'Aún no hay track real',
'tours.activity.unavailable': 'No se pudieron cargar los tracks reales',
'tours.activity.badge': 'Real',
'tours.activity.badgeHint': 'Valores del track que realmente hiciste, no del plan',
'tours.activity.isTrackHint': 'Track real de una ruta de este viaje',
@@ -1222,7 +1223,7 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_not_recorded':
'Solo se puede vincular una importación que no se haya replanificado',
'tours.activity.refused.activity_track_in_use': 'Este track ya pertenece a otra ruta',
'tours.activity.refused.activity_track_is_tour': 'Este track tiene sus propios tracks reales',
'tours.activity.refused.activity_track_is_tour': 'Este track no se puede vincular a esta ruta',
'tours.activity.refused.activity_tour_is_track': 'Esta ruta es a su vez el track real de otra ruta',
'tours.activity.refused.activity_assignment_not_of_tour': 'La ruta ya no está en ese día',
};
+2 -1
View File
@@ -1179,6 +1179,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Lisa tegelik rada',
'tours.activity.assignHint': 'Seo päriselt läbitud rada, sellest reisist või GPX-failist',
'tours.activity.empty': 'Tegelikku rada veel pole',
'tours.activity.unavailable': 'Tegelikke radu ei õnnestunud laadida',
'tours.activity.badge': 'Tegelik',
'tours.activity.badgeHint': 'Päriselt läbitud raja väärtused, mitte plaani omad',
'tours.activity.isTrackHint': 'Selle reisi ühe matka tegelik rada',
@@ -1206,7 +1207,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'Seo',
'tours.activity.refused.activity_track_not_recorded': 'Siduda saab ainult importi, mida pole üle planeeritud',
'tours.activity.refused.activity_track_in_use': 'See rada kuulub juba teisele matkale',
'tours.activity.refused.activity_track_is_tour': 'Sellel rajal on oma tegelikud rajad',
'tours.activity.refused.activity_track_is_tour': 'Seda rada ei saa selle matkaga siduda',
'tours.activity.refused.activity_tour_is_track': 'See matk on ise teise matka tegelik rada',
'tours.activity.refused.activity_assignment_not_of_tour': 'Matk pole enam sellel päeval',
};
+2 -1
View File
@@ -1219,6 +1219,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Ajouter la trace réelle',
'tours.activity.assignHint': 'Lier la trace réellement parcourue, de ce voyage ou d’un fichier GPX',
'tours.activity.empty': 'Pas encore de trace réelle',
'tours.activity.unavailable': 'Les traces réelles n’ont pas pu être chargées',
'tours.activity.badge': 'Réel',
'tours.activity.badgeHint': 'Valeurs de la trace réellement parcourue, pas du plan',
'tours.activity.isTrackHint': 'Trace réelle d’une sortie de ce voyage',
@@ -1246,7 +1247,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'Lier',
'tours.activity.refused.activity_track_not_recorded': 'Seul un import qui n’a pas été replanifié peut être lié',
'tours.activity.refused.activity_track_in_use': 'Cette trace appartient déjà à une autre sortie',
'tours.activity.refused.activity_track_is_tour': 'Cette trace a elle-même des traces réelles',
'tours.activity.refused.activity_track_is_tour': 'Cette trace ne peut pas être liée à cette sortie',
'tours.activity.refused.activity_tour_is_track': 'Cette sortie est elle-même la trace réelle d’une autre sortie',
'tours.activity.refused.activity_assignment_not_of_tour': 'La sortie n’est plus prévue ce jour-là',
};
+2 -1
View File
@@ -1220,6 +1220,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Προσθήκη πραγματικής διαδρομής',
'tours.activity.assignHint': 'Σύνδεσε τη διαδρομή που έκανες πραγματικά, από αυτό το ταξίδι ή από αρχείο GPX',
'tours.activity.empty': 'Δεν υπάρχει ακόμη πραγματική διαδρομή',
'tours.activity.unavailable': 'Δεν ήταν δυνατή η φόρτωση των πραγματικών διαδρομών',
'tours.activity.badge': 'Πραγματικό',
'tours.activity.badgeHint': 'Τιμές της διαδρομής που έκανες πραγματικά, όχι του σχεδίου',
'tours.activity.isTrackHint': 'Πραγματική διαδρομή μιας εκδρομής αυτού του ταξιδιού',
@@ -1247,7 +1248,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'Σύνδεση',
'tours.activity.refused.activity_track_not_recorded': 'Μόνο μια εισαγωγή που δεν επανασχεδιάστηκε μπορεί να συνδεθεί',
'tours.activity.refused.activity_track_in_use': 'Αυτή η διαδρομή ανήκει ήδη σε άλλη εκδρομή',
'tours.activity.refused.activity_track_is_tour': 'Αυτή η διαδρομή έχει δικές της πραγματικές διαδρομές',
'tours.activity.refused.activity_track_is_tour': 'Αυτή η διαδρομή δεν μπορεί να συνδεθεί με αυτή την περιήγηση',
'tours.activity.refused.activity_tour_is_track': 'Αυτή η εκδρομή είναι η ίδια πραγματική διαδρομή άλλης εκδρομής',
'tours.activity.refused.activity_assignment_not_of_tour': 'Η εκδρομή δεν είναι πια σε εκείνη την ημέρα',
};
+2 -1
View File
@@ -1199,6 +1199,7 @@ const tours: TranslationStrings = {
'tours.activity.assignHint':
'Kapcsold össze a ténylegesen bejárt nyomvonalat, ebből az utazásból vagy egy GPX-fájlból',
'tours.activity.empty': 'Még nincs tényleges nyomvonal',
'tours.activity.unavailable': 'A tényleges nyomvonalakat nem sikerült betölteni',
'tours.activity.badge': 'Tényleges',
'tours.activity.badgeHint': 'A ténylegesen bejárt nyomvonal értékei, nem a terv értékei',
'tours.activity.isTrackHint': 'Egy túra tényleges nyomvonala ebben az utazásban',
@@ -1226,7 +1227,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'Összekapcsolás',
'tours.activity.refused.activity_track_not_recorded': 'Csak olyan import kapcsolható össze, amelyet nem terveztek át',
'tours.activity.refused.activity_track_in_use': 'Ez a nyomvonal már egy másik túráé',
'tours.activity.refused.activity_track_is_tour': 'Ennek a nyomvonalnak saját tényleges nyomvonalai vannak',
'tours.activity.refused.activity_track_is_tour': 'Ez a nyomvonal nem kapcsolható ehhez a túrához',
'tours.activity.refused.activity_tour_is_track': 'Ez a túra maga is egy másik túra tényleges nyomvonala',
'tours.activity.refused.activity_assignment_not_of_tour': 'A túra már nem szerepel azon a napon',
};
+2 -1
View File
@@ -1180,6 +1180,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Tambah jejak aktual',
'tours.activity.assignHint': 'Tautkan jejak yang benar-benar kamu tempuh, dari perjalanan ini atau file GPX',
'tours.activity.empty': 'Belum ada jejak aktual',
'tours.activity.unavailable': 'Jejak aktual tidak dapat dimuat',
'tours.activity.badge': 'Aktual',
'tours.activity.badgeHint': 'Nilai jejak yang benar-benar kamu tempuh, bukan dari rencana',
'tours.activity.isTrackHint': 'Jejak aktual sebuah tur di perjalanan ini',
@@ -1208,7 +1209,7 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_not_recorded':
'Hanya impor yang belum direncanakan ulang yang dapat ditautkan',
'tours.activity.refused.activity_track_in_use': 'Jejak ini sudah milik tur lain',
'tours.activity.refused.activity_track_is_tour': 'Jejak ini memiliki jejak aktualnya sendiri',
'tours.activity.refused.activity_track_is_tour': 'Jejak ini tidak dapat ditautkan ke tur ini',
'tours.activity.refused.activity_tour_is_track': 'Tur ini sendiri adalah jejak aktual tur lain',
'tours.activity.refused.activity_assignment_not_of_tour': 'Tur ini sudah tidak ada di hari itu',
};
+2 -1
View File
@@ -1209,6 +1209,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Aggiungi traccia effettiva',
'tours.activity.assignHint': 'Collega la traccia percorsa davvero, da questo viaggio o da un file GPX',
'tours.activity.empty': 'Ancora nessuna traccia effettiva',
'tours.activity.unavailable': 'Impossibile caricare le tracce effettive',
'tours.activity.badge': 'Effettivo',
'tours.activity.badgeHint': 'Valori della traccia percorsa davvero, non del piano',
'tours.activity.isTrackHint': 'Traccia effettiva di un tour di questo viaggio',
@@ -1236,7 +1237,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'Collega',
'tours.activity.refused.activity_track_not_recorded': 'Si può collegare solo un’importazione non ripianificata',
'tours.activity.refused.activity_track_in_use': 'Questa traccia appartiene già a un altro tour',
'tours.activity.refused.activity_track_is_tour': 'Questa traccia ha a sua volta tracce effettive',
'tours.activity.refused.activity_track_is_tour': 'Questa traccia non può essere collegata a questo tour',
'tours.activity.refused.activity_tour_is_track': 'Questo tour è a sua volta la traccia effettiva di un altro tour',
'tours.activity.refused.activity_assignment_not_of_tour': 'Il tour non è più in quel giorno',
};
+2 -1
View File
@@ -1145,6 +1145,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': '実際のトラックを追加',
'tours.activity.assignHint': 'このトリップまたはGPXファイルから、実際に歩いたトラックをリンクします',
'tours.activity.empty': '実際のトラックはまだありません',
'tours.activity.unavailable': '実際のトラックを読み込めませんでした',
'tours.activity.badge': '実績',
'tours.activity.badgeHint': '計画ではなく、実際に歩いたトラックの値です',
'tours.activity.isTrackHint': 'このトリップのツアーの実際のトラック',
@@ -1172,7 +1173,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'リンク',
'tours.activity.refused.activity_track_not_recorded': '上書きで計画されていないインポートのみリンクできます',
'tours.activity.refused.activity_track_in_use': 'このトラックはすでに別のツアーのものです',
'tours.activity.refused.activity_track_is_tour': 'このトラックには独自の実際のトラックがあります',
'tours.activity.refused.activity_track_is_tour': 'このトラックはこのツアーにリンクできません',
'tours.activity.refused.activity_tour_is_track': 'このツアー自体が別のツアーの実際のトラックです',
'tours.activity.refused.activity_assignment_not_of_tour': 'このツアーはもうその日にありません',
};
+2 -1
View File
@@ -1143,6 +1143,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': '실제 트랙 추가',
'tours.activity.assignHint': '이 여행이나 GPX 파일에서 실제로 걸은 트랙을 연결하세요',
'tours.activity.empty': '아직 실제 트랙이 없습니다',
'tours.activity.unavailable': '실제 트랙을 불러올 수 없습니다',
'tours.activity.badge': '실제',
'tours.activity.badgeHint': '계획이 아니라 실제로 걸은 트랙의 값입니다',
'tours.activity.isTrackHint': '이 여행에 있는 투어의 실제 트랙',
@@ -1170,7 +1171,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': '연결',
'tours.activity.refused.activity_track_not_recorded': '덮어서 다시 계획하지 않은 가져오기만 연결할 수 있습니다',
'tours.activity.refused.activity_track_in_use': '이 트랙은 이미 다른 투어에 속해 있습니다',
'tours.activity.refused.activity_track_is_tour': '이 트랙에는 자체 실제 트랙이 있습니다',
'tours.activity.refused.activity_track_is_tour': '이 트랙은 이 투어에 연결할 수 없습니다',
'tours.activity.refused.activity_tour_is_track': '이 투어는 다른 투어의 실제 트랙입니다',
'tours.activity.refused.activity_assignment_not_of_tour': '이 투어는 더 이상 그날에 없습니다',
};
+2 -1
View File
@@ -1201,6 +1201,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Werkelijke track toevoegen',
'tours.activity.assignHint': 'Koppel de track die je echt hebt afgelegd, uit deze reis of een GPX-bestand',
'tours.activity.empty': 'Nog geen werkelijke track',
'tours.activity.unavailable': 'Werkelijke tracks konden niet worden geladen',
'tours.activity.badge': 'Werkelijk',
'tours.activity.badgeHint': 'Waarden van de track die je echt hebt afgelegd, niet van de planning',
'tours.activity.isTrackHint': 'Werkelijke track van een tocht in deze reis',
@@ -1229,7 +1230,7 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_not_recorded':
'Alleen een import die niet is overgepland, kan worden gekoppeld',
'tours.activity.refused.activity_track_in_use': 'Deze track hoort al bij een andere tocht',
'tours.activity.refused.activity_track_is_tour': 'Deze track heeft zelf werkelijke tracks',
'tours.activity.refused.activity_track_is_tour': 'Deze track kan niet aan deze tocht worden gekoppeld',
'tours.activity.refused.activity_tour_is_track': 'Deze tocht is zelf de werkelijke track van een andere tocht',
'tours.activity.refused.activity_assignment_not_of_tour': 'De tocht staat niet meer op die dag',
};
+2 -1
View File
@@ -1223,6 +1223,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Dodaj rzeczywistą trasę',
'tours.activity.assignHint': 'Połącz trasę, którą naprawdę przeszedłeś, z tej podróży lub z pliku GPX',
'tours.activity.empty': 'Brak rzeczywistej trasy',
'tours.activity.unavailable': 'Nie udało się wczytać rzeczywistych tras',
'tours.activity.badge': 'Rzeczywiste',
'tours.activity.badgeHint': 'Wartości trasy naprawdę przebytej, nie planu',
'tours.activity.isTrackHint': 'Rzeczywista trasa wycieczki z tej podróży',
@@ -1250,7 +1251,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'Połącz',
'tours.activity.refused.activity_track_not_recorded': 'Połączyć można tylko import, który nie został przeplanowany',
'tours.activity.refused.activity_track_in_use': 'Ta trasa należy już do innej wycieczki',
'tours.activity.refused.activity_track_is_tour': 'Ta trasa ma własne rzeczywiste trasy',
'tours.activity.refused.activity_track_is_tour': 'Tej trasy nie można powiązać z tą wycieczką',
'tours.activity.refused.activity_tour_is_track': 'Ta wycieczka jest sama rzeczywistą trasą innej wycieczki',
'tours.activity.refused.activity_assignment_not_of_tour': 'Wycieczki nie ma już w tym dniu',
};
+2 -1
View File
@@ -1229,6 +1229,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Добавить фактический трек',
'tours.activity.assignHint': 'Привяжите трек, по которому вы на самом деле прошли, из этой поездки или из файла GPX',
'tours.activity.empty': 'Фактического трека пока нет',
'tours.activity.unavailable': 'Не удалось загрузить фактические треки',
'tours.activity.badge': 'Факт',
'tours.activity.badgeHint': 'Значения фактически пройденного трека, а не плана',
'tours.activity.isTrackHint': 'Фактический трек маршрута этой поездки',
@@ -1257,7 +1258,7 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_not_recorded':
'Привязать можно только импорт, поверх которого не строили план',
'tours.activity.refused.activity_track_in_use': 'Этот трек уже относится к другому маршруту',
'tours.activity.refused.activity_track_is_tour': 'У этого трека есть собственные фактические треки',
'tours.activity.refused.activity_track_is_tour': 'Этот трек нельзя связать с этим маршрутом',
'tours.activity.refused.activity_tour_is_track': 'Этот маршрут сам является фактическим треком другого маршрута',
'tours.activity.refused.activity_assignment_not_of_tour': 'Маршрута больше нет в этот день',
};
+2 -1
View File
@@ -1185,6 +1185,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Pridať skutočnú trasu',
'tours.activity.assignHint': 'Prepoj trasu, ktorú si naozaj prešiel, z tohto výletu alebo zo súboru GPX',
'tours.activity.empty': 'Zatiaľ žiadna skutočná trasa',
'tours.activity.unavailable': 'Skutočné trasy sa nepodarilo načítať',
'tours.activity.badge': 'Skutočnosť',
'tours.activity.badgeHint': 'Hodnoty naozaj prejdenej trasy, nie plánu',
'tours.activity.isTrackHint': 'Skutočná trasa túry tohto výletu',
@@ -1212,7 +1213,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'Prepojiť',
'tours.activity.refused.activity_track_not_recorded': 'Prepojiť sa dá len import, ktorý nebol preplánovaný',
'tours.activity.refused.activity_track_in_use': 'Táto trasa už patrí k inej túre',
'tours.activity.refused.activity_track_is_tour': 'Táto trasa má vlastné skutočné trasy',
'tours.activity.refused.activity_track_is_tour': 'Túto trasu nemožno prepojiť s touto túrou',
'tours.activity.refused.activity_tour_is_track': 'Táto túra je sama skutočnou trasou inej túry',
'tours.activity.refused.activity_assignment_not_of_tour': 'Túra už v tento deň nie je',
};
+2 -1
View File
@@ -1174,6 +1174,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Lägg till faktiskt spår',
'tours.activity.assignHint': 'Koppla spåret du faktiskt gick, från den här resan eller en GPX-fil',
'tours.activity.empty': 'Inget faktiskt spår ännu',
'tours.activity.unavailable': 'Faktiska spår kunde inte läsas in',
'tours.activity.badge': 'Faktiskt',
'tours.activity.badgeHint': 'Värden från spåret du faktiskt gick, inte från planen',
'tours.activity.isTrackHint': 'Faktiskt spår för en tur i den här resan',
@@ -1201,7 +1202,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'Koppla',
'tours.activity.refused.activity_track_not_recorded': 'Bara en import som inte har planerats om kan kopplas',
'tours.activity.refused.activity_track_in_use': 'Det här spåret hör redan till en annan tur',
'tours.activity.refused.activity_track_is_tour': 'Det här spåret har egna faktiska spår',
'tours.activity.refused.activity_track_is_tour': 'Det här spåret kan inte länkas till den här turen',
'tours.activity.refused.activity_tour_is_track': 'Den här turen är själv det faktiska spåret för en annan tur',
'tours.activity.refused.activity_assignment_not_of_tour': 'Turen ligger inte längre på den dagen',
};
+2 -1
View File
@@ -1126,6 +1126,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'เพิ่มเส้นทางจริง',
'tours.activity.assignHint': 'เชื่อมเส้นทางที่คุณเดินจริง จากทริปนี้หรือจากไฟล์ GPX',
'tours.activity.empty': 'ยังไม่มีเส้นทางจริง',
'tours.activity.unavailable': 'ไม่สามารถโหลดเส้นทางจริงได้',
'tours.activity.badge': 'จริง',
'tours.activity.badgeHint': 'ค่าของเส้นทางที่คุณเดินจริง ไม่ใช่ของแผน',
'tours.activity.isTrackHint': 'เส้นทางจริงของทัวร์ในทริปนี้',
@@ -1153,7 +1154,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'เชื่อม',
'tours.activity.refused.activity_track_not_recorded': 'เชื่อมได้เฉพาะไฟล์นำเข้าที่ยังไม่ถูกวางแผนทับ',
'tours.activity.refused.activity_track_in_use': 'เส้นทางนี้เป็นของทัวร์อื่นแล้ว',
'tours.activity.refused.activity_track_is_tour': 'เส้นทางนี้มีเส้นทางจริงของตัวเอง',
'tours.activity.refused.activity_track_is_tour': 'ไม่สามารถเชื่อมเส้นทางนี้กับทัวร์นี้ได้',
'tours.activity.refused.activity_tour_is_track': 'ทัวร์นี้เป็นเส้นทางจริงของทัวร์อื่น',
'tours.activity.refused.activity_assignment_not_of_tour': 'ทัวร์ไม่ได้อยู่ในวันนั้นแล้ว',
};
+1 -1
View File
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
// placeholder spec; here only the general keys are.
const groups = pluralGroups(Object.keys(english));
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
const GENERAL_KEY_COUNT = 1098;
const GENERAL_KEY_COUNT = 1099;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
+2 -1
View File
@@ -1180,6 +1180,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Gerçek iz ekle',
'tours.activity.assignHint': 'Gerçekten yürüdüğün izi bu geziden ya da bir GPX dosyasından bağla',
'tours.activity.empty': 'Henüz gerçek iz yok',
'tours.activity.unavailable': 'Gerçek izler yüklenemedi',
'tours.activity.badge': 'Gerçek',
'tours.activity.badgeHint': 'Planın değil, gerçekten yürüdüğün izin değerleri',
'tours.activity.isTrackHint': 'Bu gezideki bir turun gerçek izi',
@@ -1208,7 +1209,7 @@ const tours: TranslationStrings = {
'tours.activity.refused.activity_track_not_recorded':
'Yalnızca üzerinden yeniden planlanmamış bir içe aktarma bağlanabilir',
'tours.activity.refused.activity_track_in_use': 'Bu iz zaten başka bir tura ait',
'tours.activity.refused.activity_track_is_tour': 'Bu izin kendi gerçek izleri var',
'tours.activity.refused.activity_track_is_tour': 'Bu iz bu tura bağlanamaz',
'tours.activity.refused.activity_tour_is_track': 'Bu tur zaten başka bir turun gerçek izi',
'tours.activity.refused.activity_assignment_not_of_tour': 'Tur artık o günde değil',
};
+2 -1
View File
@@ -1216,6 +1216,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Додати фактичний трек',
'tours.activity.assignHint': 'Прив’яжіть трек, яким ви справді пройшли, з цієї подорожі або з файлу GPX',
'tours.activity.empty': 'Фактичного треку ще немає',
'tours.activity.unavailable': 'Не вдалося завантажити фактичні треки',
'tours.activity.badge': 'Факт',
'tours.activity.badgeHint': 'Значення фактично пройденого треку, а не плану',
'tours.activity.isTrackHint': 'Фактичний трек маршруту цієї подорожі',
@@ -1243,7 +1244,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'Прив’язати',
'tours.activity.refused.activity_track_not_recorded': 'Прив’язати можна лише імпорт, поверх якого не будували план',
'tours.activity.refused.activity_track_in_use': 'Цей трек уже належить іншому маршруту',
'tours.activity.refused.activity_track_is_tour': 'Цей трек має власні фактичні треки',
'tours.activity.refused.activity_track_is_tour': 'Цей трек не можна пов’язати з цим маршрутом',
'tours.activity.refused.activity_tour_is_track': 'Цей маршрут сам є фактичним треком іншого маршруту',
'tours.activity.refused.activity_assignment_not_of_tour': 'Маршруту вже немає цього дня',
};
+2 -1
View File
@@ -1169,6 +1169,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': 'Thêm lộ trình thực tế',
'tours.activity.assignHint': 'Liên kết lộ trình bạn thực sự đã đi, từ chuyến đi này hoặc tệp GPX',
'tours.activity.empty': 'Chưa có lộ trình thực tế',
'tours.activity.unavailable': 'Không thể tải lộ trình thực tế',
'tours.activity.badge': 'Thực tế',
'tours.activity.badgeHint': 'Số liệu của lộ trình bạn thực sự đã đi, không phải của kế hoạch',
'tours.activity.isTrackHint': 'Lộ trình thực tế của một tour trong chuyến đi này',
@@ -1197,7 +1198,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': 'Liên kết',
'tours.activity.refused.activity_track_not_recorded': 'Chỉ có thể liên kết tệp nhập chưa được lập kế hoạch lại',
'tours.activity.refused.activity_track_in_use': 'Lộ trình này đã thuộc về tour khác',
'tours.activity.refused.activity_track_is_tour': 'Lộ trình này có lộ trình thực tế riêng',
'tours.activity.refused.activity_track_is_tour': 'Không thể liên kết lộ trình này với chuyến đi này',
'tours.activity.refused.activity_tour_is_track': 'Tour này chính là lộ trình thực tế của tour khác',
'tours.activity.refused.activity_assignment_not_of_tour': 'Tour không còn ở ngày đó',
};
+2 -1
View File
@@ -1086,6 +1086,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': '新增實際軌跡',
'tours.activity.assignHint': '連結你實際走過的軌跡,可來自本趟旅程或 GPX 檔案',
'tours.activity.empty': '還沒有實際軌跡',
'tours.activity.unavailable': '無法載入實際軌跡',
'tours.activity.badge': '實際',
'tours.activity.badgeHint': '實際走過的軌跡的數值,而非計畫數值',
'tours.activity.isTrackHint': '本趟旅程中某條路線的實際軌跡',
@@ -1113,7 +1114,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': '連結',
'tours.activity.refused.activity_track_not_recorded': '只能連結未被重新規劃覆蓋的匯入',
'tours.activity.refused.activity_track_in_use': '此軌跡已屬於另一條路線',
'tours.activity.refused.activity_track_is_tour': '此軌跡有自己的實際軌跡',
'tours.activity.refused.activity_track_is_tour': '此軌跡無法連結到此路線',
'tours.activity.refused.activity_tour_is_track': '此路線本身是另一條路線的實際軌跡',
'tours.activity.refused.activity_assignment_not_of_tour': '此路線已不在那一天',
};
+2 -1
View File
@@ -1087,6 +1087,7 @@ const tours: TranslationStrings = {
'tours.activity.assign': '添加实际轨迹',
'tours.activity.assignHint': '关联你实际走过的轨迹,可来自本次旅行或 GPX 文件',
'tours.activity.empty': '还没有实际轨迹',
'tours.activity.unavailable': '无法加载实际轨迹',
'tours.activity.badge': '实际',
'tours.activity.badgeHint': '实际走过的轨迹的数值,而非计划数值',
'tours.activity.isTrackHint': '本次旅行中某条路线的实际轨迹',
@@ -1114,7 +1115,7 @@ const tours: TranslationStrings = {
'tours.activity.confirm': '关联',
'tours.activity.refused.activity_track_not_recorded': '只能关联未被重新规划覆盖的导入',
'tours.activity.refused.activity_track_in_use': '该轨迹已属于另一条路线',
'tours.activity.refused.activity_track_is_tour': '该轨迹有自己的实际轨迹',
'tours.activity.refused.activity_track_is_tour': '该轨迹无法关联到此路线',
'tours.activity.refused.activity_tour_is_track': '该路线本身是另一条路线的实际轨迹',
'tours.activity.refused.activity_assignment_not_of_tour': '该路线已不在那一天',
};
+2 -1
View File
@@ -75,7 +75,8 @@ export type TourActivityRef = z.infer<typeof tourActivityRefSchema>;
/**
* Assigning a recording to a tour, confirmed by the user. An execution that
* already has one gets this one instead; the recording named must not document
* already has recordings gets this one instead (a day has one, no day can have
* several once their days are gone); the recording named must not document
* another tour.
*/
export const tourActivityAssignRequestSchema = z