feat(assistant): planning a tour from the chat is one tap and opens it in Tours

- "Plan this" decides the take it holds at once instead of waiting below for a
  second confirmation, says it is being planned, and once the tour is added the
  desktop opens it in the Tours tab to be edited.
- The stops run as a timeline: each walking time sits on the line between two
  stops, read with the stop it leads to, so no time reads as a stop's own.
- An answer about a tour leaves out the strip of places its searches went
  through (hotels, a club in another state).
- "On map" from another tab of the planner brings the map back first, and a
  map that mounts right after a request still carries it out.
This commit is contained in:
Maurice
2026-10-23 13:11:07 +02:00
parent 4365417807
commit 8afc20e1ab
41 changed files with 317 additions and 96 deletions
@@ -21,7 +21,8 @@ function useRouteTripId(): number | null {
* map" opens the planner on the change (`?place=`, `?day=`), the same way a
* notification opens a place, a tab of the trip opens as `?tab=` (a list's
* own tab remembered first), and another page is simply navigated to. A day
* picked in the chat (its weather) selects that day the same way.
* picked in the chat (its weather) selects that day the same way, and "On
* map" on a card brings the planner's map back first.
*/
export default function AssistantHost() {
const navigate = useNavigate();
@@ -53,6 +54,8 @@ export default function AssistantHost() {
[navigate, openPlanner, tripId]
);
const focusDay = useCallback((dayId: number) => openPlanner(`?day=${dayId}`), [openPlanner]);
// "On map" from another tab of the planner (Tours, Costs, ...) first brings the map back.
const revealMap = useCallback(() => openPlanner('?tab=plan'), [openPlanner]);
return <AssistantWindow trip={trip} onOpen={open} onFocusDay={focusDay} />;
return <AssistantWindow trip={trip} onOpen={open} onFocusDay={focusDay} onRevealMap={revealMap} />;
}
@@ -1,14 +1,15 @@
import type {
AssistantApplied,
AssistantChangeEffect,
AssistantChangeLabelKey,
AssistantChangePreview,
AssistantFoundPlace,
AssistantMessage,
AssistantMessagePart,
AssistantPlaces,
AssistantWeatherDay,
TrekCard,
import {
ASSISTANT_FOLLOW_UPS_TOOL,
type AssistantApplied,
type AssistantChangeEffect,
type AssistantChangeLabelKey,
type AssistantChangePreview,
type AssistantFoundPlace,
type AssistantMessage,
type AssistantMessagePart,
type AssistantPlaces,
type AssistantWeatherDay,
type TrekCard,
} from '@trek/shared';
import { CARD_RULES } from './cards/cardLooks';
@@ -71,6 +72,8 @@ type MessageBlock =
export interface CardRules {
group(card: TrekCard): string;
yieldsToChange(answer: TrekCard, change: TrekCard): boolean;
/** Whether an answer holding the card leaves out its found places. */
hidesFoundPlaces?(card: TrekCard): boolean;
}
/** The change "+ Day 3" on a found place makes; the server holds it without a model turn. */
@@ -251,13 +254,16 @@ export function messageBlocks(
else if (part.type === 'tool-call' && part.approval) changeIds.add(part.toolCallId);
});
// An answer about a tour shows the tour, not the places its searches went through to find it.
const changeCards = message.parts.flatMap((p) => (p.type === 'data-trek-change' && p.data.card ? [p.data.card] : []));
const placesHidden = [...cards.values(), ...changeCards].some((card) => rules.hidesFoundPlaces?.(card) === true);
const blocks: MessageBlock[] = [];
message.parts.forEach((part, index) => {
if (part.type === 'text') {
if (part.text.trim()) blocks.push({ kind: 'text', key: `text-${index}`, text: part.text });
return;
}
if (part.type === 'data-trek-places' && index === lastPlaces) {
if (part.type === 'data-trek-places' && index === lastPlaces && !placesHidden) {
const found = allFound(message.parts);
const places = found.places.filter((place) => !proposed(place, effects));
if (places.length > 0) blocks.push({ kind: 'places', key: `places-${index}`, found: { ...found, places } });
@@ -273,7 +279,8 @@ export function messageBlocks(
if (card && !changeIds.has(toolCallId)) pushCard(blocks, { toolCallId, card }, rules.group(card));
return;
}
if (part.type !== 'tool-call') return;
// The follow-ups the model offered are chips under the answer, never a step.
if (part.type !== 'tool-call' || part.toolName === ASSISTANT_FOLLOW_UPS_TOOL) return;
const item = changeItemOf(part, previews.get(part.toolCallId) ?? null, receipts, cards);
if (item) {
pushChange(blocks, item, fromStrip);
@@ -247,6 +247,7 @@ function lookOf(card: TrekCard): CardLook<TrekCardKind> {
export const CARD_RULES: CardRules = {
group: (card) => lookOf(card).group?.(card) ?? card.kind,
yieldsToChange: (answer, change) => lookOf(answer).yieldsToChange?.(answer, change) ?? false,
hidesFoundPlaces: (card) => lookOf(card).hidesFoundPlaces === true,
};
/** What the plan's map draws for a card through its kind's look; nothing for a kind that draws nothing. */
@@ -73,6 +73,12 @@ export interface CardLook<K extends TrekCardKind> {
lines?(card: TrekCardOf<K>, use: RouteUse): RouteLineSpec[];
ghosts?(card: TrekCardOf<K>): GhostPoint[];
hidesEffectGhost?(card: TrekCardOf<K>): boolean;
/**
* Whether an answer holding this card leaves out the strip of places its
* searches found: a planned tour searched only to find its start, and a
* strip of hotels beside it would read as suggestions.
*/
hidesFoundPlaces?: boolean;
}
/** One part of TREK's pure look, for the kinds `K` it owns. */
@@ -11,6 +11,23 @@ import { useTakeProposal } from './useTourRouteCard';
/** What "Open" says: the phone opens the tour's place on the plan, the desktop the Tours tab. */
const OPEN_LABEL = { phoneKey: 'assistant.card.tour.openPhone', desktopKey: 'assistant.card.tour.open' } as const;
/** Where a tapped "Plan this" stands: being planned, added, or (held without the tap) waiting below. */
function TakenPill({ deciding, applied }: { deciding: boolean; applied: boolean }) {
const { t } = useTranslation();
if (deciding) {
return (
<SoftPill icon={<Loader2 size={10} className="animate-spin" aria-hidden="true" />}>
{t('assistant.card.tour.adding')}
</SoftPill>
);
}
return (
<SoftPill tone="success" icon={<Check size={10} strokeWidth={2.4} aria-hidden="true" />}>
{applied ? t('assistant.card.tour.added') : t('assistant.card.tour.taken')}
</SoftPill>
);
}
/**
* The foot of a route card: "On map" always; "Plan this" on a proposed hike
* or city walk for someone who may add to the trip, off with its reason said
@@ -57,11 +74,7 @@ export default function TourRouteActions({
labelKey={phone ? OPEN_LABEL.phoneKey : OPEN_LABEL.desktopKey}
/>
)}
{take.visible && take.state === 'taken' && (
<SoftPill tone="success" icon={<Check size={10} strokeWidth={2.4} aria-hidden="true" />}>
{take.applied ? t('assistant.card.tour.added') : t('assistant.card.tour.taken')}
</SoftPill>
)}
{take.visible && take.state === 'taken' && <TakenPill deciding={take.deciding} applied={take.applied} />}
{take.visible && take.state !== 'taken' && (
<button
type="button"
@@ -52,10 +52,11 @@ function proposed(): AssistantMessage {
};
}
/** The change "Plan this" held for that hike: applied (`true`) or discarded (`false`). */
function heldTake(approved: boolean): AssistantMessage {
/** The change "Plan this" held for that hike: applied (`true`), discarded (`false`) or still waiting (none). */
function heldTake(approved?: boolean): AssistantMessage {
const approval = approved === undefined ? { id: 't1' } : { id: 't1', approved };
const parts: AssistantMessagePart[] = [
{ type: 'tool-call', toolCallId: 't1', toolName: 'take_proposal', args: {}, approval: { id: 't1', approved } },
{ type: 'tool-call', toolCallId: 't1', toolName: 'take_proposal', args: {}, approval },
{
type: 'data-trek-change',
data: {
@@ -167,6 +168,17 @@ describe('TourRouteCard', () => {
expect(screen.getByRole('list', { name: 'Stops along the way' })).toBeInTheDocument();
});
it('FE-AI-TOUR-024b: the stops run as a timeline, each walk on the line between two stops and read with the one it leads to', () => {
renderCard(HIKE);
const list = screen.getByRole('list', { name: 'Stops along the way' });
// One row per stop; the lines between them are not rows.
expect(list.querySelectorAll('[role="listitem"]')).toHaveLength(5);
// The place beside the way has no time: the hut measures from the start, then each stop from the last.
expect(screen.getAllByText(/from the last stop$/)).toHaveLength(3);
const legs = [...list.querySelectorAll('[aria-hidden="true"]')].map((leg) => leg.textContent).filter(Boolean);
expect(legs).toHaveLength(3);
});
it('FE-AI-TOUR-025: lays out the day with the sun and the walk, the forecast hours with likely rain, and the warnings', () => {
renderCard(HIKE);
expect(
@@ -217,6 +229,35 @@ describe('TourRouteCard', () => {
expect(screen.getAllByText('This can no longer be planned from here. Ask for it again.')).toHaveLength(1);
});
it('FE-AI-TOUR-026b: one tap plans the hike: the held take is decided at once and the tour opens in the Tours tab', async () => {
const onOpen = vi.fn();
const decide = vi.fn(async (_key: number, decisions: { toolCallId: string; approved: boolean }[]) => {
// The server applies the take; the thread shows it applied with the new tour's place.
const chat = chatOf(useAssistantStore.getState(), TRIP);
const applied = heldTake(true);
applied.parts.push({
type: 'data-trek-applied',
data: { toolCallId: decisions[0]?.toolCallId ?? '', entity: 'tour', ids: [77], dayId: 22, undoUntil: null },
});
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [proposed(), applied] } } });
});
seed([proposed()]);
useAssistantStore.setState({ decide });
takeProposal.mockImplementationOnce(async () => {
const chat = chatOf(useAssistantStore.getState(), TRIP);
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [proposed(), heldTake(undefined)] } } });
return 'held';
});
renderCard(HIKE, { onOpen });
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Plan this' }));
});
expect(decide).toHaveBeenCalledWith(TRIP, [{ toolCallId: 't1', approved: true }]);
expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'tour-planner', params: { tour: '77' } });
expect(screen.getByText('Added to the trip')).toBeInTheDocument();
expect(screen.queryByText('Added below for you to confirm')).toBeNull();
});
it('FE-AI-TOUR-027: "Plan this" is off while offline or while another change waits, and absent where nothing can be taken', () => {
seed([UNDECIDED]);
renderCard(HIKE);
@@ -1,6 +1,6 @@
import type { TourRouteCard, TourRouteStop } from '@trek/shared';
import { AlertTriangle, Flag, MapPin, MountainSnow, type LucideIcon } from 'lucide-react';
import { createElement } from 'react';
import { createElement, type ReactNode } from 'react';
import { useTranslation } from '../../../../i18n';
import {
@@ -17,12 +17,13 @@ import { chipStops } from './tourRouteModel';
import { FLAG_LOOK, stopName } from './tourRouteWords';
/**
* The stops of a route as chips in the order they lie along it: the start
* and the end, the huts, summits and stations on the way with their clock,
* the walking time between them faint, and what lies a little off the way
* quieter still. Pointing at a chip or tapping it moves the spot in focus
* there, on the profile and on the map alike. Past eight the middle ones
* fold into a count that names them.
* The stops of a route as a timeline in the order they lie along it: one row
* per stop (the start and the end, the huts, summits and stations on the way
* with their clock, what lies a little off the way quieter), joined by a line
* that carries the walking time of the stretch between two stops, so a time
* is never read as belonging to the stop beside it. Pointing at a chip or
* tapping it moves the spot in focus there, on the profile and on the map
* alike. Past eight the middle ones fold into a count that names them.
*/
/** A stop's glyph: the flag at either end, the mountain at the highest point, the place's own for a kind. */
@@ -105,12 +106,44 @@ function StopChip({
);
}
/** The walking time from one chip to the next, faint between them; nothing where it is not known. */
function Between({ from, to }: { from: TourRouteStop; to: TourRouteStop }) {
/** The walking time of the stretch from one stop to the next, on the line between them; null where it is not known. */
function legMinutes(from: TourRouteStop | undefined, to: TourRouteStop): number | null {
if (!from || from.minutes === null || to.minutes === null || to.minutes <= from.minutes) return null;
return to.minutes - from.minutes;
}
/** The line between two rows, with the stretch's walking time on it. */
function Leg({ minutes }: { minutes: number | null }) {
const format = useCardFormat();
if (from.minutes === null || to.minutes === null || to.minutes <= from.minutes) return null;
return (
<span className="text-caption tabular-nums text-content-faint">{format.minutes(to.minutes - from.minutes)}</span>
// Read with the stop it leads to; a list holds only its rows.
<span aria-hidden="true" className="flex min-h-[14px] items-stretch gap-2">
<span className="flex w-2.5 flex-none justify-center">
<span className="w-px bg-edge" />
</span>
{minutes !== null && (
<span className="text-caption tabular-nums leading-tight text-content-faint">{format.minutes(minutes)}</span>
)}
</span>
);
}
/** One row of the timeline: its dot on the line, then the stop, read after the walk that leads to it. */
function Row({ end, leg, children }: { end: boolean; leg?: number | null; children: ReactNode }) {
const { t } = useTranslation();
const format = useCardFormat();
return (
<span role="listitem" className="flex items-center gap-2">
{leg != null && (
<span className="sr-only">{t('assistant.card.tour.legTime', { time: format.minutes(leg) })}</span>
)}
<span className="flex w-2.5 flex-none justify-center">
<span
className={`h-2 w-2 rounded-full ${end ? 'bg-content-muted' : 'border border-content-faint bg-surface-card'}`}
/>
</span>
<span className="flex min-w-0 flex-wrap items-center gap-1.5">{children}</span>
</span>
);
}
@@ -126,44 +159,42 @@ export default function TourRouteStops({
const { t } = useTranslation();
const { head, folded, tail } = chipStops(card);
if (head.length === 0) return null;
const chip = (stop: TourRouteStop, index: number, list: readonly TourRouteStop[]) => {
// The time is walked from the last chip that has one: a place beside the way has none.
const before = list
.slice(0, index)
.reverse()
.find((other) => other.minutes !== null);
return (
<span className="inline-flex items-center gap-1.5">
{before && <Between from={before} to={stop} />}
<StopChip stop={stop} samples={samples} onFocus={onFocus} />
</span>
);
};
const names = folded.map((stop) => stopName(stop, t)).join(', ');
// The time is walked from the last stop that has one: a place beside the way has none.
let walked: TourRouteStop | undefined;
const rows: ReactNode[] = [];
const pushStop = (stop: TourRouteStop, key: string) => {
const leg = legMinutes(walked, stop);
if (rows.length > 0) rows.push(<Leg key={`${key}-leg`} minutes={leg} />);
rows.push(
<Row key={key} end={stop.role === 'start' || stop.role === 'end'} leg={leg}>
<StopChip stop={stop} samples={samples} onFocus={onFocus} />
</Row>
);
if (stop.minutes !== null) walked = stop;
};
head.forEach((stop, index) => pushStop(stop, `h-${index}`));
if (folded.length > 0) {
rows.push(<Leg key="f-leg" minutes={null} />);
rows.push(
<Row key="f" end={false}>
<Tooltip label={names}>
<span className="inline-flex">
<WhiteBadge>
{t('assistant.card.tour.stopsMore', { count: folded.length })}
<span className="sr-only">{`: ${names}`}</span>
</WhiteBadge>
</span>
</Tooltip>
</Row>
);
// The folded stretch's time is not one leg; the next stop measures from the last folded one.
walked = [...folded].reverse().find((stop) => stop.minutes !== null) ?? walked;
}
tail.forEach((stop, index) => pushStop(stop, `t-${index}`));
return (
<div className="mt-2 flex flex-wrap items-center gap-1.5" role="list" aria-label={t('assistant.card.tour.stops')}>
{head.map((stop, index) => (
<span key={`h-${index}`} role="listitem" className="inline-flex">
{chip(stop, index, head)}
</span>
))}
{folded.length > 0 && (
<span role="listitem" className="inline-flex">
<Tooltip label={names}>
<span className="inline-flex">
<WhiteBadge>
{t('assistant.card.tour.stopsMore', { count: folded.length })}
<span className="sr-only">{`: ${names}`}</span>
</WhiteBadge>
</span>
</Tooltip>
</span>
)}
{tail.map((stop, index) => (
<span key={`t-${index}`} role="listitem" className="inline-flex">
{chip(stop, index, tail)}
</span>
))}
<div className="mt-2 flex flex-col" role="list" aria-label={t('assistant.card.tour.stops')}>
{rows}
</div>
);
}
@@ -124,8 +124,10 @@ export const TOUR_LOOK = {
lines: tourRouteLines,
// A routed way draws its own line and stops: the pin of the place it adds would only cover its start.
hidesEffectGhost: (card) => card.routed && card.line.length >= 2,
hidesFoundPlaces: true,
},
tourList: {
hidesFoundPlaces: true,
followUps: [{ id: 'tourFit', labelKey: 'assistant.followUp.tourFit' }],
},
},
@@ -37,6 +37,33 @@ function proposalIndex(messages: readonly AssistantMessage[], toolCallId: string
/** Where a take of the proposal stands in the thread: waiting for a decision, applied, or none. */
type ThreadTake = 'waiting' | 'applied' | null;
/** The take a later answer holds for the proposal, with the id of its change; null when none waits or was applied. */
export function takeOfProposal(
messages: readonly AssistantMessage[],
toolCallId: string,
card: TourRouteCard
): { id: string; state: 'waiting' | 'applied'; placeId: number | null } | null {
const from = proposalIndex(messages, toolCallId);
if (from < 0) return null;
for (const message of messages.slice(from + 1)) {
const open = new Map<string, 'waiting' | 'applied'>();
for (const part of message.parts) {
if (part.type !== 'tool-call' || part.toolName !== TAKE_TOOL || part.approval?.resolution) continue;
if (part.approval?.approved !== false) open.set(part.toolCallId, part.approval?.approved ? 'applied' : 'waiting');
}
for (const part of message.parts) {
if (part.type !== 'data-trek-change') continue;
const state = open.get(part.data.toolCallId);
if (!state || !takenFromProposal(part.data, toolCallId, card)) continue;
const id = part.data.toolCallId;
const receipt = message.parts.find((p) => p.type === 'data-trek-applied' && p.data.toolCallId === id);
const placeId = receipt?.type === 'data-trek-applied' ? (receipt.data.ids[0] ?? null) : null;
return { id, state, placeId };
}
}
return null;
}
/**
* The take a later answer holds for the proposal, still waiting or applied.
* One discarded, cancelled or expired leaves the proposal free to be taken
@@ -47,19 +74,5 @@ export function takeInThread(
toolCallId: string,
card: TourRouteCard
): ThreadTake {
const from = proposalIndex(messages, toolCallId);
if (from < 0) return null;
for (const message of messages.slice(from + 1)) {
const open = new Map<string, ThreadTake>();
for (const part of message.parts) {
if (part.type !== 'tool-call' || part.toolName !== TAKE_TOOL || part.approval?.resolution) continue;
if (part.approval?.approved !== false) open.set(part.toolCallId, part.approval?.approved ? 'applied' : 'waiting');
}
for (const part of message.parts) {
if (part.type !== 'data-trek-change') continue;
const take = open.get(part.data.toolCallId);
if (take && takenFromProposal(part.data, toolCallId, card)) return take;
}
}
return null;
return takeOfProposal(messages, toolCallId, card)?.state ?? null;
}
@@ -10,8 +10,9 @@ import type { RouteProfileFocus } from '../../../../utils/routeGeometry';
import { hasUndecided } from '../../assistantParts';
import { useAssistantView } from '../../assistantViewContext';
import { useChangeHover } from '../../useChangeHover';
import { tourTarget } from './tourLook';
import { canTake, routePoints } from './tourRouteModel';
import { takeInThread } from './tourTakeModel';
import { takeInThread, takeOfProposal } from './tourTakeModel';
/**
* What a route card and the phone's route peek share: the spot of the route
@@ -103,10 +104,13 @@ function stateAfter(outcome: HoldOutcome, error: string | null): TakeState {
}
/**
* "Plan this" on a proposed hike or city walk: the server holds the tour it
* creates as a change below, to decide like any other, without a model turn.
* It shows for someone who may add places to the trip, and waits (saying
* why) while offline, while the assistant answers or while another change is
* "Plan this" on a proposed hike or city walk: one tap adds the tour. The
* server holds the tour it creates as a change, without a model turn, and
* the tap decides it at once, since tapping "Plan this" already is the
* decision; the assistant then answers to what was added. Once the tour is
* there, the desktop opens it in the Tours tab to be edited. It shows for
* someone who may add places to the trip, and waits (saying why) while
* offline, while the assistant answers or while another change is
* undecided, since one decision answers every open card. A proposal the
* server can no longer find or create is refused; the card then asks for it
* to be asked again. Taken is read from the thread too, so a card drawn again
@@ -129,7 +133,11 @@ export function useTakeProposal(toolCallId: string, card: TourRouteCard) {
})
);
const takeProposal = useAssistantStore((s) => s.takeProposal);
const decide = useAssistantStore((s) => s.decide);
const { variant, onOpen } = useAssistantView();
const [tapped, setState] = useState<TakeState>('idle');
// The take this card started and decides; its tour opens once it is applied, and only then.
const [started, setStarted] = useState<string | null>(null);
const state: TakeState = held !== null && tapped !== 'busy' ? 'taken' : tapped;
const mounted = useRef(true);
useEffect(() => {
@@ -151,7 +159,28 @@ export function useTakeProposal(toolCallId: string, card: TourRouteCard) {
if (!mounted.current) return;
// A refusal is kept as the chat's error, read only when the take was refused.
setState(stateAfter(outcome, chatOf(useAssistantStore.getState(), tripId).error));
}, [takeProposal, tripId, toolCallId]);
if (outcome !== 'held') return;
const waiting = takeOfProposal(chatOf(useAssistantStore.getState(), tripId).messages, toolCallId, card);
if (waiting?.state !== 'waiting') return;
setStarted(waiting.id);
await decide(tripId, [{ toolCallId: waiting.id, approved: true }]);
}, [takeProposal, decide, tripId, toolCallId, card]);
return { visible, disabled, reasonKey, state, applied: held === 'applied', take };
// The tour this card added opens in the Tours tab, where it is edited; the phone has no such tab.
const appliedPlace = useAssistantStore((s) => {
if (!started) return null;
const found = takeOfProposal(chatOf(s, tripId).messages, toolCallId, card);
return found?.id === started && found.state === 'applied' ? found.placeId : null;
});
// Opened once per take, whatever renders follow.
const opened = useRef<string | null>(null);
useEffect(() => {
if (appliedPlace === null || opened.current === started) return;
opened.current = started;
if (variant !== 'fullscreen' && onOpen) onOpen(tourTarget(appliedPlace));
}, [appliedPlace, started, variant, onOpen]);
// While the take this card started is being decided, the card says it is being planned, not that it waits below.
const deciding = started !== null && held === 'waiting';
return { visible, disabled, reasonKey, state, applied: held === 'applied', deciding, take };
}
+12 -2
View File
@@ -16,6 +16,8 @@ export const ASSISTANT_CAMERA_MS = 600;
const USER_MOVE_QUIET_MS = 5000;
/** How long a pin that just arrived keeps its pop class; the animation itself is shorter. */
const POP_HOLD_MS = 400;
/** A request this young when the map mounts was meant for this map: the chat switched to it to show something. */
const FRESH_REQUEST_MS = 5000;
/** The renderer's padding, widened on the side the assistant's window covers. */
export function coverPadding(base: ViewportPadding, cover: AssistantCamera['cover']): ViewportPadding {
@@ -52,7 +54,8 @@ export function useUserMoveClock(getContainer: () => HTMLElement | null, ready:
/**
* The request the camera should carry out now, or null. The one present when
* the map mounts is skipped (it was meant for a map that is gone), as is a
* the map mounts is skipped (it was meant for a map that is gone) unless it
* was asked for a moment ago (the chat switched to the map to show it), as is a
* repeated key, and a move the user did not ask for while they are moving
* the map themselves.
*/
@@ -60,8 +63,15 @@ export function useCameraRequest(
camera: AssistantCamera | null | undefined,
userMovedAt: { readonly current: number }
): (run: (camera: AssistantCamera) => void) => void {
const seen = useRef(camera?.key ?? null);
// The request on screen when the map mounted, judged on the first run: young enough, it was meant for this map.
const mounted = useRef(camera ?? null);
const seen = useRef<string | null>(null);
return (run) => {
const first = mounted.current;
if (first) {
mounted.current = null;
if (!(first.at && Date.now() - first.at < FRESH_REQUEST_MS)) seen.current = first.key;
}
if (!camera || camera.key === seen.current) return;
seen.current = camera.key;
if (camera.auto && Date.now() - userMovedAt.current < USER_MOVE_QUIET_MS) return;
@@ -266,6 +266,8 @@ export interface AssistantCamera {
mode: 'fit' | 'pan';
animate: boolean;
auto: boolean;
/** When it was asked for (ms since the epoch): a map that mounts right after still carries it out. */
at?: number;
cover?: { side: 'left' | 'right'; px: number } | null;
}
@@ -282,6 +282,7 @@ function useCameraProp(tripId: number, isMobile: boolean): AssistantMapProps['as
mode: camera.mode,
animate: !prefersReducedMotion(),
auto: camera.why === 'auto',
at: camera.at,
cover,
};
}, [camera, isMobile, windowRect]);
+10 -2
View File
@@ -131,7 +131,15 @@ interface AssistantState {
*/
mapLink: { tripId: number; hoveredId: string | null; source: AssistantMapSource } | null;
/** The latest request to move the map; `seq` tells a new one from the same points asked again. */
camera: { tripId: number; seq: number; points: LatLng[]; mode: 'fit' | 'pan'; why: 'user' | 'auto' } | null;
camera: {
tripId: number;
seq: number;
points: LatLng[];
mode: 'fit' | 'pan';
why: 'user' | 'auto';
/** When it was asked for, so a map that mounts right after (the chat switched to it) still moves. */
at: number;
} | null;
/**
* A spot a card points at (a found place, a row), drawn on the map until
* the pointer leaves; a place of the trip pulses its own pin instead.
@@ -794,7 +802,7 @@ async function holdWithoutTurn(
function requestCameraOn(ctx: Ctx, tripId: number, points: LatLng[], mode: 'fit' | 'pan', why: 'user' | 'auto') {
if (points.length === 0) return;
const seq = (ctx.get().camera?.seq ?? 0) + 1;
ctx.set({ camera: { tripId, seq, points, mode, why } });
ctx.set({ camera: { tripId, seq, points, mode, why, at: Date.now() } });
}
function mapActions(ctx: Ctx) {
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'خطّط لهذا',
'assistant.card.tour.planWay': 'خطّط للطريق {letter}',
'assistant.card.tour.taken': 'أُضيف أدناه لتأكيده',
'assistant.card.tour.adding': 'جارٍ التخطيط…',
'assistant.card.tour.added': 'أُضيفت إلى الرحلة',
'assistant.card.tour.askAgain': 'لم يعد بالإمكان التخطيط لهذا من هنا. اطلبه مرة أخرى.',
'assistant.card.tour.open': 'فتح في الجولات',
@@ -54,6 +55,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stopsMore.two': 'محطتان أخريان',
'assistant.card.tour.stopsMore.few': '{count} محطات أخرى',
'assistant.card.tour.stopsMore.many': '{count} محطة أخرى',
'assistant.card.tour.legTime': '{time} من المحطة السابقة',
'assistant.card.tour.alongOff': 'على بعد {distance} من الطريق',
'assistant.card.tour.flag.closed': 'مغلق حينها',
'assistant.card.tour.flag.late': 'متأخر عن الموعد',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Planlaşdır',
'assistant.card.tour.planWay': '{letter} yolunu planlaşdır',
'assistant.card.tour.taken': 'Təsdiqləməyin üçün aşağıda əlavə olundu',
'assistant.card.tour.adding': 'Planlaşdırılır…',
'assistant.card.tour.added': 'Səyahətə əlavə edildi',
'assistant.card.tour.askAgain': 'Bunu artıq buradan planlaşdırmaq olmur. Yenidən soruşun.',
'assistant.card.tour.open': 'Turlarda aç',
@@ -46,6 +47,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stay': 'Qalma {time}',
'assistant.card.tour.stopsMore': 'Daha {count} dayanacaq',
'assistant.card.tour.stopsMore.one': 'Daha {count} dayanacaq',
'assistant.card.tour.legTime': 'əvvəlki dayanacaqdan {time}',
'assistant.card.tour.alongOff': 'Yoldan {distance} kənarda',
'assistant.card.tour.flag.closed': 'O vaxt bağlıdır',
'assistant.card.tour.flag.late': 'Görüşə gecikir',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Planejar',
'assistant.card.tour.planWay': 'Planejar o caminho {letter}',
'assistant.card.tour.taken': 'Adicionado abaixo para você confirmar',
'assistant.card.tour.adding': 'Planejando…',
'assistant.card.tour.added': 'Adicionado à viagem',
'assistant.card.tour.askAgain': 'Isso não pode mais ser planejado daqui. Peça de novo.',
'assistant.card.tour.open': 'Abrir em Passeios',
@@ -46,6 +47,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stay': 'Permanência de {time}',
'assistant.card.tour.stopsMore': 'Mais {count} paradas',
'assistant.card.tour.stopsMore.one': 'Mais {count} parada',
'assistant.card.tour.legTime': '{time} desde a última parada',
'assistant.card.tour.alongOff': 'A {distance} do caminho',
'assistant.card.tour.flag.closed': 'Fechado nesse horário',
'assistant.card.tour.flag.late': 'Atrasado para o compromisso',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Planifica-ho',
'assistant.card.tour.planWay': 'Planifica el camí {letter}',
'assistant.card.tour.taken': 'Afegit a sota perquè ho confirmis',
'assistant.card.tour.adding': "S'està planificant…",
'assistant.card.tour.added': 'Afegit al viatge',
'assistant.card.tour.askAgain': 'Això ja no es pot planificar des d’aquí. Torna-ho a demanar.',
'assistant.card.tour.open': 'Obre a Excursions',
@@ -46,6 +47,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stay': 'Estada de {time}',
'assistant.card.tour.stopsMore': '{count} parades més',
'assistant.card.tour.stopsMore.one': '{count} parada més',
'assistant.card.tour.legTime': "{time} des de l'última parada",
'assistant.card.tour.alongOff': 'A {distance} del camí',
'assistant.card.tour.flag.closed': 'Tancat a aquella hora',
'assistant.card.tour.flag.late': 'Tard per a la cita',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Naplánovat',
'assistant.card.tour.planWay': 'Naplánovat cestu {letter}',
'assistant.card.tour.taken': 'Přidáno níže k potvrzení',
'assistant.card.tour.adding': 'Plánuje se…',
'assistant.card.tour.added': 'Přidáno do cesty',
'assistant.card.tour.askAgain': 'Odsud to už naplánovat nejde. Zeptej se na to znovu.',
'assistant.card.tour.open': 'Otevřít ve Výletech',
@@ -48,6 +49,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stopsMore': 'Dalších {count} zastávek',
'assistant.card.tour.stopsMore.one': 'Další {count} zastávka',
'assistant.card.tour.stopsMore.few': 'Další {count} zastávky',
'assistant.card.tour.legTime': '{time} od poslední zastávky',
'assistant.card.tour.alongOff': '{distance} od cesty',
'assistant.card.tour.flag.closed': 'V tu dobu zavřeno',
'assistant.card.tour.flag.late': 'Pozdě na schůzku',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Einplanen',
'assistant.card.tour.planWay': 'Weg {letter} einplanen',
'assistant.card.tour.taken': 'Unten zum Bestätigen hinzugefügt',
'assistant.card.tour.adding': 'Wird eingeplant…',
'assistant.card.tour.added': 'Zur Reise hinzugefügt',
'assistant.card.tour.askAgain': 'Das lässt sich hier nicht mehr einplanen. Frag einfach noch einmal danach.',
'assistant.card.tour.open': 'In Touren öffnen',
@@ -46,6 +47,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stay': 'Aufenthalt {time}',
'assistant.card.tour.stopsMore': '{count} weitere Stopps',
'assistant.card.tour.stopsMore.one': '{count} weiterer Stopp',
'assistant.card.tour.legTime': '{time} vom letzten Stopp',
'assistant.card.tour.alongOff': '{distance} abseits vom Weg',
'assistant.card.tour.flag.closed': 'Dann geschlossen',
'assistant.card.tour.flag.late': 'Zu spät für den Termin',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Plan this',
'assistant.card.tour.planWay': 'Plan way {letter}',
'assistant.card.tour.taken': 'Added below for you to confirm',
'assistant.card.tour.adding': 'Planning it…',
'assistant.card.tour.added': 'Added to the trip',
'assistant.card.tour.askAgain': 'This can no longer be planned from here. Ask for it again.',
'assistant.card.tour.open': 'Open in Tours',
@@ -46,6 +47,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stay': 'Stay {time}',
'assistant.card.tour.stopsMore': '{count} more stops',
'assistant.card.tour.stopsMore.one': '{count} more stop',
'assistant.card.tour.legTime': '{time} from the last stop',
'assistant.card.tour.alongOff': '{distance} off the way',
'assistant.card.tour.flag.closed': 'Closed then',
'assistant.card.tour.flag.late': 'Late for the appointment',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Planificar',
'assistant.card.tour.planWay': 'Planificar el camino {letter}',
'assistant.card.tour.taken': 'Añadido abajo para que lo confirmes',
'assistant.card.tour.adding': 'Planificando…',
'assistant.card.tour.added': 'Añadido al viaje',
'assistant.card.tour.askAgain': 'Esto ya no se puede planificar desde aquí. Vuelve a pedirlo.',
'assistant.card.tour.open': 'Abrir en Excursiones',
@@ -46,6 +47,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stay': 'Estancia de {time}',
'assistant.card.tour.stopsMore': '{count} paradas más',
'assistant.card.tour.stopsMore.one': '{count} parada más',
'assistant.card.tour.legTime': '{time} desde la última parada',
'assistant.card.tour.alongOff': 'A {distance} del camino',
'assistant.card.tour.flag.closed': 'Cerrado a esa hora',
'assistant.card.tour.flag.late': 'Tarde para la cita',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Planeeri',
'assistant.card.tour.planWay': 'Planeeri tee {letter}',
'assistant.card.tour.taken': 'Lisatud allpool kinnitamiseks',
'assistant.card.tour.adding': 'Planeerin…',
'assistant.card.tour.added': 'Reisile lisatud',
'assistant.card.tour.askAgain': 'Seda ei saa siit enam planeerida. Küsi seda uuesti.',
'assistant.card.tour.open': 'Ava retkedes',
@@ -46,6 +47,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stay': 'Viibimine {time}',
'assistant.card.tour.stopsMore': 'Veel {count} peatust',
'assistant.card.tour.stopsMore.one': 'Veel {count} peatus',
'assistant.card.tour.legTime': '{time} eelmisest peatusest',
'assistant.card.tour.alongOff': '{distance} teest eemal',
'assistant.card.tour.flag.closed': 'Siis suletud',
'assistant.card.tour.flag.late': 'Kohtumisele hiljaks',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Planifier',
'assistant.card.tour.planWay': 'Planifier le chemin {letter}',
'assistant.card.tour.taken': 'Ajouté ci-dessous, à confirmer',
'assistant.card.tour.adding': 'Planification…',
'assistant.card.tour.added': 'Ajouté au voyage',
'assistant.card.tour.askAgain': "Ceci ne peut plus être planifié d'ici. Redemandez-le.",
'assistant.card.tour.open': 'Ouvrir dans Sorties',
@@ -46,6 +47,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stay': 'Pause de {time}',
'assistant.card.tour.stopsMore': '{count} autres arrêts',
'assistant.card.tour.stopsMore.one': '{count} autre arrêt',
'assistant.card.tour.legTime': '{time} depuis le dernier arrêt',
'assistant.card.tour.alongOff': 'À {distance} du chemin',
'assistant.card.tour.flag.closed': 'Fermé à ce moment',
'assistant.card.tour.flag.late': 'En retard pour le rendez-vous',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Προγραμμάτισε',
'assistant.card.tour.planWay': 'Προγραμμάτισε τη διαδρομή {letter}',
'assistant.card.tour.taken': 'Προστέθηκε παρακάτω για επιβεβαίωση',
'assistant.card.tour.adding': 'Προγραμματίζεται…',
'assistant.card.tour.added': 'Προστέθηκε στο ταξίδι',
'assistant.card.tour.askAgain': 'Αυτό δεν μπορεί πια να προγραμματιστεί από εδώ. Ζήτησέ το ξανά.',
'assistant.card.tour.open': 'Άνοιγμα στις Εκδρομές',
@@ -46,6 +47,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stay': 'Παραμονή {time}',
'assistant.card.tour.stopsMore': 'Άλλες {count} στάσεις',
'assistant.card.tour.stopsMore.one': 'Άλλη {count} στάση',
'assistant.card.tour.legTime': '{time} από την προηγούμενη στάση',
'assistant.card.tour.alongOff': '{distance} έξω από τη διαδρομή',
'assistant.card.tour.flag.closed': 'Κλειστό εκείνη την ώρα',
'assistant.card.tour.flag.late': 'Καθυστέρηση για το ραντεβού',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Megtervezés',
'assistant.card.tour.planWay': 'A(z) {letter} út megtervezése',
'assistant.card.tour.taken': 'Lent hozzáadva, hogy megerősítsd',
'assistant.card.tour.adding': 'Tervezés…',
'assistant.card.tour.added': 'Hozzáadva az utazáshoz',
'assistant.card.tour.askAgain': 'Ezt innen már nem lehet megtervezni. Kérd újra.',
'assistant.card.tour.open': 'Megnyitás a Túrákban',
@@ -46,6 +47,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stay': 'Tartózkodás: {time}',
'assistant.card.tour.stopsMore': 'Még {count} megálló',
'assistant.card.tour.stopsMore.one': 'Még {count} megálló',
'assistant.card.tour.legTime': '{time} az előző megállótól',
'assistant.card.tour.alongOff': '{distance} távolságra az úttól',
'assistant.card.tour.flag.closed': 'Akkor zárva',
'assistant.card.tour.flag.late': 'Késés a találkozóról',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Rencanakan',
'assistant.card.tour.planWay': 'Rencanakan jalan {letter}',
'assistant.card.tour.taken': 'Ditambahkan di bawah untuk kamu konfirmasi',
'assistant.card.tour.adding': 'Sedang direncanakan…',
'assistant.card.tour.added': 'Ditambahkan ke perjalanan',
'assistant.card.tour.askAgain': 'Ini tidak bisa lagi direncanakan dari sini. Minta lagi.',
'assistant.card.tour.open': 'Buka di Tur',
@@ -44,6 +45,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stop.other': 'Perhentian',
'assistant.card.tour.stay': 'Singgah {time}',
'assistant.card.tour.stopsMore': '{count} perhentian lagi',
'assistant.card.tour.legTime': '{time} dari perhentian sebelumnya',
'assistant.card.tour.alongOff': '{distance} dari jalan',
'assistant.card.tour.flag.closed': 'Tutup saat itu',
'assistant.card.tour.flag.late': 'Terlambat untuk janji',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Pianifica',
'assistant.card.tour.planWay': 'Pianifica il percorso {letter}',
'assistant.card.tour.taken': 'Aggiunto qui sotto da confermare',
'assistant.card.tour.adding': 'Pianificazione…',
'assistant.card.tour.added': 'Aggiunto al viaggio',
'assistant.card.tour.askAgain': 'Non si può più pianificare da qui. Chiedilo di nuovo.',
'assistant.card.tour.open': 'Apri in Escursioni',
@@ -46,6 +47,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stay': 'Sosta di {time}',
'assistant.card.tour.stopsMore': 'Altre {count} tappe',
'assistant.card.tour.stopsMore.one': 'Altra {count} tappa',
'assistant.card.tour.legTime': "{time} dall'ultima tappa",
'assistant.card.tour.alongOff': 'A {distance} dal percorso',
'assistant.card.tour.flag.closed': 'Chiuso a quell’ora',
'assistant.card.tour.flag.late': 'In ritardo per l’appuntamento',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': '計画に追加',
'assistant.card.tour.planWay': '道{letter}を計画に追加',
'assistant.card.tour.taken': '確認できるよう下に追加しました',
'assistant.card.tour.adding': '計画中…',
'assistant.card.tour.added': '旅行に追加しました',
'assistant.card.tour.askAgain': 'ここからはもう計画できません。もう一度頼んでください。',
'assistant.card.tour.open': 'ツアーで開く',
@@ -44,6 +45,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stop.other': '立ち寄り先',
'assistant.card.tour.stay': '滞在{time}',
'assistant.card.tour.stopsMore': 'ほか{count}か所',
'assistant.card.tour.legTime': '前の地点から {time}',
'assistant.card.tour.alongOff': '道から{distance}',
'assistant.card.tour.flag.closed': 'その時間は閉まっています',
'assistant.card.tour.flag.late': '約束の時間に遅れます',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': '계획에 넣기',
'assistant.card.tour.planWay': '길 {letter} 계획에 넣기',
'assistant.card.tour.taken': '확인할 수 있게 아래에 추가했어요',
'assistant.card.tour.adding': '계획하는 중…',
'assistant.card.tour.added': '여행에 추가했어요',
'assistant.card.tour.askAgain': '여기서는 더 이상 계획할 수 없어요. 다시 요청해 주세요.',
'assistant.card.tour.open': '투어에서 열기',
@@ -44,6 +45,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stop.other': '정류 지점',
'assistant.card.tour.stay': '머무름 {time}',
'assistant.card.tour.stopsMore': '정류 지점 {count}곳 더',
'assistant.card.tour.legTime': '이전 지점에서 {time}',
'assistant.card.tour.alongOff': '길에서 {distance} 떨어짐',
'assistant.card.tour.flag.closed': '그 시간엔 닫혀 있어요',
'assistant.card.tour.flag.late': '약속에 늦어요',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Inplannen',
'assistant.card.tour.planWay': 'Weg {letter} inplannen',
'assistant.card.tour.taken': 'Hieronder toegevoegd om te bevestigen',
'assistant.card.tour.adding': 'Wordt ingepland…',
'assistant.card.tour.added': 'Toegevoegd aan de reis',
'assistant.card.tour.askAgain': 'Dit kan hier niet meer worden ingepland. Vraag er opnieuw om.',
'assistant.card.tour.open': 'Openen in Tochten',
@@ -46,6 +47,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stay': 'Verblijf van {time}',
'assistant.card.tour.stopsMore': 'Nog {count} stops',
'assistant.card.tour.stopsMore.one': 'Nog {count} stop',
'assistant.card.tour.legTime': '{time} vanaf de vorige stop',
'assistant.card.tour.alongOff': '{distance} van de weg af',
'assistant.card.tour.flag.closed': 'Dan gesloten',
'assistant.card.tour.flag.late': 'Te laat voor de afspraak',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Zaplanuj',
'assistant.card.tour.planWay': 'Zaplanuj drogę {letter}',
'assistant.card.tour.taken': 'Dodano poniżej do potwierdzenia',
'assistant.card.tour.adding': 'Planowanie…',
'assistant.card.tour.added': 'Dodano do podróży',
'assistant.card.tour.askAgain': 'Tego nie da się już stąd zaplanować. Poproś o to jeszcze raz.',
'assistant.card.tour.open': 'Otwórz w Wycieczkach',
@@ -50,6 +51,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stopsMore.one': 'Jeszcze {count} przystanek',
'assistant.card.tour.stopsMore.few': 'Jeszcze {count} przystanki',
'assistant.card.tour.stopsMore.many': 'Jeszcze {count} przystanków',
'assistant.card.tour.legTime': '{time} od poprzedniego przystanku',
'assistant.card.tour.alongOff': '{distance} od drogi',
'assistant.card.tour.flag.closed': 'Wtedy zamknięte',
'assistant.card.tour.flag.late': 'Za późno na spotkanie',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Запланировать',
'assistant.card.tour.planWay': 'Запланировать путь {letter}',
'assistant.card.tour.taken': 'Добавлено ниже, подтвердите',
'assistant.card.tour.adding': 'Планируется…',
'assistant.card.tour.added': 'Добавлено в поездку',
'assistant.card.tour.askAgain': 'Отсюда это уже не запланировать. Спросите ещё раз.',
'assistant.card.tour.open': 'Открыть в Турах',
@@ -50,6 +51,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stopsMore.one': 'Ещё {count} остановка',
'assistant.card.tour.stopsMore.few': 'Ещё {count} остановки',
'assistant.card.tour.stopsMore.many': 'Ещё {count} остановок',
'assistant.card.tour.legTime': '{time} от предыдущей остановки',
'assistant.card.tour.alongOff': '{distance} от пути',
'assistant.card.tour.flag.closed': 'В это время закрыто',
'assistant.card.tour.flag.late': 'Опоздание на встречу',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Naplánovať',
'assistant.card.tour.planWay': 'Naplánovať cestu {letter}',
'assistant.card.tour.taken': 'Pridané nižšie na potvrdenie',
'assistant.card.tour.adding': 'Plánuje sa…',
'assistant.card.tour.added': 'Pridané do cesty',
'assistant.card.tour.askAgain': 'Odtiaľto sa to už naplánovať nedá. Opýtaj sa na to znova.',
'assistant.card.tour.open': 'Otvoriť v Túrach',
@@ -48,6 +49,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stopsMore': 'Ďalších {count} zastávok',
'assistant.card.tour.stopsMore.one': 'Ďalšia {count} zastávka',
'assistant.card.tour.stopsMore.few': 'Ďalšie {count} zastávky',
'assistant.card.tour.legTime': '{time} od poslednej zastávky',
'assistant.card.tour.alongOff': '{distance} od cesty',
'assistant.card.tour.flag.closed': 'V tom čase zatvorené',
'assistant.card.tour.flag.late': 'Neskoro na stretnutie',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Planera',
'assistant.card.tour.planWay': 'Planera väg {letter}',
'assistant.card.tour.taken': 'Tillagd nedan för dig att bekräfta',
'assistant.card.tour.adding': 'Planeras…',
'assistant.card.tour.added': 'Tillagd i resan',
'assistant.card.tour.askAgain': 'Det här kan inte längre planeras härifrån. Fråga efter det igen.',
'assistant.card.tour.open': 'Öppna i Turer',
@@ -46,6 +47,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stay': 'Vistelse {time}',
'assistant.card.tour.stopsMore': '{count} stopp till',
'assistant.card.tour.stopsMore.one': '{count} stopp till',
'assistant.card.tour.legTime': '{time} från förra stoppet',
'assistant.card.tour.alongOff': '{distance} från vägen',
'assistant.card.tour.flag.closed': 'Stängt då',
'assistant.card.tour.flag.late': 'Sen till mötet',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'วางแผนสิ่งนี้',
'assistant.card.tour.planWay': 'วางแผนเส้นทาง {letter}',
'assistant.card.tour.taken': 'เพิ่มไว้ด้านล่างให้คุณยืนยันแล้ว',
'assistant.card.tour.adding': 'กำลังวางแผน…',
'assistant.card.tour.added': 'เพิ่มในทริปแล้ว',
'assistant.card.tour.askAgain': 'วางแผนจากตรงนี้ไม่ได้แล้ว โปรดขอใหม่อีกครั้ง',
'assistant.card.tour.open': 'เปิดในทัวร์',
@@ -44,6 +45,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stop.other': 'จุดแวะ',
'assistant.card.tour.stay': 'แวะพัก {time}',
'assistant.card.tour.stopsMore': 'อีก {count} จุดแวะ',
'assistant.card.tour.legTime': '{time} จากจุดก่อนหน้า',
'assistant.card.tour.alongOff': 'ห่างจากเส้นทาง {distance}',
'assistant.card.tour.flag.closed': 'ปิดในเวลานั้น',
'assistant.card.tour.flag.late': 'ไม่ทันนัดหมาย',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Planla',
'assistant.card.tour.planWay': '{letter} yolunu planla',
'assistant.card.tour.taken': 'Onaylaman için aşağıya eklendi',
'assistant.card.tour.adding': 'Planlanıyor…',
'assistant.card.tour.added': 'Geziye eklendi',
'assistant.card.tour.askAgain': 'Bu artık buradan planlanamıyor. Yeniden iste.',
'assistant.card.tour.open': 'Turlar sekmesinde aç',
@@ -46,6 +47,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stay': 'Kalış {time}',
'assistant.card.tour.stopsMore': '{count} durak daha',
'assistant.card.tour.stopsMore.one': '{count} durak daha',
'assistant.card.tour.legTime': 'önceki duraktan {time}',
'assistant.card.tour.alongOff': 'Yoldan {distance} uzakta',
'assistant.card.tour.flag.closed': 'O saatte kapalı',
'assistant.card.tour.flag.late': 'Randevuya geç',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Запланувати',
'assistant.card.tour.planWay': 'Запланувати шлях {letter}',
'assistant.card.tour.taken': 'Додано нижче, підтвердьте',
'assistant.card.tour.adding': 'Плануємо…',
'assistant.card.tour.added': 'Додано до подорожі',
'assistant.card.tour.askAgain': 'Звідси це вже не запланувати. Запитайте ще раз.',
'assistant.card.tour.open': 'Відкрити в Турах',
@@ -50,6 +51,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stopsMore.one': 'Ще {count} зупинка',
'assistant.card.tour.stopsMore.few': 'Ще {count} зупинки',
'assistant.card.tour.stopsMore.many': 'Ще {count} зупинок',
'assistant.card.tour.legTime': '{time} від попередньої зупинки',
'assistant.card.tour.alongOff': '{distance} від шляху',
'assistant.card.tour.flag.closed': 'У цей час зачинено',
'assistant.card.tour.flag.late': 'Запізнення на зустріч',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': 'Lên kế hoạch',
'assistant.card.tour.planWay': 'Lên kế hoạch lối đi {letter}',
'assistant.card.tour.taken': 'Đã thêm bên dưới để bạn xác nhận',
'assistant.card.tour.adding': 'Đang lên kế hoạch…',
'assistant.card.tour.added': 'Đã thêm vào chuyến đi',
'assistant.card.tour.askAgain': 'Không thể lên kế hoạch từ đây nữa. Hãy hỏi lại.',
'assistant.card.tour.open': 'Mở trong Chuyến tham quan',
@@ -44,6 +45,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stop.other': 'Điểm dừng',
'assistant.card.tour.stay': 'Dừng {time}',
'assistant.card.tour.stopsMore': 'Thêm {count} điểm dừng',
'assistant.card.tour.legTime': '{time} từ điểm dừng trước',
'assistant.card.tour.alongOff': 'Cách lối đi {distance}',
'assistant.card.tour.flag.closed': 'Lúc đó đóng cửa',
'assistant.card.tour.flag.late': 'Trễ hẹn',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': '加入計畫',
'assistant.card.tour.planWay': '將道路{letter}加入計畫',
'assistant.card.tour.taken': '已新增到下方,等你確認',
'assistant.card.tour.adding': '正在安排…',
'assistant.card.tour.added': '已加入旅程',
'assistant.card.tour.askAgain': '這裡已無法再加入計畫,請重新詢問。',
'assistant.card.tour.open': '在遊程中開啟',
@@ -44,6 +45,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stop.other': '停留點',
'assistant.card.tour.stay': '停留{time}',
'assistant.card.tour.stopsMore': '還有{count}個停留點',
'assistant.card.tour.legTime': '距上一站 {time}',
'assistant.card.tour.alongOff': '距道路{distance}',
'assistant.card.tour.flag.closed': '屆時關閉',
'assistant.card.tour.flag.late': '趕不上約定時間',
+2
View File
@@ -12,6 +12,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.plan': '加入计划',
'assistant.card.tour.planWay': '将道路{letter}加入计划',
'assistant.card.tour.taken': '已添加到下方,等你确认',
'assistant.card.tour.adding': '正在安排…',
'assistant.card.tour.added': '已加入行程',
'assistant.card.tour.askAgain': '这里已无法再加入计划,请重新询问。',
'assistant.card.tour.open': '在游览中打开',
@@ -44,6 +45,7 @@ const assistantTour: TranslationStrings = {
'assistant.card.tour.stop.other': '停留点',
'assistant.card.tour.stay': '停留{time}',
'assistant.card.tour.stopsMore': '还有{count}个停留点',
'assistant.card.tour.legTime': '距上一站 {time}',
'assistant.card.tour.alongOff': '距道路{distance}',
'assistant.card.tour.flag.closed': '届时关闭',
'assistant.card.tour.flag.late': '赶不上约定时间',