mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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': 'متأخر عن الموعد',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Καθυστέρηση για το ραντεβού',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '約束の時間に遅れます',
|
||||
|
||||
@@ -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': '약속에 늦어요',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Опоздание на встречу',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'ไม่ทันนัดหมาย',
|
||||
|
||||
@@ -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ç',
|
||||
|
||||
@@ -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': 'Запізнення на зустріч',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '趕不上約定時間',
|
||||
|
||||
@@ -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': '赶不上约定时间',
|
||||
|
||||
Reference in New Issue
Block a user