mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd2431db97 | |||
| 746f53ff7e | |||
| 7f9a4da52a | |||
| 8043b7952d | |||
| e198392060 | |||
| deea669e28 | |||
| d6b624e400 | |||
| 263d650460 | |||
| a3fc696510 | |||
| 9f6f62c6ae |
@@ -1,10 +1,11 @@
|
||||
import { Check, LoaderCircle, MapPin, Undo2 } from 'lucide-react';
|
||||
import { Check, CheckCheck, ListChecks, LoaderCircle, MapPin, Undo2 } from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { blockKeyOf } from '../../store/assistantMapModel';
|
||||
import { chatOf, useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import { DialogButton, NEUTRAL_TINT } from '../shared/DialogShell';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { Tooltip } from '../shared/Tooltip';
|
||||
import AssistantCompareSwitch from './AssistantCompareSwitch';
|
||||
@@ -13,6 +14,8 @@ import AssistantWeekOverview from './AssistantWeekOverview';
|
||||
import { type ChangeItem, undoLeftMs } from './assistantParts';
|
||||
import { useAssistantView } from './assistantViewContext';
|
||||
import { ChangeRows } from './cards/ChangeCardSlots';
|
||||
import CardHeader from './cards/parts/CardHeader';
|
||||
import { CardFoot } from './cards/parts/CardSection';
|
||||
import { ICON_BUTTON } from './cards/parts/assistantTones';
|
||||
import { useChangeExclusions } from './cards/useChangeExclusions';
|
||||
import { changePoints, type DayChange } from './dayAfterModel';
|
||||
@@ -60,6 +63,9 @@ function undoableNow(item: ChangeItem, undone: Readonly<Record<string, true>>, n
|
||||
);
|
||||
}
|
||||
|
||||
/** The band of a card whose changes are all decided: the green of a done booking. */
|
||||
const DONE_TINT = 'color-mix(in srgb, var(--success) 9%, transparent)';
|
||||
|
||||
/** The waiting changes the user left on that touch the day plan. */
|
||||
function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string>): DayChange[] {
|
||||
return items.flatMap((i) =>
|
||||
@@ -69,17 +75,18 @@ function dayChangesOf(items: readonly ChangeItem[], excluded: ReadonlySet<string
|
||||
);
|
||||
}
|
||||
|
||||
/** The card's head: what the card is, what can be done with it as a whole, and how many changes it holds. */
|
||||
/** The change card's band: tinted green once everything in it is decided, what it holds and its actions. */
|
||||
function CardHead({ open, count, actions }: { open: boolean; count: number; actions?: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="mb-1 flex min-h-7 items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-caption font-semibold text-content-secondary">
|
||||
{t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
|
||||
</span>
|
||||
{actions}
|
||||
<span className="text-caption tabular-nums text-content-faint">{count}</span>
|
||||
</div>
|
||||
<CardHeader
|
||||
icon={open ? ListChecks : CheckCheck}
|
||||
tone={open ? 'info' : 'success'}
|
||||
tint={open ? NEUTRAL_TINT : DONE_TINT}
|
||||
title={t(open ? 'assistant.change.title' : 'assistant.change.doneTitle')}
|
||||
count={count}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -224,7 +231,7 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
|
||||
|
||||
const open = pending.length > 0;
|
||||
return (
|
||||
<section className="trek-popover-enter rounded-xl border border-edge-faint bg-surface-card px-3 pb-2 pt-2.5 shadow-card">
|
||||
<section className="trek-popover-enter overflow-hidden rounded-[16px] border border-edge-faint bg-surface-card px-3.5 pb-3 pt-3 shadow-card">
|
||||
<CardHead
|
||||
open={open}
|
||||
count={items.length}
|
||||
@@ -257,25 +264,19 @@ export default function AssistantChangeCard({ items }: { items: ChangeItem[] })
|
||||
<p className="sr-only" role="status">
|
||||
{t('assistant.change.pending', { count: pending.length })}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2 border-t border-edge-faint pt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRunning}
|
||||
onClick={() => void submit(false)}
|
||||
className="rounded-lg px-3 py-1.5 text-body text-content-secondary hover:bg-surface-hover hover:text-content disabled:opacity-50"
|
||||
>
|
||||
<CardFoot>
|
||||
<DialogButton disabled={isRunning} onClick={() => void submit(false)}>
|
||||
{t('assistant.change.discard')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
variant="primary"
|
||||
disabled={isRunning || chosen === 0}
|
||||
onClick={() => void submit(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-body font-medium text-accent-text hover:bg-accent-hover disabled:opacity-50"
|
||||
icon={<Check size={14} aria-hidden="true" />}
|
||||
>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
{pending.length === 1 ? t('assistant.change.apply') : t('assistant.change.applyN', { count: chosen })}
|
||||
</button>
|
||||
</div>
|
||||
</DialogButton>
|
||||
</CardFoot>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useLocation, useMatch, useNavigate } from 'react-router';
|
||||
|
||||
import { rememberListsSubTab } from '../../hooks/useListsSubTab';
|
||||
import { useAssistantStore } from '../../store/assistantStore';
|
||||
import { useTripStore } from '../../store/tripStore';
|
||||
import AssistantWindow from './AssistantWindow';
|
||||
import { plannerSearchOf } from './assistantShowOnMap';
|
||||
@@ -22,7 +23,7 @@ function useRouteTripId(): number | null {
|
||||
* 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, and "On
|
||||
* map" on a card brings the planner's map back first.
|
||||
* map" on a card brings the planner's map back first from a tab without one.
|
||||
*/
|
||||
export default function AssistantHost() {
|
||||
const navigate = useNavigate();
|
||||
@@ -54,8 +55,12 @@ 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]);
|
||||
// "On map" from a tab without a map (Costs, Lists, ...) first brings the plan's map back; the Tours tab
|
||||
// shows the route on its own map. A map on screen tells the assistant its view, which is how it is known.
|
||||
const revealMap = useCallback(() => {
|
||||
if (useAssistantStore.getState().mapView?.tripId === tripId) return;
|
||||
openPlanner('?tab=plan');
|
||||
}, [openPlanner, tripId]);
|
||||
|
||||
return <AssistantWindow trip={trip} onOpen={open} onFocusDay={focusDay} onRevealMap={revealMap} />;
|
||||
}
|
||||
|
||||
@@ -111,6 +111,14 @@ describe('the questions an answer offers', () => {
|
||||
|
||||
it('FE-AI-FOLLOW-007: an answer that asks the user something waits for their answer, not for "tell me more"', () => {
|
||||
expect(followUpsFor(of([{ type: 'text', text: 'Should I plan it anyway?' }]), true)).toEqual([]);
|
||||
// Not the found places' chips either, but the model's own answers to its question.
|
||||
const asked = of([
|
||||
{ type: 'data-trek-places', data: { toolCallId: 's', places: [], more: 0 } },
|
||||
{ type: 'text', text: 'Shall I plan it there?' },
|
||||
]);
|
||||
expect(followUpsFor(asked, true)).toEqual([]);
|
||||
const offered = of([...asked.parts, { type: 'data-trek-follow-ups', data: { questions: ['Yes, plan it'] } }]);
|
||||
expect(followUpsFor(offered, true).map((f) => f.text)).toEqual(['Yes, plan it']);
|
||||
expect(followUpsFor(of([{ type: 'text', text: 'Here it is.' }]), true).map((f) => f.id)).toEqual(['more', 'next']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,11 +126,12 @@ export function followUpsFor(message: AssistantMessage, canEdit: boolean): reado
|
||||
if (fromModel.length > 0) return fromModel;
|
||||
const fromCards = cardFollowUps(message, canEdit);
|
||||
if (fromCards.length > 0) return fromCards;
|
||||
// An answer that asks the user something waits for their answer: only the model's own offer fits it.
|
||||
if (endsWithQuestion(message)) return [];
|
||||
const fromChanges = changeFollowUps(message, canEdit);
|
||||
if (fromChanges !== null) return fromChanges.length > 0 ? fromChanges : GENERAL;
|
||||
const types = new Set(message.parts.map((part) => part.type));
|
||||
if (types.has('data-trek-places')) return PLACES;
|
||||
if (types.has('data-trek-weather')) return WEATHER;
|
||||
// An answer that asks the user something waits for their answer, not for "tell me more".
|
||||
return types.has('text') && !endsWithQuestion(message) ? GENERAL : [];
|
||||
return types.has('text') ? GENERAL : [];
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ function BodySlot({ item, settled }: { item: ChangeItem; settled: boolean }) {
|
||||
const view = item.card ? viewOf(item.card) : null;
|
||||
if (!view?.Body || !item.card) return null;
|
||||
return (
|
||||
<div className={`pb-1.5 ps-[38px] ${settled ? 'opacity-60' : ''}`}>
|
||||
<div className={`mt-1.5 pb-1.5 ${view.wideBody ? '' : 'ps-[38px]'} ${settled ? 'opacity-60' : ''}`}>
|
||||
<Slot item={item} view={view} name="Body" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AssistantMessage, AtlasCountryCard as CountryCard } from '@trek/shared';
|
||||
import { Briefcase, MapPin, Radio } from 'lucide-react';
|
||||
import { Briefcase, Globe2, MapPin, Radio } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
@@ -8,8 +8,10 @@ import type { ChangeItem } from '../../assistantParts';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import { CardFacts } from '../parts/CardFacts';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { CardMore } from '../parts/CardRows';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { CONTINENT_LABEL, continentOf, shortDay, STATUS_LOOK } from './atlasCardModel';
|
||||
import { ATLAS_PAGE } from './atlasPage';
|
||||
import { ChangeFlag, FlagTile, StatusFact } from './AtlasParts';
|
||||
@@ -112,14 +114,15 @@ export default function AtlasCountryBlock({ cards }: { cards: readonly CardEntry
|
||||
if (countries.length === 0) return null;
|
||||
return (
|
||||
<CardFrame label={t('assistant.domain.atlas')}>
|
||||
<CardHeader icon={Globe2} tone="info" title={t('assistant.domain.atlas')} />
|
||||
{countries.map((entry, index) => (
|
||||
<div key={entry.toolCallId} className={index > 0 ? 'mt-2 border-t border-edge-faint pt-2' : ''}>
|
||||
{entry.card.kind === 'atlasCountry' && <CountrySection card={entry.card} />}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-1 flex justify-end">
|
||||
<CardFoot>
|
||||
<CardOpenButton target={ATLAS_PAGE} labelKey="assistant.card.atlas.open" />
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,6 @@ export function ExpenseFacts({ card }: SlotProps) {
|
||||
<>
|
||||
<CategoryFact row={row} />
|
||||
<DateFact row={row} />
|
||||
{row.place && <PlaceFact place={row.place} />}
|
||||
{split && (
|
||||
<FactChange
|
||||
icon={Tag}
|
||||
@@ -92,13 +91,16 @@ export function ExpenseFacts({ card }: SlotProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The split under the row, and the expense's note under it: too long for the faint line beside the icons. */
|
||||
/** Under the row: the place it belongs to beside the split, and the expense's note under them. */
|
||||
export function ExpenseBody({ card }: SlotProps) {
|
||||
const row = card.rows[0];
|
||||
if (!row) return null;
|
||||
return (
|
||||
<span className="flex flex-col items-start gap-1">
|
||||
<SplitLine row={row} />
|
||||
<span className="flex flex-col items-start gap-1.5">
|
||||
<span className="flex flex-wrap items-center gap-1.5">
|
||||
{row.place && <PlaceFact place={row.place} />}
|
||||
<SplitLine row={row} />
|
||||
</span>
|
||||
{row.note && <NotePill note={row.note} />}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -121,8 +121,7 @@ describe('an expense in a change row', () => {
|
||||
expect(screen.getByText(money(84))).toHaveClass('line-through');
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-012: the facts name the category, day and place, and what an update changes', () => {
|
||||
const onOpen = vi.fn();
|
||||
it('FE-AI-CARD-BUD-012: the facts name the category and day, and what an update changes', () => {
|
||||
const update = expense({
|
||||
category: 'transport',
|
||||
date: '2026-06-05',
|
||||
@@ -132,7 +131,7 @@ describe('an expense in a change row', () => {
|
||||
split: 'equal',
|
||||
before: { name: null, amount: null, category: 'food', date: '2026-06-03', split: 'custom' },
|
||||
});
|
||||
inView(<ExpenseFacts card={expenses([update])} item={changeItem('a', null)} />, { onOpen });
|
||||
inView(<ExpenseFacts card={expenses([update])} item={changeItem('a', null)} />);
|
||||
expect(screen.getByText('Food & drink')).toHaveClass('line-through');
|
||||
expect(screen.getByText('Transport')).toBeInTheDocument();
|
||||
expect(screen.getByText('Split unevenly')).toHaveClass('line-through');
|
||||
@@ -140,6 +139,14 @@ describe('an expense in a change row', () => {
|
||||
expect(screen.getByText('1 receipt')).toBeInTheDocument();
|
||||
// The note is too long for the faint line beside the row's buttons: it sits under the row.
|
||||
expect(screen.queryByText('Window table')).toBeNull();
|
||||
// The place sits under the row, beside the split.
|
||||
expect(screen.queryByText('Lokanta')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-AI-CARD-BUD-012c: the place sits under the row beside the split, and opens on the map', () => {
|
||||
const onOpen = vi.fn();
|
||||
const row = expense({ place: { id: 12, name: 'Lokanta' } });
|
||||
inView(<ExpenseBody card={expenses([row])} item={changeItem('a', null)} />, { onOpen });
|
||||
fireEvent.click(screen.getByRole('button', { name: /Lokanta/ }));
|
||||
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 12, dayId: null });
|
||||
});
|
||||
|
||||
@@ -126,6 +126,8 @@ export interface CardView<K extends TrekCardKind> {
|
||||
Facts?: ComponentType<CardSlotProps<K>>;
|
||||
Body?: ComponentType<CardSlotProps<K>>;
|
||||
ownsSubject?: boolean;
|
||||
/** The body runs the whole width of the change card instead of under the row's text (a tour's drawing). */
|
||||
wideBody?: boolean;
|
||||
Group?: ComponentType<CardRunProps & { place: 'head' | 'foot' }>;
|
||||
Run?: ComponentType<CardRunProps>;
|
||||
runnable?(card: TrekCardOf<K>): boolean;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { AssistantMessage, JourneyEntryLook } from '@trek/shared';
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { ArrowUpRight, BookOpen } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { DAY_COLORS } from '../../../Journey/dayColors';
|
||||
import { cardTitle } from '../../../Journey/journeyCard';
|
||||
import JourneyEntryCover from '../../../Journey/JourneyEntryCover';
|
||||
import { LinkedJournalIcon } from '../../../Trips/LinkedJournalIcon';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import CardStrip from '../parts/CardStrip';
|
||||
import { type JournalEntries, coverEntryOf, mergeJournalCards, titleSourceOf } from './journeyCardModel';
|
||||
import { EntryMarks, EntryStory, EntryThumb, EntryWhen } from './JourneyEntryParts';
|
||||
@@ -33,9 +34,6 @@ const TITLE_BUTTON =
|
||||
'block w-full rounded-md text-start outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent';
|
||||
/** The phone's taller target, the title still at the top of the row. */
|
||||
const TITLE_BUTTON_TALL = `${TITLE_BUTTON} min-h-10`;
|
||||
const FOOTER = 'mt-1 flex items-center gap-2 border-t border-edge-faint pt-2 text-caption';
|
||||
const FOOTER_TITLE = 'min-w-0 flex-1 truncate text-content-muted';
|
||||
const FAINT = 'text-content-faint';
|
||||
const OPEN = 'inline-flex flex-none items-center gap-1 font-medium text-accent hover:underline';
|
||||
const OPEN_TALL = 'inline-flex h-10 flex-none items-center gap-1 font-medium text-accent hover:underline';
|
||||
/** The size and corners of the journal's own tile, so the count stands in the row like one more entry. */
|
||||
@@ -47,29 +45,40 @@ const TILE = 'flex-none snap-start';
|
||||
|
||||
type Open = ((entryId: number | null) => void) | null;
|
||||
|
||||
/** The journal's name and the way into it, under the card. */
|
||||
function JournalFooter({ journal, open }: { journal: JournalEntries; open: Open }) {
|
||||
/**
|
||||
* The way into the journal under its entries: in the foot bar of the card, or
|
||||
* under the strip of tiles with how many entries it holds, which the strip
|
||||
* has no band to say.
|
||||
*/
|
||||
function JournalFooter({ open, count }: { open: Open; count?: number }) {
|
||||
const { t } = useTranslation();
|
||||
const { variant } = useAssistantView();
|
||||
const title = journal.journalTitle ?? t('assistant.card.journey.label');
|
||||
const count = journal.entries.length + journal.more;
|
||||
const button = open && (
|
||||
<button type="button" onClick={() => open(null)} className={variant === 'fullscreen' ? OPEN_TALL : OPEN}>
|
||||
{t('assistant.card.journey.open')}
|
||||
<ArrowUpRight size={12} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
);
|
||||
if (count === undefined) return button ? <CardFoot>{button}</CardFoot> : null;
|
||||
return (
|
||||
<div className={FOOTER}>
|
||||
<LinkedJournalIcon size={12} className={FAINT} />
|
||||
<span title={title} className={FOOTER_TITLE}>
|
||||
<bdi>{title}</bdi>
|
||||
{count > 2 && <span className="ms-1.5 tabular-nums">{t('assistant.card.journey.entries', { count })}</span>}
|
||||
<div className="mt-1 flex items-center gap-2 text-caption">
|
||||
<span className="min-w-0 flex-1 truncate tabular-nums text-content-muted">
|
||||
{t('assistant.card.journey.entries', { count })}
|
||||
</span>
|
||||
{open && (
|
||||
<button type="button" onClick={() => open(null)} className={variant === 'fullscreen' ? OPEN_TALL : OPEN}>
|
||||
{t('assistant.card.journey.open')}
|
||||
<ArrowUpRight size={12} aria-hidden="true" className="rtl:-scale-x-100" />
|
||||
</button>
|
||||
)}
|
||||
{button}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The band of the entries: the journal's name, and how many entries it holds once there are more than two. */
|
||||
function JournalHead({ journal }: { journal: JournalEntries }) {
|
||||
const { t } = useTranslation();
|
||||
const title = journal.journalTitle ?? t('assistant.card.journey.label');
|
||||
const count = journal.entries.length + journal.more;
|
||||
const meta = count > 2 ? t('assistant.card.journey.entries', { count }) : undefined;
|
||||
return <CardHeader icon={BookOpen} tone="info" title={title} meta={meta} />;
|
||||
}
|
||||
|
||||
/** One entry as a row: its photo, its title (the way to it), its facts and the start of its text. */
|
||||
function EntryRow({
|
||||
look,
|
||||
@@ -116,10 +125,11 @@ function JournalRows({ journal, fallbackRef }: { journal: JournalEntries; fallba
|
||||
const hover = useEntryHover(journal.journeyId, fallbackRef);
|
||||
return (
|
||||
<CardFrame label={t('assistant.card.journey.label')}>
|
||||
<JournalHead journal={journal} />
|
||||
{journal.entries.map((look, index) => (
|
||||
<EntryRow key={look.entryId ?? `new-${index}`} look={look} divided={index > 0} open={open} hover={hover} />
|
||||
))}
|
||||
<JournalFooter journal={journal} open={open} />
|
||||
<JournalFooter open={open} />
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
@@ -152,7 +162,7 @@ function JournalStrip({ journal, fallbackRef }: { journal: JournalEntries; fallb
|
||||
</li>
|
||||
)}
|
||||
</CardStrip>
|
||||
<JournalFooter journal={journal} open={open} />
|
||||
<JournalFooter open={open} count={journal.entries.length + journal.more} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import type { PackingListCard as PackingCard, PackingCardGroup } from '@trek/shared';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Backpack, Check } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { PackedBar, PackTile } from '../../../Tours/packing/packingLinkParts';
|
||||
import { PackedBar } from '../../../Tours/packing/packingLinkParts';
|
||||
import AssistantChangeFields from '../../AssistantChangeFields';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import type { CardEntry, EntityTarget } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import { isRunnable, mergePackingCards, packingColorOf, rowCount } from './checklistModel';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { isRunnable, mergePackingCards, rowCount } from './checklistModel';
|
||||
import { ChecklistFoot, ChecklistRun, type RunEntry, type RunProps } from './checklistParts';
|
||||
import { PackingGroup, PackingRowAside, PackingRowFacts } from './packingParts';
|
||||
|
||||
@@ -62,7 +64,7 @@ function useTitle(card: PackingCard): string {
|
||||
: t('assistant.card.packing.title');
|
||||
}
|
||||
|
||||
/** The head of an answer: the tile with the count, the title, how much is packed, and "All packed" once it is. */
|
||||
/** The head band of an answer: the backpack, the title, how much is packed with its bar, and "All packed" once it is. */
|
||||
function PackingHead({ card }: { card: PackingCard }) {
|
||||
const { t } = useTranslation();
|
||||
const title = useTitle(card);
|
||||
@@ -73,50 +75,46 @@ function PackingHead({ card }: { card: PackingCard }) {
|
||||
if (source.type === 'tour') caption = t('assistant.card.packing.forTour');
|
||||
else if (source.type === 'template') caption = t('assistant.card.packing.templateAdds', { count: source.itemCount });
|
||||
else if (card.progress && total > 0) caption = t('tours.packing.packed', { packed, count: total });
|
||||
const first = card.groups[0]?.key;
|
||||
const color =
|
||||
source.type === 'tour' && source.category !== null && first?.by === 'category'
|
||||
? packingColorOf(first.slot, source.category)
|
||||
: null;
|
||||
const done = card.progress !== null && total > 0 && packed === total;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<PackTile count={source.type === 'template' ? source.itemCount : total} color={color} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p title={title} className="m-0 line-clamp-2 text-body font-semibold leading-tight text-content">
|
||||
{title}
|
||||
</p>
|
||||
{caption && (
|
||||
<p title={caption} className="m-0 truncate text-caption text-content-faint">
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{card.progress && total > 0 && packed === total && (
|
||||
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
|
||||
{t('tours.packing.allPacked')}
|
||||
</SoftPill>
|
||||
<CardHeader
|
||||
icon={Backpack}
|
||||
tone={done ? 'success' : 'info'}
|
||||
title={title}
|
||||
count={source.type === 'template' ? source.itemCount : total}
|
||||
meta={caption ?? undefined}
|
||||
actions={
|
||||
done ? (
|
||||
<SoftPill tone="success" icon={<Check size={11} strokeWidth={2.4} className="flex-none" />}>
|
||||
{t('tours.packing.allPacked')}
|
||||
</SoftPill>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{card.progress && card.progress.total > 0 && (
|
||||
<div className="mt-2.5">
|
||||
<PackedBar packed={card.progress.packed} total={card.progress.total} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
|
||||
/** One answer card. */
|
||||
function PackingAnswer({ card }: { card: PackingCard }) {
|
||||
const title = useTitle(card);
|
||||
const progress = card.progress;
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<div className="flex min-w-0 flex-col gap-2.5">
|
||||
<PackingHead card={card} />
|
||||
{progress && progress.total > 0 && <PackedBar packed={progress.packed} total={progress.total} />}
|
||||
<PackingGroups card={card} inChange={false} />
|
||||
<PackingHead card={card} />
|
||||
<PackingGroups card={card} inChange={false} />
|
||||
<CardFoot>
|
||||
<ChecklistFoot
|
||||
more={card.moreGroups}
|
||||
moreKey={MORE_GROUPS[card.groupBy].labelKey}
|
||||
target={PACKING_TARGET}
|
||||
openKey="tours.packing.open"
|
||||
/>
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@ import { type PillTone, SoftPill, WhiteBadge } from '../../../Planner/planParts'
|
||||
import { localToday } from '../../../Planner/today';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { PRIO_CONFIG } from '../../../Todo/todoListModel';
|
||||
import { PackTile } from '../../../Tours/packing/packingLinkParts';
|
||||
import AssistantChangeFields from '../../AssistantChangeFields';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import type { CardEntry, EntityTarget } from '../cardTypes';
|
||||
import { ChangeMark, FactChange, NoLonger } from '../parts/CardFacts';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { PersonFace } from '../parts/CardPeople';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { isRunnable, mergeTodoCards, todoColorOf } from './checklistModel';
|
||||
import {
|
||||
@@ -204,27 +205,30 @@ function TodoAnswer({ card }: { card: TodoCard }) {
|
||||
const title = t('assistant.card.todo.title');
|
||||
return (
|
||||
<CardFrame label={title}>
|
||||
<div className="flex min-w-0 flex-col gap-2.5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
||||
<PackTile count={progress?.open ?? 0} color={null} icon={ListTodo} />
|
||||
<p className="m-0 text-body font-semibold leading-tight text-content">{title}</p>
|
||||
{progress && <SoftPill>{t('assistant.card.todo.openCount', { count: progress.open })}</SoftPill>}
|
||||
{progress && progress.overdue > 0 && (
|
||||
<CardHeader
|
||||
icon={ListTodo}
|
||||
tone={progress && progress.overdue > 0 ? 'danger' : 'info'}
|
||||
title={title}
|
||||
meta={progress ? t('assistant.card.todo.openCount', { count: progress.open }) : undefined}
|
||||
actions={
|
||||
progress && progress.overdue > 0 ? (
|
||||
<SoftPill tone="danger">{t('assistant.card.todo.overdueCount', { count: progress.overdue })}</SoftPill>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{card.groups.map((group) => (
|
||||
<TodoGroup key={group.category ?? ''} group={group} inChange={false} />
|
||||
))}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{card.groups.map((group) => (
|
||||
<TodoGroup key={group.category ?? ''} group={group} inChange={false} />
|
||||
))}
|
||||
</div>
|
||||
<CardFoot>
|
||||
<ChecklistFoot
|
||||
more={card.moreGroups}
|
||||
moreKey="assistant.card.todo.moreLists"
|
||||
target={TODO_TARGET}
|
||||
openKey="assistant.card.todo.open"
|
||||
/>
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ChecklistRow({
|
||||
>
|
||||
{name}
|
||||
</bdi>
|
||||
{aside && <span className="flex flex-none flex-wrap items-center justify-end gap-1">{aside}</span>}
|
||||
{aside && <span className="flex flex-none flex-wrap items-center justify-end gap-x-2 gap-y-1">{aside}</span>}
|
||||
</div>
|
||||
{under}
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { formatWeight } from '../../../Packing/packingListModel';
|
||||
import { bagFillPct } from '../../../Packing/packingListPanel.helpers';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { CountBadge } from '../../../Tours/packing/packingLinkParts';
|
||||
import { ChangeMark, FactChange, NoLonger } from '../parts/CardFacts';
|
||||
import { PeopleStack, PersonFace } from '../parts/CardPeople';
|
||||
import { packingColorOf } from './checklistModel';
|
||||
@@ -56,15 +55,31 @@ function BagSwatch({ bag, size = 'row' }: { bag: PackingCardBag | undefined; siz
|
||||
* What a row says at its end: how many, how many packed, its weight, its tier
|
||||
* and its bag. A bag the card does not carry draws nothing rather than "Unassigned".
|
||||
*/
|
||||
/** A row's quantity and weight: quiet figures at its end, no frame each. */
|
||||
const QUIET = 'text-caption tabular-nums text-content-muted';
|
||||
|
||||
/** How many rows a group holds, quiet beside its name. */
|
||||
function GroupCount({ count }: { count: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span
|
||||
aria-label={t('tours.packing.items', { count })}
|
||||
className="flex-none text-caption tabular-nums text-content-faint"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PackingRowAside({ row, bag }: { row: PackingCardItem; bag: PackingCardBag | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
const personal = t('assistant.card.packing.tierPersonal');
|
||||
const partial = !row.checked && row.packed > 0 && row.packed < row.quantity;
|
||||
return (
|
||||
<>
|
||||
{row.quantity > 1 && <WhiteBadge>×{row.quantity}</WhiteBadge>}
|
||||
{partial && <WhiteBadge>{`${row.packed}/${row.quantity}`}</WhiteBadge>}
|
||||
{row.weightGrams !== null && <WhiteBadge>{formatWeight(row.weightGrams)}</WhiteBadge>}
|
||||
{row.quantity > 1 && <span className={QUIET}>×{row.quantity}</span>}
|
||||
{partial && <span className={QUIET}>{`${row.packed}/${row.quantity}`}</span>}
|
||||
{row.weightGrams !== null && <span className={QUIET}>{formatWeight(row.weightGrams)}</span>}
|
||||
{row.tier === 'personal' && (
|
||||
<Tooltip label={personal}>
|
||||
<span role="img" aria-label={personal} className="flex text-content-faint">
|
||||
@@ -216,7 +231,7 @@ function BagHead({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!bag) {
|
||||
const badge = inChange ? undefined : <CountBadge count={group.count} />;
|
||||
const badge = inChange ? undefined : <GroupCount count={group.count} />;
|
||||
const lead = <BagSwatch bag={undefined} size="head" />;
|
||||
return <ChecklistHead color="" name={t('packing.noBag')} lead={lead} badge={badge} />;
|
||||
}
|
||||
@@ -283,7 +298,7 @@ export function PackingGroup({
|
||||
<ChecklistHead
|
||||
color={packingColorOf(key.slot, name)}
|
||||
name={name}
|
||||
badge={inChange ? undefined : <CountBadge count={group.count} />}
|
||||
badge={inChange ? undefined : <GroupCount count={group.count} />}
|
||||
people={group.assignees}
|
||||
removed={group.assigneesRemoved}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* The frame every answer card sits in, the same as the change card's: a
|
||||
* rounded card on the chat. A waiting change shows its frame dashed, a
|
||||
* rounded card on the chat whose head band (CardHeader) runs to its edges. A waiting change shows its frame dashed, a
|
||||
* removed one faded, a settled one (discarded, expired, undone) fainter
|
||||
* still. The chat card (a message in the trip chat) sits on the quieter
|
||||
* secondary surface.
|
||||
@@ -32,7 +32,7 @@ export default function CardFrame({
|
||||
return (
|
||||
<section
|
||||
aria-label={label}
|
||||
className={`trek-assistant-enter rounded-xl border px-3 pb-2 pt-2.5 shadow-card ${STATE[state]} ${
|
||||
className={`trek-assistant-enter overflow-hidden rounded-[16px] border px-3.5 pb-3 pt-3 shadow-card ${STATE[state]} ${
|
||||
surface === 'secondary' ? 'bg-surface-secondary' : 'bg-surface-card'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -1,43 +1,94 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { WhiteBadge } from '../../../Planner/planParts';
|
||||
import { fs, NEUTRAL_TINT } from '../../../shared/DialogShell';
|
||||
import type { Tone } from './assistantTones';
|
||||
import KindTile from './KindTile';
|
||||
|
||||
/**
|
||||
* The head of an answer card: its tile, what it is about (wrapping to two
|
||||
* lines at most, the whole title in its tooltip), how many it holds, one
|
||||
* quiet line of context under it, and its actions at the end.
|
||||
* The head band of an answer card, as the trip's dialogs and booking cards
|
||||
* wear it: a tinted band across the card's top, a raised white tile with the
|
||||
* icon of what the card is about in its tone, an eyebrow naming the kind,
|
||||
* the title, one quiet line under it, a count, and the card's actions at the
|
||||
* end. It reaches the card's edges, so a card that opens with it needs no
|
||||
* other head.
|
||||
*/
|
||||
|
||||
/** The icon's colour on the white tile, per tone. */
|
||||
const GLYPH: Record<Tone, string> = {
|
||||
success: 'text-success',
|
||||
info: 'text-info',
|
||||
warning: 'text-warning',
|
||||
danger: 'text-danger',
|
||||
};
|
||||
|
||||
export default function CardHeader({
|
||||
icon,
|
||||
icon: Icon,
|
||||
tone,
|
||||
title,
|
||||
eyebrow,
|
||||
count,
|
||||
meta,
|
||||
actions,
|
||||
tile,
|
||||
tint = NEUTRAL_TINT,
|
||||
children,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
tone: Tone;
|
||||
title: string;
|
||||
/** The kind above the title ("PACKING LIST"); none when the title says it. */
|
||||
eyebrow?: string;
|
||||
count?: number;
|
||||
meta?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
/** In place of the icon tile: a drawing of what the card shows (a route's sketch). */
|
||||
tile?: ReactNode;
|
||||
tint?: string;
|
||||
/** Under the title row, on the band: a progress bar, a row of pills. */
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<KindTile icon={icon} tone={tone} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<bdi title={title} className="line-clamp-2 text-body font-semibold leading-tight text-content">
|
||||
{title}
|
||||
</bdi>
|
||||
{count !== undefined && <WhiteBadge>{count}</WhiteBadge>}
|
||||
<div className="-mx-3.5 -mt-3 mb-3 px-3.5 pb-3 pt-3" style={{ background: tint }}>
|
||||
<div className={`flex gap-3 ${meta ? 'items-start' : 'items-center'}`}>
|
||||
{tile ?? (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`grid h-10 w-10 flex-none place-items-center rounded-[12px] bg-surface-card shadow-sm ${GLYPH[tone]}`}
|
||||
>
|
||||
<Icon size={18} strokeWidth={2} />
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{eyebrow && (
|
||||
<div
|
||||
className="truncate font-geist font-bold uppercase tracking-[.08em] text-content-muted"
|
||||
style={fs(9.5)}
|
||||
>
|
||||
{eyebrow}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<bdi
|
||||
title={title}
|
||||
className="line-clamp-2 min-w-0 font-bold leading-tight tracking-[-0.01em] text-content"
|
||||
style={fs(15, 'body')}
|
||||
>
|
||||
{title}
|
||||
</bdi>
|
||||
{count !== undefined && (
|
||||
<span
|
||||
className="inline-flex h-5 flex-none items-center rounded-full bg-surface-card px-2 font-semibold tabular-nums text-content-secondary shadow-sm"
|
||||
style={fs(11)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{meta && <div className="mt-0.5 font-geist text-caption text-content-muted">{meta}</div>}
|
||||
</div>
|
||||
{meta && <div className="mt-0.5 text-caption text-content-muted">{meta}</div>}
|
||||
{actions && <div className="flex flex-none items-center gap-1">{actions}</div>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-none items-center gap-1">{actions}</div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +44,11 @@ export function CardRow({
|
||||
<span className="block text-body font-semibold leading-tight text-content">{title}</span>
|
||||
{facts}
|
||||
</span>
|
||||
{aside && <span className="flex-none text-caption tabular-nums text-content-secondary">{aside}</span>}
|
||||
{aside && (
|
||||
<span className="flex flex-none items-center gap-2 text-caption tabular-nums text-content-secondary">
|
||||
{aside}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
const hover = onHover
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Eyebrow, Field } from '../../../Planner/bookings/bookingParts';
|
||||
|
||||
/**
|
||||
* The body of an answer card in the trip dialogs' look: sections under an
|
||||
* eyebrow of their own, figures as framed fields with their label over them
|
||||
* (the field every booking card is made of), and a foot bar for the card's
|
||||
* ways onward, set off by a line across the card.
|
||||
*/
|
||||
|
||||
/** One part of a card under its eyebrow, with room for something on the right of the label. */
|
||||
export function CardSection({ label, aside, children }: { label: string; aside?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<section className="mt-3 first:mt-0">
|
||||
<div className="mb-1.5 flex min-h-4 items-center gap-2">
|
||||
<Eyebrow>{label}</Eyebrow>
|
||||
{aside && <span className="ms-auto flex items-center gap-1">{aside}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A card's figures, side by side in framed fields: as many columns as figures, at most four. */
|
||||
export function CardFields({ fields }: { fields: readonly { key: string; label: string; value: ReactNode }[] }) {
|
||||
if (fields.length === 0) return null;
|
||||
const columns = ['grid-cols-1', 'grid-cols-2', 'grid-cols-3', 'grid-cols-4'][Math.min(fields.length, 4) - 1];
|
||||
return (
|
||||
<div className={`grid gap-1.5 ${columns}`}>
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="min-w-0" data-field={field.key}>
|
||||
<Field label={field.label} tabular>
|
||||
{field.value}
|
||||
</Field>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The foot of a card: its actions at the end, under a line that runs to the card's edges. */
|
||||
export function CardFoot({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="-mx-3.5 -mb-3 mt-3 flex flex-wrap items-center justify-end gap-1.5 border-t border-edge-faint bg-surface-secondary px-3.5 py-2.5">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Clock, MoveHorizontal, TrendingUp } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { DistanceUnit } from '../../../../types';
|
||||
import { formatDistance, formatElevation } from '../../../../utils/units';
|
||||
import type { LegVariant } from '../../../Tours/planner/legVariants';
|
||||
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||
|
||||
/**
|
||||
* A way's length, climb and time as one quiet line of glyphs and values,
|
||||
* without a frame around each: what a choice of ways and a change row show
|
||||
* where framed fields would crowd the row.
|
||||
*/
|
||||
|
||||
/** One figure of a way: its glyph and its value, quiet, in a line of them. */
|
||||
function Figure({ name, icon, children }: { name: string; icon: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 whitespace-nowrap" data-figure={name}>
|
||||
{icon}
|
||||
<span className="tabular-nums">{children}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const GLYPH = { size: 11, strokeWidth: 2.2, 'aria-hidden': true, className: 'flex-none text-content-faint' } as const;
|
||||
|
||||
/** A way's length, climb and time in one quiet line. */
|
||||
export default function WayFigures({ way, unit }: { way: LegVariant; unit: DistanceUnit }) {
|
||||
return (
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-caption text-content-secondary">
|
||||
<Figure name="distance" icon={<MoveHorizontal {...GLYPH} />}>
|
||||
{formatDistance(way.distanceM / 1000, unit)}
|
||||
</Figure>
|
||||
{way.gainM !== null && (
|
||||
<Figure name="ascent" icon={<TrendingUp {...GLYPH} />}>
|
||||
{formatElevation(way.gainM, unit)}
|
||||
</Figure>
|
||||
)}
|
||||
{way.minutes !== null && (
|
||||
<Figure name="time" icon={<Clock {...GLYPH} />}>
|
||||
{formatTourDuration(way.minutes)}
|
||||
</Figure>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { tourTarget } from './tourLook';
|
||||
import { useTakeProposal } from './useTourRouteCard';
|
||||
|
||||
@@ -58,7 +59,7 @@ export default function TourRouteActions({
|
||||
if (take.state === 'failed') caption = t('assistant.card.tour.askAgain');
|
||||
else if (take.state !== 'taken' && take.reasonKey) caption = t(take.reasonKey);
|
||||
return (
|
||||
<div className="mt-2.5 flex flex-col items-end gap-1">
|
||||
<CardFoot>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
@@ -92,10 +93,10 @@ export default function TourRouteActions({
|
||||
)}
|
||||
</div>
|
||||
{take.visible && caption && (
|
||||
<p className="m-0 text-end text-caption text-content-muted" role="status">
|
||||
<p className="m-0 basis-full text-end text-caption text-content-muted" role="status">
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardFoot>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,11 +115,11 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('TourRouteCard', () => {
|
||||
it('FE-AI-TOUR-021: states the name, the figures as badges, the day and the hardest grade, uncertain when little is graded', () => {
|
||||
it('FE-AI-TOUR-021: states the name, the figures as framed fields, the day and the hardest grade, uncertain when little is graded', () => {
|
||||
const { container } = renderCard(HIKE);
|
||||
expect(screen.getByText('Hochfelln from Bergen')).toBeInTheDocument();
|
||||
for (const figure of ['time', 'distance', 'ascent', 'descent']) {
|
||||
expect(container.querySelector(`[data-figure="${figure}"]`)).not.toBeNull();
|
||||
expect(container.querySelector(`[data-field="${figure}"]`)).not.toBeNull();
|
||||
}
|
||||
expect(screen.getByText('8.2 km')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('640 m')).toHaveLength(2);
|
||||
@@ -159,7 +159,7 @@ describe('TourRouteCard', () => {
|
||||
|
||||
it('FE-AI-TOUR-024: a stop chip moves the spot in focus to the stop while pointed at', () => {
|
||||
renderCard(HIKE);
|
||||
const hut = screen.getByRole('button', { name: 'Strausshütte, 09:40' });
|
||||
const hut = screen.getByRole('button', { name: /^Strausshütte, 09:40/ });
|
||||
fireEvent.mouseEnter(hut);
|
||||
expect(useAssistantStore.getState().routeFocus?.focus.distanceMeters).toBeCloseTo(2400, 0);
|
||||
fireEvent.mouseLeave(hut);
|
||||
@@ -171,11 +171,11 @@ describe('TourRouteCard', () => {
|
||||
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);
|
||||
// One row per stop; the walks between them are hidden, read with the stop they lead to.
|
||||
expect(list.querySelectorAll('li:not([aria-hidden="true"])')).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(screen.getAllByRole('button', { name: /from the last stop$/ })).toHaveLength(3);
|
||||
const legs = [...list.querySelectorAll('li[aria-hidden="true"]')].map((leg) => leg.textContent).filter(Boolean);
|
||||
expect(legs).toHaveLength(3);
|
||||
});
|
||||
|
||||
@@ -234,18 +234,21 @@ describe('TourRouteCard', () => {
|
||||
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({
|
||||
// The server sends the answer back with the take joined to it, now applied with the new tour's place.
|
||||
const applied = proposed();
|
||||
applied.parts.push(...heldTake(true).parts, {
|
||||
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] } } });
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [applied] } } });
|
||||
});
|
||||
seed([proposed()]);
|
||||
useAssistantStore.setState({ decide });
|
||||
takeProposal.mockImplementationOnce(async () => {
|
||||
const chat = chatOf(useAssistantStore.getState(), TRIP);
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [proposed(), heldTake(undefined)] } } });
|
||||
const joined = proposed();
|
||||
joined.parts.push(...heldTake(undefined).parts);
|
||||
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, messages: [joined] } } });
|
||||
return 'held';
|
||||
});
|
||||
renderCard(HIKE, { onOpen });
|
||||
@@ -374,7 +377,7 @@ describe('TourRouteCard', () => {
|
||||
it('FE-AI-TOUR-042: the day shows the latest start when none is set, and the walk in the dark in red', () => {
|
||||
const schedule = HIKE.schedule as NonNullable<Route['schedule']>;
|
||||
renderCard({ ...HIKE, schedule: { ...schedule, start: null, arrive: null, latestStart: '10:30' } });
|
||||
expect(screen.getByText('Start by 10:30 to finish before sunset')).toBeInTheDocument();
|
||||
expect(screen.getByText('Start by 10:30')).toBeInTheDocument();
|
||||
|
||||
const { container } = renderCard({
|
||||
...HIKE,
|
||||
@@ -382,6 +385,6 @@ describe('TourRouteCard', () => {
|
||||
schedule: { ...schedule, start: '06:00', arrive: '19:00', latestStart: '10:30' },
|
||||
});
|
||||
expect(container.querySelectorAll('[data-testid="tour-day"] .bg-danger')).toHaveLength(2);
|
||||
expect(screen.getAllByText('Start by 10:30 to finish before sunset')).toHaveLength(1);
|
||||
expect(screen.getAllByText('Start by 10:30')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useMemo } from 'react';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import type { DistanceIndexedProfileSample } from '../../../../utils/routeGeometry';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import { CardSection } from '../parts/CardSection';
|
||||
import TourRouteActions from './TourRouteActions';
|
||||
import TourRouteDay from './TourRouteDay';
|
||||
import TourRouteHead from './TourRouteHead';
|
||||
import TourRouteHead, { TourRouteFields } from './TourRouteHead';
|
||||
import { profileSamples } from './tourRouteModel';
|
||||
import { TourRouteProblem, TourRouteWarnings } from './TourRouteNotes';
|
||||
import TourRouteProfile from './TourRouteProfile';
|
||||
@@ -40,19 +40,30 @@ export function TourRouteBody({
|
||||
compare?: ReadonlyArray<{ key: string; samples: readonly DistanceIndexedProfileSample[]; color: string }>;
|
||||
letter?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const samples = useMemo(() => profileSamples(card), [card]);
|
||||
return (
|
||||
<>
|
||||
<TourRouteProfile
|
||||
toolCallId={toolCallId}
|
||||
card={card}
|
||||
samples={samples}
|
||||
color={color}
|
||||
focus={route.focus}
|
||||
onFocus={route.setFocus}
|
||||
compare={compare}
|
||||
/>
|
||||
<TourRouteStops card={card} samples={samples} onFocus={route.setFocus} />
|
||||
<TourRouteFields card={card} />
|
||||
{/* A way drawn straight has neither heights nor the line to say so under: no section at all. */}
|
||||
{(samples.length >= 2 || card.routed) && (
|
||||
<CardSection label={t('tours.planner.inspector.elevation')}>
|
||||
<TourRouteProfile
|
||||
toolCallId={toolCallId}
|
||||
card={card}
|
||||
samples={samples}
|
||||
color={color}
|
||||
focus={route.focus}
|
||||
onFocus={route.setFocus}
|
||||
compare={compare}
|
||||
/>
|
||||
</CardSection>
|
||||
)}
|
||||
{card.stops.length > 0 && (
|
||||
<CardSection label={t('assistant.card.tour.stops')}>
|
||||
<TourRouteStops card={card} samples={samples} onFocus={route.setFocus} />
|
||||
</CardSection>
|
||||
)}
|
||||
<TourRouteDay card={card} />
|
||||
<TourRouteWarnings card={card} />
|
||||
<TourRouteProblem card={card} />
|
||||
@@ -63,12 +74,11 @@ export function TourRouteBody({
|
||||
|
||||
export default function TourRouteCard({ toolCallId, card }: { toolCallId: string; card: Route }) {
|
||||
const { t } = useTranslation();
|
||||
const { density } = useAssistantView();
|
||||
const route = useTourRouteCard(toolCallId, card);
|
||||
return (
|
||||
<div {...route.handlers} className={`rounded-xl ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||
<CardFrame label={routeName(card, t)}>
|
||||
<TourRouteHead card={card} sketchHeight={density === 'roomy' ? 64 : 56} onShow={route.showOnMap} />
|
||||
<TourRouteHead card={card} onShow={route.showOnMap} />
|
||||
<TourRouteBody toolCallId={toolCallId} card={card} route={route} color={variantPaintCss('current')} />
|
||||
</CardFrame>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createElement } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { weatherIconFor } from '../../../Weather/weatherIcons';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { type BarSpan, dayBarOf, forecastHours, RAIN_SHOWN_PCT } from './tourRouteDayModel';
|
||||
@@ -93,9 +94,13 @@ export default function TourRouteDay({ card }: { card: TourRouteCard }) {
|
||||
)}
|
||||
{!schedule.start && schedule.latestStart && (
|
||||
<div className="mt-1 flex">
|
||||
<SoftPill icon={<Clock size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.tour.latestStart', { time: format.clock(schedule.latestStart) })}
|
||||
</SoftPill>
|
||||
<Tooltip label={t('assistant.card.tour.latestStart', { time: format.clock(schedule.latestStart) })}>
|
||||
<span className="inline-flex">
|
||||
<SoftPill icon={<Clock size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t('assistant.card.tour.latestStartShort', { time: format.clock(schedule.latestStart) })}
|
||||
</SoftPill>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
{schedule.weather === 'climate' && (
|
||||
|
||||
@@ -4,12 +4,14 @@ import { createElement } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { fs, PILL } from '../../../shared/DialogShell';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||
import { tourDayRowIcon } from '../../../Tours/tourDayRowIcon';
|
||||
import { CardFacts } from '../parts/CardFacts';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardFields } from '../parts/CardSection';
|
||||
import RouteSketch from '../parts/RouteSketch';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
import { factKeysOf, factValue, percentText, type FactKey } from './tourRouteModel';
|
||||
@@ -89,7 +91,7 @@ export function TourGrade({ card }: { card: TourRouteCard }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The day a route is planned on, as words: "Day 2, Tue 14 Oct", or the date alone. */
|
||||
/** The day a route is planned on, as a white pill on the band: "Day 2, Tue 14 Oct", or the date alone. */
|
||||
function DayPill({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
@@ -98,7 +100,12 @@ function DayPill({ card }: { card: TourRouteCard }) {
|
||||
const date = format.date(schedule.date);
|
||||
const text =
|
||||
schedule.dayNumber !== null ? `${t('assistant.card.tour.day', { day: schedule.dayNumber })}, ${date}` : date;
|
||||
return <SoftPill icon={<CalendarDays size={10} strokeWidth={2.2} aria-hidden="true" />}>{text}</SoftPill>;
|
||||
return (
|
||||
<span className={PILL} style={fs(11)}>
|
||||
<CalendarDays size={11} strokeWidth={2.2} aria-hidden="true" className="flex-none text-content-faint" />
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** The route drawn small: a button that shows it on the map. */
|
||||
@@ -127,35 +134,51 @@ export function TourRouteSketch({
|
||||
);
|
||||
}
|
||||
|
||||
export default function TourRouteHead({
|
||||
card,
|
||||
sketchHeight,
|
||||
onShow,
|
||||
}: {
|
||||
card: TourRouteCard;
|
||||
sketchHeight: number;
|
||||
onShow: () => void;
|
||||
}) {
|
||||
/** The route's figures as framed fields, each under its label: distance, climb, descent and time, as known. */
|
||||
export function TourRouteFields({ card }: { card: TourRouteCard }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const fields = factKeysOf(card).flatMap((key) => {
|
||||
const value = factValue(card, key);
|
||||
if (value === null) return [];
|
||||
// A field's label is short; how the time was reckoned is the figure badges' tooltip.
|
||||
const label = t(FACT_LOOK[key].labelKey ?? 'tours.planner.inspector.duration');
|
||||
let text: string;
|
||||
if (key === 'time') text = formatTourDuration(value);
|
||||
else text = key === 'distance' ? format.distance(value / 1000) : format.elevation(value);
|
||||
return [{ key, label, value: text }];
|
||||
});
|
||||
return <CardFields fields={fields} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* The head band of a route card: the route drawn small on a raised tile, what
|
||||
* kind of tour it is above its name, the label under it, and its grade and
|
||||
* the day it is planned on as pills on the band.
|
||||
*/
|
||||
export default function TourRouteHead({ card, onShow }: { card: TourRouteCard; onShow: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const name = routeName(card, t);
|
||||
const tile = (
|
||||
<span className="w-14 flex-none overflow-hidden rounded-[12px] bg-surface-card p-1 shadow-sm">
|
||||
<TourRouteSketch card={card} height={48} onShow={onShow} />
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-[72px] flex-none">
|
||||
<TourRouteSketch card={card} height={sketchHeight} onShow={onShow} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<bdi title={name} className="line-clamp-2 min-w-0 flex-1 text-body font-semibold leading-tight text-content">
|
||||
{name}
|
||||
</bdi>
|
||||
<TourGrade card={card} />
|
||||
</div>
|
||||
{card.label && <p className="m-0 mt-0.5 truncate text-caption text-content-muted">{card.label}</p>}
|
||||
<CardFacts>
|
||||
<TourRouteFigures card={card} />
|
||||
<CardHeader
|
||||
icon={tourDayRowIcon(card.activity)}
|
||||
tone="info"
|
||||
tile={tile}
|
||||
eyebrow={t(TOUR_TYPE_WORDS[card.activity].labelKey)}
|
||||
title={name}
|
||||
meta={card.label ?? undefined}
|
||||
>
|
||||
{((card.activity === 'hike' && card.grades) || card.schedule) && (
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
||||
{card.activity === 'hike' && card.grades && <TourGrade card={card} />}
|
||||
<DayPill card={card} />
|
||||
</CardFacts>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import { tourDayRowIcon } from '../../../Tours/tourDayRowIcon';
|
||||
import type { ChangeItem } from '../../assistantParts';
|
||||
import { verbLookOf } from '../cardLooks';
|
||||
import KindTile from '../parts/KindTile';
|
||||
import { TourGrade, TourRouteFigures, TourRouteSketch } from './TourRouteHead';
|
||||
import WayFigures from './TourFigureLine';
|
||||
import { TourGrade, TourRouteSketch } from './TourRouteHead';
|
||||
import { legVariantOf, previousVariantOf, profileBands, profileSamples } from './tourRouteModel';
|
||||
import { useTourRouteCard } from './useTourRouteCard';
|
||||
|
||||
@@ -37,29 +38,33 @@ export default function TourRouteInRow({ card, item }: { card: TourRouteCard; it
|
||||
const samples = useMemo(() => profileSamples(card), [card]);
|
||||
const previous = previousVariantOf(card);
|
||||
const notPlanned = card.problem?.why === 'not_planned';
|
||||
const grades = card.activity === 'hike' ? card.grades : null;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5" data-testid="tour-route-row">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-16 flex-none">
|
||||
<TourRouteSketch card={card} height={40} onShow={showOnMap} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
<TourRouteFigures card={card} />
|
||||
<TourGrade card={card} />
|
||||
{previous && (
|
||||
<VariantDelta
|
||||
variant={legVariantOf(card, 'current')}
|
||||
current={previous}
|
||||
unit={unit}
|
||||
t={t}
|
||||
hint={t('assistant.card.tour.deltaBefore')}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-col gap-2 rounded-[12px] border border-edge-faint bg-surface-secondary p-2.5"
|
||||
data-testid="tour-route-row"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-14 flex-none overflow-hidden rounded-[10px] bg-surface-card p-1 shadow-sm">
|
||||
<TourRouteSketch card={card} height={36} onShow={showOnMap} />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<WayFigures way={legVariantOf(card, 'current')} unit={unit} />
|
||||
</span>
|
||||
{grades && grades.highest !== null && <TourGrade card={card} />}
|
||||
</span>
|
||||
{notPlanned && (
|
||||
<p className="m-0 mt-1 text-caption text-content-faint">{t('assistant.card.tour.notPlanned')}</p>
|
||||
{previous && (
|
||||
<VariantDelta
|
||||
variant={legVariantOf(card, 'current')}
|
||||
current={previous}
|
||||
unit={unit}
|
||||
t={t}
|
||||
hint={t('assistant.card.tour.deltaBefore')}
|
||||
/>
|
||||
)}
|
||||
{notPlanned && <p className="m-0 text-caption text-content-faint">{t('assistant.card.tour.notPlanned')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{!notPlanned && samples.length >= 2 && (
|
||||
|
||||
@@ -87,7 +87,7 @@ export default function TourRouteProfile({ toolCallId, card, samples, color, foc
|
||||
const bands = useMemo(() => profileBands(card, samples), [card, samples]);
|
||||
if (samples.length < 2) {
|
||||
if (!card.routed) return null;
|
||||
return <p className="m-0 mt-2 text-caption text-content-faint">{t('assistant.card.tour.noHeights')}</p>;
|
||||
return <p className="m-0 text-caption text-content-faint">{t('assistant.card.tour.noHeights')}</p>;
|
||||
}
|
||||
const phone = variant === 'fullscreen';
|
||||
const focusGrade = focus ? gradeAt(grades, focus.distanceMeters) : null;
|
||||
@@ -112,7 +112,7 @@ export default function TourRouteProfile({ toolCallId, card, samples, color, foc
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div className="rounded-[12px] border border-edge-faint bg-surface-secondary px-2 pb-1.5 pt-2">
|
||||
<ElevationProfile
|
||||
samples={samples}
|
||||
color={color}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { TourRouteCard, TourRouteStop } from '@trek/shared';
|
||||
import { AlertTriangle, Flag, MapPin, MountainSnow, type LucideIcon } from 'lucide-react';
|
||||
import { AlertTriangle, Clock, Flag, MapPin, MountainSnow, type LucideIcon } from 'lucide-react';
|
||||
import { createElement, type ReactNode } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type RouteProfileFocus,
|
||||
} from '../../../../utils/routeGeometry';
|
||||
import { TOUR_POI_LOOKS } from '../../../Map/poiCategories';
|
||||
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
|
||||
import { SoftPill } from '../../../Planner/planParts';
|
||||
import { Tooltip } from '../../../shared/Tooltip';
|
||||
import { StageEndCorner } from '../../../Tours/stages/StageParts';
|
||||
import { useCardFormat } from '../parts/useCardFormat';
|
||||
@@ -18,12 +18,13 @@ import { FLAG_LOOK, stopName } from './tourRouteWords';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* per stop on a line down the card (the start and the end filled, the huts,
|
||||
* summits and stations on the way with their glyph, what lies a little off
|
||||
* the way quieter), its name, how far off the way or how long the stay under
|
||||
* it, and its clock at the end; the walk between two stops sits on the line
|
||||
* between their rows, so a time is never read as a stop's own. Pointing at a
|
||||
* row 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 row 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. */
|
||||
@@ -46,12 +47,31 @@ function focusOf(stop: TourRouteStop, samples: readonly DistanceIndexedProfileSa
|
||||
);
|
||||
}
|
||||
|
||||
function StopChip({
|
||||
/** A stop's mark on the line: filled at the start and the end, a ring with its glyph on the way, smaller off it. */
|
||||
function Marker({ stop }: { stop: TourRouteStop }) {
|
||||
const end = stop.role === 'start' || stop.role === 'end';
|
||||
const along = stop.role === 'along';
|
||||
const glyph = createElement(iconOf(stop), { size: along ? 9 : 10, strokeWidth: 2.2, 'aria-hidden': true });
|
||||
let look = 'h-5 w-5 bg-surface-card text-content-muted ring-1 ring-inset ring-edge';
|
||||
if (end) look = 'h-5 w-5 bg-accent text-accent-text';
|
||||
else if (along) look = 'h-4 w-4 bg-surface-secondary text-content-faint ring-1 ring-inset ring-edge-faint';
|
||||
return (
|
||||
<span className="relative z-[1] flex w-5 flex-none justify-center">
|
||||
<span className={`grid place-items-center rounded-full ${look}`}>{glyph}</span>
|
||||
{stop.stageEnd && <StageEndCorner />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StopRow({
|
||||
stop,
|
||||
leg,
|
||||
samples,
|
||||
onFocus,
|
||||
}: {
|
||||
stop: TourRouteStop;
|
||||
/** The walk from the last stop with a time to this one, read with it. */
|
||||
leg: number | null;
|
||||
samples: readonly DistanceIndexedProfileSample[];
|
||||
onFocus: (focus: RouteProfileFocus | null) => void;
|
||||
}) {
|
||||
@@ -65,44 +85,45 @@ function StopChip({
|
||||
: null;
|
||||
const stay = stop.stayMinutes ? t('assistant.card.tour.stay', { time: format.minutes(stop.stayMinutes) }) : null;
|
||||
const clock = stop.clock ? format.clock(stop.clock) : null;
|
||||
const label = [name, clock, off, stay].filter(Boolean).join(', ');
|
||||
const glyph = createElement(iconOf(stop), {
|
||||
size: 10,
|
||||
strokeWidth: 2.2,
|
||||
'aria-hidden': true,
|
||||
className: 'flex-none text-content-faint',
|
||||
});
|
||||
const body = (
|
||||
<>
|
||||
<bdi className="max-w-[10rem] truncate">{name}</bdi>
|
||||
{clock && <span className="tabular-nums text-content-muted">{clock}</span>}
|
||||
{off && <span className="text-content-faint">{off}</span>}
|
||||
</>
|
||||
);
|
||||
const walked = leg !== null ? t('assistant.card.tour.legTime', { time: format.minutes(leg) }) : null;
|
||||
const label = [name, clock, off, stay, walked].filter(Boolean).join(', ');
|
||||
const point = () => onFocus(focusOf(stop, samples));
|
||||
const under = [off, stay].filter((part): part is string => part !== null);
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Tooltip label={label}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onMouseEnter={() => onFocus(focusOf(stop, samples))}
|
||||
onMouseLeave={() => onFocus(null)}
|
||||
onFocus={() => onFocus(focusOf(stop, samples))}
|
||||
onBlur={() => onFocus(null)}
|
||||
onClick={() => onFocus(focusOf(stop, samples))}
|
||||
className="relative inline-flex rounded-full focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
{along ? <WhiteBadge icon={glyph}>{body}</WhiteBadge> : <SoftPill icon={glyph}>{body}</SoftPill>}
|
||||
{stop.stageEnd && <StageEndCorner />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{stay && <span className="text-caption text-content-faint">{stay}</span>}
|
||||
{stop.flag && (
|
||||
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t(FLAG_LOOK[stop.flag].labelKey)}
|
||||
</SoftPill>
|
||||
)}
|
||||
</span>
|
||||
<li className="flex">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onMouseEnter={point}
|
||||
onMouseLeave={() => onFocus(null)}
|
||||
onFocus={point}
|
||||
onBlur={() => onFocus(null)}
|
||||
onClick={point}
|
||||
className="-mx-1.5 flex min-w-0 flex-1 items-center gap-2.5 rounded-[10px] px-1.5 py-1 text-start transition-colors hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent"
|
||||
>
|
||||
<Marker stop={stop} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<bdi
|
||||
className={`block truncate leading-tight ${along ? 'text-caption text-content-secondary' : 'text-body font-medium text-content'}`}
|
||||
>
|
||||
{name}
|
||||
</bdi>
|
||||
{(under.length > 0 || stop.flag) && (
|
||||
<span className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-caption text-content-faint">
|
||||
{under.map((part) => (
|
||||
<span key={part}>{part}</span>
|
||||
))}
|
||||
{stop.flag && (
|
||||
<SoftPill tone="warning" icon={<AlertTriangle size={10} strokeWidth={2.2} aria-hidden="true" />}>
|
||||
{t(FLAG_LOOK[stop.flag].labelKey)}
|
||||
</SoftPill>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{clock && <span className="flex-none text-caption tabular-nums text-content-muted">{clock}</span>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,38 +133,36 @@ function legMinutes(from: TourRouteStop | undefined, to: TourRouteStop): number
|
||||
return to.minutes - from.minutes;
|
||||
}
|
||||
|
||||
/** The line between two rows, with the stretch's walking time on it. */
|
||||
/** The walk between two rows, beside the line: read with the row it leads to, so hidden here. */
|
||||
function Leg({ minutes }: { minutes: number | null }) {
|
||||
const format = useCardFormat();
|
||||
if (minutes === null) return <li aria-hidden="true" className="h-1.5" />;
|
||||
return (
|
||||
// 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" />
|
||||
<li aria-hidden="true" className="flex items-center py-0.5 ps-[30px]">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-surface-secondary px-2 py-0.5 text-caption tabular-nums text-content-muted">
|
||||
<Clock size={10} strokeWidth={2.2} className="flex-none text-content-faint" />
|
||||
{format.minutes(minutes)}
|
||||
</span>
|
||||
{minutes !== null && (
|
||||
<span className="text-caption tabular-nums leading-tight text-content-faint">{format.minutes(minutes)}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 }) {
|
||||
/** The middle stops folded into one row past eight, naming them in its tooltip. */
|
||||
function FoldedRow({ stops }: { stops: readonly TourRouteStop[] }) {
|
||||
const { t } = useTranslation();
|
||||
const format = useCardFormat();
|
||||
const names = stops.map((stop) => stopName(stop, t)).join(', ');
|
||||
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'}`}
|
||||
/>
|
||||
<li className="flex items-center gap-2.5 py-1">
|
||||
<span className="relative z-[1] flex w-5 flex-none justify-center">
|
||||
<span className="h-2 w-2 rounded-full border border-dashed border-content-faint bg-surface-card" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5">{children}</span>
|
||||
</span>
|
||||
<Tooltip label={names}>
|
||||
<span className="text-caption text-content-muted underline decoration-dotted underline-offset-2">
|
||||
{t('assistant.card.tour.stopsMore', { count: stops.length })}
|
||||
<span className="sr-only">{`: ${names}`}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,42 +178,30 @@ export default function TourRouteStops({
|
||||
const { t } = useTranslation();
|
||||
const { head, folded, tail } = chipStops(card);
|
||||
if (head.length === 0) return null;
|
||||
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>
|
||||
);
|
||||
rows.push(<StopRow key={key} stop={stop} leg={leg} samples={samples} onFocus={onFocus} />);
|
||||
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>
|
||||
);
|
||||
rows.push(<FoldedRow key="f" stops={folded} />);
|
||||
// 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-col" role="list" aria-label={t('assistant.card.tour.stops')}>
|
||||
{rows}
|
||||
<div className="relative">
|
||||
{/* The line down the timeline, from the first mark to the last, under the marks. */}
|
||||
<span aria-hidden="true" className="absolute bottom-3 start-[9.5px] top-3 w-px bg-edge" />
|
||||
<ul className="relative m-0 flex list-none flex-col p-0" aria-label={t('assistant.card.tour.stops')}>
|
||||
{rows}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,128 @@
|
||||
import type { TourRouteCard as Route } from '@trek/shared';
|
||||
import { Route as RouteIcon } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
|
||||
import { useSettingsStore } from '../../../../store/settingsStore';
|
||||
import type { DistanceUnit } from '../../../../types';
|
||||
import { formatDistance, formatElevation } from '../../../../utils/units';
|
||||
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
|
||||
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
|
||||
import { VariantDelta } from '../../../Tours/planner/LegVariantsCard';
|
||||
import { VariantFigures, WayChoice } from '../../../Tours/planner/mapCardParts';
|
||||
import { type LegVariant, variantDelta } from '../../../Tours/planner/legVariants';
|
||||
import { VariantMark } from '../../../Tours/planner/mapCardParts';
|
||||
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
|
||||
import { useAssistantView } from '../../assistantViewContext';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardSection } from '../parts/CardSection';
|
||||
import WayFigures from './TourFigureLine';
|
||||
import { TourRouteBody } from './TourRouteCard';
|
||||
import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel';
|
||||
import { routeName, TOUR_TYPE_WORDS } from './tourRouteWords';
|
||||
import { useLineHover, useTourRouteCard } from './useTourRouteCard';
|
||||
|
||||
/** The ways of one answer with the chosen one known: its choices, then the chosen way's body. */
|
||||
/** A signed difference: a plus for more, a minus sign for less, nothing for none worth printing. */
|
||||
function signed(value: number, format: (absolute: number) => string): string | null {
|
||||
const text = format(Math.abs(value));
|
||||
if (Math.abs(value) < 1e-9 || /^0\s/.test(text)) return null;
|
||||
return `${value > 0 ? '+' : '−'}${text}`;
|
||||
}
|
||||
|
||||
/** How a way differs from the chosen one, as one faint line: "Compared with way A: −4.3 km −178 m −1 h 33 min". */
|
||||
function WayDelta({
|
||||
way,
|
||||
chosen,
|
||||
letter,
|
||||
unit,
|
||||
}: {
|
||||
way: LegVariant;
|
||||
chosen: LegVariant;
|
||||
letter: string;
|
||||
unit: DistanceUnit;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const delta = variantDelta(way, chosen);
|
||||
const parts = [
|
||||
signed(delta.distanceM / 1000, (km) => formatDistance(km, unit)),
|
||||
delta.gainM === null ? null : signed(delta.gainM, (m) => formatElevation(m, unit)),
|
||||
delta.minutes === null ? null : signed(delta.minutes, formatTourDuration),
|
||||
].filter((part): part is string => part !== null);
|
||||
if (parts.length === 0) return null;
|
||||
return (
|
||||
<span
|
||||
className="flex flex-wrap items-center gap-x-2 text-caption text-content-faint"
|
||||
data-testid="leg-variant-delta"
|
||||
>
|
||||
<span>{t('assistant.card.tour.deltaTo', { letter })}</span>
|
||||
{parts.map((part) => (
|
||||
<span key={part} className="tabular-nums text-content-muted">
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** One way to choose: its letter in the colour it is drawn in, its name and grade, its figures, how it differs. */
|
||||
function WayOption({
|
||||
entry,
|
||||
index,
|
||||
chosen,
|
||||
chosenWay,
|
||||
chosenLetter,
|
||||
unit,
|
||||
onSelect,
|
||||
onHover,
|
||||
}: {
|
||||
entry: RouteEntry;
|
||||
index: number;
|
||||
chosen: boolean;
|
||||
chosenWay: LegVariant;
|
||||
chosenLetter: string;
|
||||
unit: DistanceUnit;
|
||||
onSelect: () => void;
|
||||
onHover: (on: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const key = WAY_KEYS[index] ?? 'a';
|
||||
const letter = key.toUpperCase();
|
||||
const way = legVariantOf(entry.card, key);
|
||||
const grades = entry.card.grades;
|
||||
const name = entry.card.label ?? t('tours.variants.option', { letter });
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={chosen}
|
||||
aria-label={name}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => onHover(true)}
|
||||
onMouseLeave={() => onHover(false)}
|
||||
onFocus={() => onHover(true)}
|
||||
onBlur={() => onHover(false)}
|
||||
className={`flex w-full items-start gap-2.5 rounded-[12px] border px-2.5 py-2 text-start transition-colors ${
|
||||
chosen
|
||||
? 'border-accent bg-surface-card shadow-sm'
|
||||
: 'border-edge-faint bg-surface-secondary hover:border-edge hover:bg-surface-card'
|
||||
}`}
|
||||
>
|
||||
<VariantMark variant={way} chosen={chosen} />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<bdi className="min-w-0 flex-1 truncate text-body font-semibold leading-tight text-content">{name}</bdi>
|
||||
{grades && grades.highest !== null && (
|
||||
<SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />
|
||||
)}
|
||||
</span>
|
||||
<WayFigures way={way} unit={unit} />
|
||||
{!chosen && <WayDelta way={way} chosen={chosenWay} letter={chosenLetter} unit={unit} />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** The ways of one answer with the chosen one known: the band, the choices, then the chosen way's body. */
|
||||
function WaysCard({
|
||||
entries,
|
||||
chosen,
|
||||
@@ -44,57 +153,49 @@ function WaysCard({
|
||||
[entries, chosenIndex]
|
||||
);
|
||||
|
||||
const current = legVariantOf(chosen.card, chosenKey);
|
||||
const chosenWay = legVariantOf(chosen.card, chosenKey);
|
||||
// Side by side where the window is roomy; on the phone the ways stay rows, so a finger scrubs the profile freely.
|
||||
let columns = '';
|
||||
if (density === 'roomy' && variant !== 'fullscreen') columns = entries.length >= 3 ? 'grid-cols-3' : 'grid-cols-2';
|
||||
const title = t('assistant.card.tour.ways', { count: entries.length });
|
||||
const card: Route = chosen.card;
|
||||
return (
|
||||
<div {...route.handlers} className={`rounded-xl ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||
<div {...route.handlers} className={`rounded-[16px] ${route.litFromMap ? 'ring-1 ring-accent' : ''}`}>
|
||||
<CardFrame label={title}>
|
||||
<p className="m-0 text-caption text-content-muted">{title}</p>
|
||||
<div role="radiogroup" aria-label={title} className={`mt-1.5 grid gap-1.5 ${columns}`}>
|
||||
{entries.map((entry, index) => {
|
||||
const key = WAY_KEYS[index] ?? 'a';
|
||||
const letter = key.toUpperCase();
|
||||
const way = legVariantOf(entry.card, key);
|
||||
const grades = entry.card.grades;
|
||||
return (
|
||||
<WayChoice
|
||||
<CardHeader
|
||||
icon={RouteIcon}
|
||||
tone="info"
|
||||
eyebrow={t(TOUR_TYPE_WORDS[card.activity].labelKey)}
|
||||
title={routeName(card, t)}
|
||||
meta={title}
|
||||
/>
|
||||
<CardSection label={t('assistant.card.tour.waysPick')}>
|
||||
<div role="radiogroup" aria-label={title} className={`grid gap-1.5 ${columns}`}>
|
||||
{entries.map((entry, index) => (
|
||||
<WayOption
|
||||
key={entry.toolCallId}
|
||||
way={way}
|
||||
name={entry.card.label ?? t('tours.variants.option', { letter })}
|
||||
entry={entry}
|
||||
index={index}
|
||||
chosen={index === chosenIndex}
|
||||
aside={
|
||||
<>
|
||||
{index !== chosenIndex && (
|
||||
<VariantDelta
|
||||
variant={way}
|
||||
current={current}
|
||||
unit={unit}
|
||||
t={t}
|
||||
hint={t('assistant.card.tour.deltaTo', { letter: chosenKey.toUpperCase() })}
|
||||
/>
|
||||
)}
|
||||
{grades && <SacStepChip step={grades.highest} uncertain={grades.unknownShare >= 0.5} />}
|
||||
</>
|
||||
}
|
||||
chosenWay={chosenWay}
|
||||
chosenLetter={chosenKey.toUpperCase()}
|
||||
unit={unit}
|
||||
onSelect={() => pick(tripId, blockKey, entry.toolCallId)}
|
||||
onHover={(on) => hover(entry.toolCallId, on)}
|
||||
>
|
||||
<VariantFigures variant={way} unit={unit} t={t} />
|
||||
</WayChoice>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</CardSection>
|
||||
<div className="mt-3">
|
||||
<TourRouteBody
|
||||
toolCallId={chosen.toolCallId}
|
||||
card={chosen.card}
|
||||
route={route}
|
||||
color={variantPaintCss(chosenKey)}
|
||||
compare={compare}
|
||||
letter={chosenKey.toUpperCase()}
|
||||
/>
|
||||
</div>
|
||||
<TourRouteBody
|
||||
toolCallId={chosen.toolCallId}
|
||||
card={chosen.card}
|
||||
route={route}
|
||||
color={variantPaintCss(chosenKey)}
|
||||
compare={compare}
|
||||
letter={chosenKey.toUpperCase()}
|
||||
/>
|
||||
</CardFrame>
|
||||
</div>
|
||||
);
|
||||
@@ -102,12 +203,12 @@ function WaysCard({
|
||||
|
||||
/**
|
||||
* Two or three ways of one route an answer proposed, as the planner offers a
|
||||
* section's ways: each a choice with its letter in the colour it is drawn in,
|
||||
* its own words, how it differs from the chosen one and its hardest grade,
|
||||
* its figures below. One profile holds them all, the chosen way filled and
|
||||
* the others thin on the same axes; under it the chosen way's stops, day and
|
||||
* warnings, and "Plan way B". A choice is kept per answer, and pointing at a
|
||||
* way lights its line on the map.
|
||||
* section's ways: a band naming the route, each way a choice with its letter
|
||||
* in the colour it is drawn in, its name and hardest grade, its figures and
|
||||
* how it differs from the chosen one; one profile holds them all, the chosen
|
||||
* way filled and the others thin on the same axes; under it the chosen way's
|
||||
* figures, stops, day and warnings, and "Plan way B". A choice is kept per
|
||||
* answer, and pointing at a way lights its line on the map.
|
||||
*/
|
||||
export default function TourWays({ entries }: { entries: readonly RouteEntry[] }) {
|
||||
const { tripId } = useAssistantView();
|
||||
|
||||
@@ -67,6 +67,9 @@ describe('takeInThread', () => {
|
||||
it('FE-AI-TOUR-048: a take that names its proposal is matched by it, not by its way', () => {
|
||||
const other = { ...GENTLE, line: [[47.9, 12.8, 600], ...GENTLE.line.slice(1)] } satisfies TourRouteCard;
|
||||
expect(takeInThread([proposal, take(other, {}, 'take_proposal', 'call_1')], 'call_1', HIKE)).toBe('waiting');
|
||||
// The server joins a take to the answer that drew the proposal: found there too.
|
||||
const joined = { ...proposal, parts: [...proposal.parts, ...take(other, {}, 'take_proposal', 'call_1').parts] };
|
||||
expect(takeInThread([joined], 'call_1', HIKE)).toBe('waiting');
|
||||
// The same way taken from another proposal leaves this one free.
|
||||
expect(
|
||||
takeInThread([proposal, take(HIKE, { approved: true }, 'take_proposal', 'call_2')], 'call_1', HIKE)
|
||||
|
||||
@@ -45,7 +45,8 @@ export function takeOfProposal(
|
||||
): { 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)) {
|
||||
// The server joins a take to the answer that drew its proposal, so that answer is searched as well as the later ones.
|
||||
for (const message of messages.slice(from)) {
|
||||
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;
|
||||
|
||||
@@ -14,6 +14,6 @@ import TourRouteInRow, { TourRouteLead } from './TourRouteInRow';
|
||||
* file.
|
||||
*/
|
||||
export const TOUR_VIEWS = {
|
||||
tourRoute: { Block: TourRouteBlock, Lead: TourRouteLead, Body: TourRouteInRow },
|
||||
tourRoute: { Block: TourRouteBlock, Lead: TourRouteLead, Body: TourRouteInRow, wideBody: true },
|
||||
tourList: { Block: TourListCard },
|
||||
} satisfies { [K in 'tourRoute' | 'tourList']: CardView<K> };
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { Palmtree } from 'lucide-react';
|
||||
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { safeHexColor } from '../../../../utils/safeColor';
|
||||
import type { CardEntry } from '../cardTypes';
|
||||
import CardFrame from '../parts/CardFrame';
|
||||
import CardHeader from '../parts/CardHeader';
|
||||
import { CardOpenButton } from '../parts/CardOpen';
|
||||
import { CardFoot } from '../parts/CardSection';
|
||||
import { useViewerId } from '../parts/useViewerId';
|
||||
import VacayBalance from './VacayBalance';
|
||||
import { balanceLines, mergeVacayCards, type VacayView } from './vacayCardModel';
|
||||
@@ -26,7 +30,6 @@ const SQUARE = 'size-2 flex-none rounded-sm';
|
||||
const COMPANY = 'size-2 flex-none rounded-sm bg-warning-soft ring-1 ring-inset ring-warning';
|
||||
const TRIP = 'size-1.5 flex-none rounded-full bg-info';
|
||||
const OTHER = 'mt-1.5 text-caption text-content-muted';
|
||||
const FOOT = 'mt-1 flex justify-end';
|
||||
|
||||
interface LegendItem {
|
||||
key: string;
|
||||
@@ -79,13 +82,14 @@ export default function VacayAnswerBlock({ cards }: { cards: readonly CardEntry[
|
||||
const first = own[0] ?? null;
|
||||
return (
|
||||
<CardFrame label={t('assistant.card.vacay.label')}>
|
||||
<CardHeader icon={Palmtree} tone="info" title={t('assistant.card.vacay.label')} />
|
||||
{view.months.length > 0 && <VacayMonthStrip view={view} placed={[]} labelOf={() => ''} />}
|
||||
<Legend view={view} />
|
||||
{view.otherDays > 0 && <p className={OTHER}>{t('assistant.card.vacay.otherDays', { count: view.otherDays })}</p>}
|
||||
<VacayBalance lines={lines} view={view} mode="read" />
|
||||
<div className={FOOT}>
|
||||
<CardFoot>
|
||||
<CardOpenButton target={{ kind: 'page', path: vacayPathOf(first) }} labelKey="assistant.card.vacay.open" />
|
||||
</div>
|
||||
</CardFoot>
|
||||
</CardFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -625,7 +625,7 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
|
||||
<TourPlannerMapStage
|
||||
tripId={tripId} mapTileUrl={mapTileUrl} leftHidden={leftHidden} rightHidden={rightHidden} t={t}
|
||||
tourPlanner={tourPlanner} tourMap={tourMap} locationSearch={locationSearch} canPlaceEdit={canPlaceEdit}
|
||||
leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx}
|
||||
leftPanelPx={leftPanelPx} rightPanelPx={rightPanelPx} assistantRoutes={assistantMap}
|
||||
onViewportChange={wandererBrowser.onViewportChange} paddleChecklist={pageState.paddleChecklist}
|
||||
/>
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressG
|
||||
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher';
|
||||
import { MapUndoHint } from '../../components/Map/MapUndoHint';
|
||||
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
|
||||
import type { AssistantMapProps } from '../../components/Map/mapViewContract';
|
||||
import LocationSelect from '../../components/Planner/LocationSelect';
|
||||
import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel';
|
||||
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
|
||||
@@ -74,6 +75,11 @@ export interface TourPlannerMapStageProps
|
||||
onViewportChange?: (bounds: FerrataBbox) => void;
|
||||
/** The open paddle tour's checklist (PAD-06): its part for the picked object rides in the object's map card. */
|
||||
paddleChecklist?: PageState['paddleChecklist'];
|
||||
/** The routes the assistant's cards show and its camera, as on the plan's map, so a proposed hike shows here too. */
|
||||
assistantRoutes?: Pick<
|
||||
AssistantMapProps,
|
||||
'assistantCamera' | 'assistantRouteLines' | 'onAssistantRouteHover' | 'onAssistantRoutePoint'
|
||||
>;
|
||||
}
|
||||
|
||||
/** What the map reads of the tour on screen: a GPX file viewed as it came in has its analysis and no points. */
|
||||
@@ -104,6 +110,7 @@ export function TourPlannerMapStage({
|
||||
rightPanelPx,
|
||||
onViewportChange,
|
||||
paddleChecklist,
|
||||
assistantRoutes,
|
||||
}: Readonly<TourPlannerMapStageProps>): ReactElement {
|
||||
const [terrain3d, setTerrain3d] = useState(false);
|
||||
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
|
||||
@@ -285,6 +292,7 @@ export function TourPlannerMapStage({
|
||||
onViewportChange={viewportChanged}
|
||||
placeTool={placeTool}
|
||||
{...poiMarkers}
|
||||
{...assistantRoutes}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* FE-AI-ROUTE-001 to FE-AI-ROUTE-005
|
||||
* FE-AI-ROUTE-001 to FE-AI-ROUTE-006
|
||||
*
|
||||
* Which routes of the chat's cards the plan's map draws, and how: changes
|
||||
* that still stand on the map with the way they replace, the cards of the
|
||||
@@ -127,6 +127,20 @@ describe('assistantRouteModel', () => {
|
||||
expect(refs[1]?.card).toBe(SAVED);
|
||||
});
|
||||
|
||||
it('FE-AI-ROUTE-006: once one of the ways is taken into the trip, the answer draws none of them; a discarded take brings them back', () => {
|
||||
const latest = message([...read('call_1', HIKE), ...read('call_2', GENTLE)], 'msg-1');
|
||||
const take = (approval: Approval) => {
|
||||
const parts = held('t1', PENDING, approval);
|
||||
const change = parts[1] as Extract<AssistantMessagePart, { type: 'data-trek-change' }>;
|
||||
change.data = { ...change.data, takenFrom: 'call_2' };
|
||||
return message(parts, 'msg-2');
|
||||
};
|
||||
const waiting = routeRefs([latest, take({})], {}, NOBODY, LOOK, AS_WAYS);
|
||||
expect(waiting.map((ref) => `${ref.toolCallId}:${ref.use}`)).toEqual(['t1:before', 't1:pending']);
|
||||
const discarded = routeRefs([latest, take({ approved: false })], {}, NOBODY, LOOK, AS_WAYS);
|
||||
expect(discarded.map((ref) => ref.toolCallId)).toEqual(['call_1', 'call_2']);
|
||||
});
|
||||
|
||||
it("FE-AI-ROUTE-003: lines lie in order (replaced way, others, the chosen way, the lit one) in their ways' colours", () => {
|
||||
const previous = { line: flat(HIKE), distanceM: 8000, ascentM: null, minutes: null };
|
||||
const messages = [
|
||||
|
||||
@@ -101,7 +101,8 @@ function standsOnMap(part: ToolCallPart, receipt: AssistantApplied | undefined,
|
||||
/**
|
||||
* The cards of the latest answer that draws any, laid out as the chat lays
|
||||
* them out: a group of several is a choice of ways, each with its letter, the
|
||||
* chosen one (by `picks`, else the first) picked. Older answers drop.
|
||||
* chosen one (by `picks`, else the first) picked. Older answers drop, and so
|
||||
* do the ways of a group once one of them was taken into the trip.
|
||||
*/
|
||||
function lookRefs(
|
||||
messages: readonly AssistantMessage[],
|
||||
@@ -111,6 +112,7 @@ function lookRefs(
|
||||
layout: AnswerLayout
|
||||
): RouteRef[] {
|
||||
const chosenIds = new Set(Object.values(picks));
|
||||
const taken = takenProposals(messages);
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const answers = (messages[index]?.parts ?? []).flatMap((part): RouteCardEntry[] =>
|
||||
part.type === 'data-trek-card' && part.data.card && !changeIds.has(part.data.toolCallId)
|
||||
@@ -121,6 +123,7 @@ function lookRefs(
|
||||
const drawn = new Set(lined.map((entry) => entry.toolCallId));
|
||||
if (drawn.size === 0) continue;
|
||||
return layout(answers).flatMap((group) => {
|
||||
if (group.some((entry) => taken.has(entry.toolCallId))) return [];
|
||||
const ways = group.length >= 2;
|
||||
const chosen = group.find((entry) => chosenIds.has(entry.toolCallId)) ?? group[0];
|
||||
return group.flatMap((entry, position): RouteRef[] => {
|
||||
@@ -133,6 +136,28 @@ function lookRefs(
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The proposals taken into the trip ("Plan this") and not discarded since:
|
||||
* each stands as its change from then on, so its answer's ways leave the map.
|
||||
*/
|
||||
function takenProposals(messages: readonly AssistantMessage[]): Set<string> {
|
||||
const dropped = new Set<string>();
|
||||
for (const message of messages) {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'tool-call' || !part.approval) continue;
|
||||
if (part.approval.approved === false || part.approval.resolution) dropped.add(part.toolCallId);
|
||||
}
|
||||
}
|
||||
const taken = new Set<string>();
|
||||
for (const message of messages) {
|
||||
for (const part of message.parts) {
|
||||
if (part.type !== 'data-trek-change' || !part.data.takenFrom) continue;
|
||||
if (!dropped.has(part.data.toolCallId)) taken.add(part.data.takenFrom);
|
||||
}
|
||||
}
|
||||
return taken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every card of the chat the map draws now, in the part it plays: the
|
||||
* changes that still stand on the map (each with the way it replaces), then
|
||||
|
||||
@@ -226,9 +226,10 @@ type ToolGroups = Readonly<Record<string, { about: string; controllers: readonly
|
||||
const CORE_TOOL_GROUPS: ToolGroups = {
|
||||
tours: {
|
||||
about:
|
||||
'Tours: hikes, bike tours, city walks, paddling and ski routes; plan, create, change and export them ' +
|
||||
'(hikes with their heights and SAC grades), their packing, the tour library and imports from GPX files ' +
|
||||
'and Google Maps walks.',
|
||||
'Tours: hikes (also over several days in stages), city walks, bike tours, runs, rides, paddling and ski ' +
|
||||
'routes; plan, create, change and export them (hikes with their heights and SAC grades), search along a ' +
|
||||
'tour, check off a city walk under way, the recorded tracks and tour stats, their packing, tours in ' +
|
||||
'collections, the tour library and imports from GPX files and Google Maps walks.',
|
||||
controllers: [
|
||||
'ToursMcp',
|
||||
'CityWalkMcp',
|
||||
|
||||
@@ -34,7 +34,7 @@ How you work:
|
||||
- Expenses and their ids come from list_budget_items. Change an existing one with update_budget_item; never add a second entry to correct one. For what each person's share comes to, use get_budget_per_person; for who pays whom, get_settlement_summary.
|
||||
- For the wind on a day (a paddle, a sail, a ridge walk), use get_wind_forecast: it has the gusts and the direction hour by hour.
|
||||
- End every answer with one suggest_follow_ups call, alone in its step after your text: up to three short messages the user would plausibly send next, about what you just discussed (a hike you planned: an easier variant, what to pack; never the day plan when the answer was not about it). When you asked the user a question, offer its likely answers instead ("Yes, plan it anyway", "Rather in the Harz"). Skip it only while changes wait for the user's decision.
|
||||
- If the request is unclear, ask one short question instead of guessing.`;
|
||||
- If the request is unclear, ask one short question instead of guessing. A request that names what and roughly where is clear: propose it at once (a proposal saves nothing) and say in a sentence what you assumed, rather than asking whether you may.`;
|
||||
|
||||
/** The instructions with the packages' lines about their cards, built once so the provider's cache holds. */
|
||||
const STANDING = INSTRUCTIONS + CARD_PROMPT_LINES.map((line) => `\n- ${line}`).join('');
|
||||
|
||||
@@ -85,7 +85,7 @@ export const TOUR_CARDS: DomainCards = {
|
||||
},
|
||||
},
|
||||
prompt: [
|
||||
'Hikes: call propose_hike with a name, and day_id and start_time when known; to compare ways, call it up to three times in one step, each with a variant_label and along false. Call create_hike only once the user chose a way.',
|
||||
'The app draws a proposed, planned or saved tour (route, profile, stops, times, weather, warnings) and lists of tours as cards: do not repeat their figures or list them again, say in one sentence what matters.',
|
||||
'Hikes: call propose_hike with a name, and day_id and start_time when known; to compare ways, call it up to three times in one step, each with a variant_label and along false, the way you recommend first (the card shows the first one chosen). Call create_hike only once the user chose a way. A hike over several days is one hike: stage_end on each stop a day ends at (no day_id), then each stage on its day with assign_place_to_day and tourStageKey.',
|
||||
'The app draws a proposed, planned or saved tour (route, profile, stops, times, weather, warnings) and lists of tours as cards: do not repeat their figures or list them again, say in one sentence what matters. Once a tour is planned, offer what goes with it: places along it (search_tour_pois), what to pack (get_tour_packing, link_tour_packing), the GPX file (export_tour_gpx).',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -139,11 +139,18 @@ function waypointStops(waypoints: readonly Row[], start: string | null): TourRou
|
||||
atM: metresOf(point.at_m) ?? 0,
|
||||
minutes,
|
||||
clock: clockOf(point.arrival) ?? clockAfter(start, minutes),
|
||||
stageEnd: point.stage_end === true && index > 0 && index < waypoints.length - 1,
|
||||
}),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** How many stages a proposed hike's stops make; null for a day hike, as a saved tour's card says it. */
|
||||
function stagesOf(stops: readonly TourRouteStop[]): number | null {
|
||||
const count = 1 + stops.filter((stop) => stop.stageEnd).length;
|
||||
return count >= 2 ? Math.min(count, 60) : null;
|
||||
}
|
||||
|
||||
/** The places along a proposed way and its highest point, timed between the points around them. */
|
||||
function alongStops(value: Row, own: readonly TourRouteStop[], start: string | null): TourRouteStop[] {
|
||||
const along = rowsIn(value.along)
|
||||
@@ -248,6 +255,7 @@ export function hikeCard(
|
||||
line,
|
||||
stretches: sacStretches(sac?.segments),
|
||||
stops: sortedStops([...own, ...alongStops(plan, own, start)]),
|
||||
stages: stagesOf(own),
|
||||
schedule: scheduleOf(plan.schedule, card),
|
||||
warnings: warningsOf(plan.warnings),
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type HikeHighPoint,
|
||||
type HikePlan,
|
||||
type HikePlanPoint,
|
||||
type HikeStop,
|
||||
type HikeProposal,
|
||||
type HikeProposalSac,
|
||||
type HikeProposeRequest,
|
||||
@@ -39,9 +40,10 @@ import {
|
||||
*/
|
||||
|
||||
/** The points a hike is planned through: as asked, and for a round trip back to its start. */
|
||||
export function hikePointsOf(waypoints: readonly HikePlanPoint[], roundTrip: boolean | undefined): HikePlanPoint[] {
|
||||
export function hikePointsOf(waypoints: readonly HikeStop[], roundTrip: boolean | undefined): HikeStop[] {
|
||||
const [start] = waypoints;
|
||||
return roundTrip === true && start ? [...waypoints, { ...start }] : [...waypoints];
|
||||
// The way back ends where the hike began: the start ends no stage there.
|
||||
return roundTrip === true && start ? [...waypoints, { ...start, stage_end: undefined }] : [...waypoints];
|
||||
}
|
||||
|
||||
/** Whether the way was planned, and why not, as the answers name it. */
|
||||
@@ -49,19 +51,29 @@ export function routeOutcomeOf(plan: HikePlan): HikeRouteOutcome {
|
||||
return plan.kind === 'routed' ? { planned: true } : { planned: false, why: plan.why, position: plan.position };
|
||||
}
|
||||
|
||||
/** The points as a tour keeps them: in order, the first the start and the last the end, with their names. */
|
||||
export function hikeWaypointsOf(points: readonly HikePlanPoint[]): TourWaypoint[] {
|
||||
/**
|
||||
* The points as a tour keeps them: in order, the first the start and the
|
||||
* last the end, with their names, and on a hike over several days the stops
|
||||
* its stages end at.
|
||||
*/
|
||||
export function hikeWaypointsOf(points: readonly HikeStop[]): TourWaypoint[] {
|
||||
const last = points.length - 1;
|
||||
return points.map(({ lat, lng, name, poi_category }, sequence) => ({
|
||||
return points.map(({ lat, lng, name, poi_category, stage_end }, sequence) => ({
|
||||
lat,
|
||||
lng,
|
||||
...(name ? { name } : {}),
|
||||
...(poi_category ? { poi_category } : {}),
|
||||
role: sequence === 0 ? 'start' : sequence === last ? 'end' : 'via',
|
||||
sequence,
|
||||
...(stage_end === true && sequence > 0 && sequence < last ? { stage_end: true } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** How many stages a hike's points make: one for a day hike. */
|
||||
export function hikeStagesOf(points: readonly HikeStop[]): number {
|
||||
return 1 + points.filter((point) => point.stage_end === true).length;
|
||||
}
|
||||
|
||||
/** The way a saved hike keeps from a plan: its whole line and walking time, or none, and how the planning went. */
|
||||
export interface KeptWay {
|
||||
line: GeometryPoint[] | null;
|
||||
@@ -93,7 +105,7 @@ export function keptWayOf({ plan, line }: PlannedHike): KeptWay {
|
||||
*/
|
||||
export function hikeCreateCommandOf(
|
||||
request: HikeCreateRequest,
|
||||
points: readonly HikePlanPoint[],
|
||||
points: readonly HikeStop[],
|
||||
way: KeptWay,
|
||||
limit: number,
|
||||
): TourCreateCommand {
|
||||
@@ -135,13 +147,15 @@ function savedPointAt(point: { lat: number; lng: number }, saved: readonly TourW
|
||||
* keeps its link to the trip's place it stands for, and its name where the
|
||||
* new one gives none. A saved point no new one stands on goes, with its link.
|
||||
*/
|
||||
export function repointedWaypoints(points: readonly HikePlanPoint[], saved: readonly TourWaypoint[]): TourWaypoint[] {
|
||||
export function repointedWaypoints(points: readonly HikeStop[], saved: readonly TourWaypoint[]): TourWaypoint[] {
|
||||
return hikeWaypointsOf(points).map((point) => {
|
||||
const same = savedPointAt(point, saved);
|
||||
if (!same) return point;
|
||||
const name = point.name ?? same.name;
|
||||
// Its key too: a stage is put on its day by the key of the stop it starts at.
|
||||
return {
|
||||
...point,
|
||||
...(same.key ? { key: same.key } : {}),
|
||||
...(name ? { name } : {}),
|
||||
...(same.linked_place_id ? { linked_place_id: same.linked_place_id } : {}),
|
||||
};
|
||||
@@ -319,7 +333,7 @@ export interface HikeProposalContext {
|
||||
*/
|
||||
export function hikeProposalOf(
|
||||
request: HikeProposeRequest,
|
||||
points: readonly HikePlanPoint[],
|
||||
points: readonly HikeStop[],
|
||||
planned: PlannedHike,
|
||||
context: HikeProposalContext,
|
||||
): HikeProposal {
|
||||
@@ -329,8 +343,9 @@ export function hikeProposalOf(
|
||||
const heights = routed?.has_heights && line ? heightRangeOf(line) : null;
|
||||
const minutes = routed && line && positions ? waypointMinutesOf(line, positions, routed.duration_minutes) : null;
|
||||
const arrivals = arrivalsOf(request.start_time ?? null, minutes ?? points.map(() => null));
|
||||
// One day's sun and hours say nothing about a hike over several days.
|
||||
const day =
|
||||
routed && context.date
|
||||
routed && context.date && hikeStagesOf(points) === 1
|
||||
? hikeScheduleOf({
|
||||
date: context.date,
|
||||
dayId: context.dayId,
|
||||
@@ -357,11 +372,12 @@ export function hikeProposalOf(
|
||||
: {}),
|
||||
sac: proposalSacOf(routed?.grades ?? null),
|
||||
high_point: routed ? highPointOf(line) : null,
|
||||
waypoints: points.map(({ lat, lng, name, poi_category }, index) => ({
|
||||
waypoints: points.map(({ lat, lng, name, poi_category, stage_end }, index) => ({
|
||||
lat,
|
||||
lng,
|
||||
name: name || null,
|
||||
poi_category: poi_category ?? null,
|
||||
stage_end: stage_end === true,
|
||||
at_m: positions?.[index] ?? null,
|
||||
snapped_m: routed ? (planned.snapped?.[index] ?? null) : null,
|
||||
minutes: minutes?.[index] ?? null,
|
||||
|
||||
@@ -55,7 +55,7 @@ export class HikePlanMcp {
|
||||
@Tool({
|
||||
name: 'propose_hike',
|
||||
description:
|
||||
"Plan a hike for a trip and show it to the traveller, without saving anything; give it a name. The way on foot runs through waypoints in order (the start, the points on the way such as a hut or a summit, each with its poi_category where it is one, the end; with round_trip it comes back to the start) and takes no path harder than max_hiking_difficulty on the SAC scale (1 for T1 hiking to 6 for T6 difficult alpine hiking, 2 when left out), avoiding steep climbs, steps or ferries where there is a way round when hiking_preferences asks so. TREK's router plans it as the tour planner does. The answer gives distance_m, ascent_m and descent_m, the walking time without breaks (duration_minutes: by DIN 33466, the rule hiking signposts use, where the heights are known, duration_basis din33466; else the router's, duration_basis router), min_ele_m, max_ele_m and high_point, the SAC grades OpenStreetMap records along the way (sac: highest, the graded stretches in metres along the way, and unknown_share, the part with no grade, which is unknown and never easy), every point with at_m (where it lies along the way), snapped_m (how far it was moved onto a way), minutes from the start and, with start_time, its arrival, and along: summits, huts, water and viewpoints near the way (null with along false or where they could not be searched). With day_id (a day of the trip, whose date it takes) or date, schedule gives the day: the arrival (next_day past midnight), sunrise and sunset, the weather by the hour at the highest point (weather forecast, or climate further out: last year's day) and, without start_time, latest_start to be back half an hour before sunset; warnings name a start before sunrise (dark_start), an arrival after sunset (dark_end) or within the hour before it (dusk), and the first walking hour with thunderstorms (thunder) or likely rain (rain). Where no way was planned, route says why: no_valhalla (this TREK has no router that knows hiking grades), router_unavailable (try again later), no_way (nothing within the limit joins the points: a higher limit or other points may), point_off_the_way (unreachable names the point and how far it is from any way) or too_long (longer than one hike is planned for: split it into shorter hikes). To compare ways, propose each one with its own variant_label, such as 'short and steep' or 'by the lake', and along false. The chat shows the route, its profile, times, weather and warnings as a card: never list coordinates, the hours or the figures, sum the hike up in a sentence and say what matters. Propose first and create the hike with create_hike only after the traveller said yes.",
|
||||
"Plan a hike for a trip and show it to the traveller, without saving anything; give it a name. A hike over several days (a trek from hut to hut) is one hike: mark each stop a day ends at with stage_end, and the card shows its stages. The way on foot runs through waypoints in order (the start, the points on the way such as a hut or a summit, each with its poi_category where it is one, the end; with round_trip it comes back to the start) and takes no path harder than max_hiking_difficulty on the SAC scale (1 for T1 hiking to 6 for T6 difficult alpine hiking, 2 when left out), avoiding steep climbs, steps or ferries where there is a way round when hiking_preferences asks so. TREK's router plans it as the tour planner does. The answer gives distance_m, ascent_m and descent_m, the walking time without breaks (duration_minutes: by DIN 33466, the rule hiking signposts use, where the heights are known, duration_basis din33466; else the router's, duration_basis router), min_ele_m, max_ele_m and high_point, the SAC grades OpenStreetMap records along the way (sac: highest, the graded stretches in metres along the way, and unknown_share, the part with no grade, which is unknown and never easy), every point with at_m (where it lies along the way), snapped_m (how far it was moved onto a way), minutes from the start and, with start_time, its arrival, and along: summits, huts, water and viewpoints near the way (null with along false or where they could not be searched). With day_id (a day of the trip, whose date it takes) or date, schedule gives the day: the arrival (next_day past midnight), sunrise and sunset, the weather by the hour at the highest point (weather forecast, or climate further out: last year's day) and, without start_time, latest_start to be back half an hour before sunset; warnings name a start before sunrise (dark_start), an arrival after sunset (dark_end) or within the hour before it (dusk), and the first walking hour with thunderstorms (thunder) or likely rain (rain). Where no way was planned, route says why: no_valhalla (this TREK has no router that knows hiking grades), router_unavailable (try again later), no_way (nothing within the limit joins the points: a higher limit or other points may), point_off_the_way (unreachable names the point and how far it is from any way) or too_long (longer than one hike is planned for: split it into shorter hikes). To compare ways, propose each one with its own variant_label, such as 'short and steep' or 'by the lake', and along false. The chat shows the route, its profile, times, weather and warnings as a card: never list coordinates, the hours or the figures, sum the hike up in a sentence and say what matters. Propose first and create the hike with create_hike only after the traveller said yes.",
|
||||
inputSchema: { tripId: idSchema, ...hikeProposeRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
@@ -69,7 +69,7 @@ export class HikePlanMcp {
|
||||
@Tool({
|
||||
name: 'create_hike',
|
||||
description:
|
||||
"Create a hike in a trip from points the traveller confirmed, usually the name and waypoints of a proposal of propose_hike with its max_hiking_difficulty, hiking_preferences and round_trip. It is saved the way the planner saves a new hike: a tour of type hike with its points in order (start, points on the way, end, with their names and poi_category), the way on foot planned by the same router as the proposal and kept whole (the very line a proposal of the last minutes showed), with its heights where every point has one, its distance, ascent, descent and walking time, its hiking limit and preferences, and planned_duration_minutes (the time out, breaks included) where given. Where the router did not answer, for the way or its heights (route planned false, why router_unavailable), the hike is saved with its points only (route_pending true) and TREK plans the way when someone opens it in the planner; no straight line is stored. Any other reason the proposal names (no_valhalla, no_way, point_off_the_way, too_long) refuses the hike with why, and nothing is saved. With day_id the hike is put on that day of the trip in the same write, start_time kept as the start of that visit (which needs day editing permission too); assignment names the visit, null without a day. The answer is the tour as list_tours reads it (tour.place_id is the tour's id, its revision 1), route and assignment. Put it on a day later with assign_place_to_day, link it to the packing list with link_tour_packing, export it with export_tour_gpx, change it with update_tour. Needs place editing permission on the trip. Create it only after the traveller said yes.",
|
||||
"Create a hike in a trip from points the traveller confirmed, usually the name and waypoints of a proposal of propose_hike with its max_hiking_difficulty, hiking_preferences and round_trip, and its stage_end marks for a hike over several days; then put each stage on its day with assign_place_to_day and tourStageKey (the key of the stop the stage starts at, from the stage_stops list_tours gives); leave day_id out for such a hike, which would put the whole hike on one day. It is saved the way the planner saves a new hike: a tour of type hike with its points in order (start, points on the way, end, with their names and poi_category), the way on foot planned by the same router as the proposal and kept whole (the very line a proposal of the last minutes showed), with its heights where every point has one, its distance, ascent, descent and walking time, its hiking limit and preferences, and planned_duration_minutes (the time out, breaks included) where given. Where the router did not answer, for the way or its heights (route planned false, why router_unavailable), the hike is saved with its points only (route_pending true) and TREK plans the way when someone opens it in the planner; no straight line is stored. Any other reason the proposal names (no_valhalla, no_way, point_off_the_way, too_long) refuses the hike with why, and nothing is saved. With day_id the hike is put on that day of the trip in the same write, start_time kept as the start of that visit (which needs day editing permission too); assignment names the visit, null without a day. The answer is the tour as list_tours reads it (tour.place_id is the tour's id, its revision 1), route and assignment. Put it on a day later with assign_place_to_day, link it to the packing list with link_tour_packing, export it with export_tour_gpx, change it with update_tour. Needs place editing permission on the trip. Create it only after the traveller said yes.",
|
||||
inputSchema: { tripId: idSchema.describe('The trip to create the hike in'), ...hikeCreateRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
@@ -88,7 +88,7 @@ export class HikePlanMcp {
|
||||
@Tool({
|
||||
name: 'update_tour',
|
||||
description:
|
||||
"Change a saved hike (tour_type hike) against the revision it was read at: expected_revision is tour.revision from get_tour_plan or list_tours. A new name, description or planned_duration_minutes keeps the hike's way. New waypoints (start to end; a round trip ends on its start), a new max_hiking_difficulty or new hiking_preferences plan the way anew as propose_hike plans it, and so does a hike still waiting for its way (route_pending); propose the new way to the traveller first. A hike changed since that revision is refused as a conflict: read it again and show the traveller what changed. Where the new way cannot be planned, or not with its heights, nothing is saved and the hike keeps its way; the error says why. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) or split into stages takes new points only in the planner, and the hand-drawn one no new limit or preferences either: those changes are refused here. A recorded track without points of its own (an import) takes only new waypoints, which plan it over and keep the recording. Any other type is refused: change a city walk with prepare_tour_change and save_tour_change. The hike stays on its days. The answer is the tour as saved, with its new revision, route and replanned. Needs place editing permission on the trip. Change it only after the traveller said yes.",
|
||||
"Change a saved hike (tour_type hike) against the revision it was read at: expected_revision is tour.revision from get_tour_plan or list_tours. A new name, description or planned_duration_minutes keeps the hike's way. New waypoints (start to end; a round trip ends on its start), a new max_hiking_difficulty or new hiking_preferences plan the way anew as propose_hike plans it, and so does a hike still waiting for its way (route_pending); propose the new way to the traveller first. A hike changed since that revision is refused as a conflict: read it again and show the traveller what changed. Where the new way cannot be planned, or not with its heights, nothing is saved and the hike keeps its way; the error says why. A new point standing where a saved one stood (within 25 m) keeps that point's link to a place of the trip and its name. A hike with a section drawn by hand (a via ferrata, a free line, a ferry) takes new points, a new limit or new preferences only in the planner: those changes are refused here. A hike split into stages takes new points only with stage_end given again on each stop a day ends at, and its stages keep their days where a stop stays where it was. A recorded track without points of its own (an import) takes only new waypoints, which plan it over and keep the recording. Any other type is refused: change a city walk with prepare_tour_change and save_tour_change. The hike stays on its days. The answer is the tour as saved, with its new revision, route and replanned. Needs place editing permission on the trip. Change it only after the traveller said yes.",
|
||||
inputSchema: { tripId: idSchema, ...tourUpdateRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
HIKE_ALONG_CATEGORIES,
|
||||
HIKE_ALONG_CORRIDOR_M,
|
||||
HIKE_PLAN_MAX_WAYPOINTS,
|
||||
hikeStageIssues,
|
||||
hikingPreferencesOf,
|
||||
TOUR_POI_MAX_LINE_POINTS,
|
||||
tourGpxImportFieldsSchema,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
type HikeAlongPlace,
|
||||
type HikeCreateRequest,
|
||||
type HikePlanPoint,
|
||||
type HikeStop,
|
||||
type HikeProposal,
|
||||
type HikeProposeRequest,
|
||||
type HikingPreferences,
|
||||
@@ -109,14 +111,21 @@ const HAND_DRAWN =
|
||||
* ends of its stages would go with the points they belong to, and a stage
|
||||
* that goes would leave its day. Null where new points take nothing away.
|
||||
*/
|
||||
function repointingRefusal(saved: readonly TourWaypoint[]): string | null {
|
||||
function repointingRefusal(saved: readonly TourWaypoint[], points: readonly HikeStop[]): string | null {
|
||||
if (saved.some((point) => point.fixed_leg)) return HAND_DRAWN;
|
||||
if (saved.some((point) => point.stage_end)) {
|
||||
return 'This hike is split into stages over several days, which new points would end: change its points in the planner. A new limit or new preferences plan it anew here, its stages kept.';
|
||||
// New points carry the stages of the hike themselves; without any, its stages would silently end.
|
||||
if (saved.some((point) => point.stage_end) && !points.some((point) => point.stage_end)) {
|
||||
return 'This hike is split into stages over several days: give stage_end again on each stop a day ends at, or the stages would end. A new limit or new preferences alone plan it anew here, its stages kept.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Stage ends that do not hold (at the start or the end, too many stages): refused with their reason. */
|
||||
function refuseBadStages(points: readonly HikeStop[], roundTrip: boolean | undefined): void {
|
||||
const issues = hikeStageIssues(points, roundTrip);
|
||||
if (issues.length > 0) throw badRequest(issues.map((issue) => issue.message).join('; '));
|
||||
}
|
||||
|
||||
/**
|
||||
* A hike planned with the traveller over MCP, end to end on the server: the
|
||||
* proposal reads only and may be asked for as often as ways are compared;
|
||||
@@ -149,10 +158,17 @@ export class HikePlanService {
|
||||
preferences: HikingPreferences | null | undefined,
|
||||
grades: boolean,
|
||||
): Promise<PlannedHike> {
|
||||
// The router takes the points only: where a stage ends is the hike's, not the way's.
|
||||
const waypoints = points.map(({ lat, lng, name, poi_category }) => ({
|
||||
lat,
|
||||
lng,
|
||||
...(name === undefined ? {} : { name }),
|
||||
...(poi_category === undefined ? {} : { poi_category }),
|
||||
}));
|
||||
return await this.routing.plan(
|
||||
userId,
|
||||
tripId,
|
||||
{ waypoints: points, max_hiking_difficulty: limit, hiking_preferences: preferences },
|
||||
{ waypoints, max_hiking_difficulty: limit, hiking_preferences: preferences },
|
||||
{ grades },
|
||||
);
|
||||
}
|
||||
@@ -165,6 +181,7 @@ export class HikePlanService {
|
||||
* best effort: without them the proposal still answers.
|
||||
*/
|
||||
async propose(userId: number, tripId: number, request: HikeProposeRequest): Promise<HikeProposal> {
|
||||
refuseBadStages(request.waypoints, request.round_trip);
|
||||
const day = await this.dayOf(tripId, request.day_id, request.date);
|
||||
const points = hikePointsOf(request.waypoints, request.round_trip);
|
||||
const { max_hiking_difficulty: limit, hiking_preferences: preferences } = request;
|
||||
@@ -231,6 +248,7 @@ export class HikePlanService {
|
||||
* drop onto the day tell it.
|
||||
*/
|
||||
async create(userId: number, tripId: number, request: HikeCreateRequest): Promise<HikeCreated> {
|
||||
refuseBadStages(request.waypoints, request.round_trip);
|
||||
const day = request.day_id === undefined ? null : await this.dayOf(tripId, request.day_id, undefined);
|
||||
const points = hikePointsOf(request.waypoints, request.round_trip);
|
||||
const { max_hiking_difficulty: limit, hiking_preferences: preferences } = request;
|
||||
@@ -295,7 +313,8 @@ export class HikePlanService {
|
||||
'This hike is a recorded track without points of its own: give waypoints to plan it over (the recorded track is kept), or rename it with update_place.',
|
||||
);
|
||||
}
|
||||
const repointing = request.waypoints ? repointingRefusal(saved.waypoints) : null;
|
||||
if (request.waypoints) refuseBadStages(request.waypoints, false);
|
||||
const repointing = request.waypoints ? repointingRefusal(saved.waypoints, request.waypoints) : null;
|
||||
if (repointing) throw badRequest(repointing);
|
||||
const kept = hikingPreferencesOf(tour.hiking_preferences);
|
||||
// A recorded track keeps no limit of its own; planned over, it takes the planner's.
|
||||
|
||||
@@ -202,8 +202,8 @@ function proposalFor(asked: HikeProposeRequest): HikeProposal {
|
||||
sac: { segments: [{ from_m: 0, to_m: 1112, sac: 2 }], highest: 2, unknown_share: 0 },
|
||||
high_point: { lat: 47.61, lng: 11.3, ele_m: 880, at_m: 1112 },
|
||||
waypoints: [
|
||||
{ ...WAYPOINTS[0], poi_category: null, at_m: 0, snapped_m: 2, minutes: 0, arrival: null },
|
||||
{ ...WAYPOINTS[1], at_m: 1112, snapped_m: 3, minutes: 22, arrival: null },
|
||||
{ ...WAYPOINTS[0], poi_category: null, at_m: 0, snapped_m: 2, minutes: 0, arrival: null, stage_end: false },
|
||||
{ ...WAYPOINTS[1], at_m: 1112, snapped_m: 3, minutes: 22, arrival: null, stage_end: false },
|
||||
],
|
||||
along: null,
|
||||
max_hiking_difficulty: 2,
|
||||
|
||||
@@ -180,6 +180,16 @@ describe('the card of a proposed hike', () => {
|
||||
expect(shown.warnings).toEqual([{ kind: 'thunder', at: '08:00', count: null }]);
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-001b: a hike over several days counts its stages and marks the stops they end at', () => {
|
||||
const base = proposal();
|
||||
const [start, summit] = base.waypoints as Array<Record<string, unknown>>;
|
||||
const hut = { ...start, lat: 47.605, name: 'Hut', at_m: 556, minutes: 11, arrival: '08:11', stage_end: true };
|
||||
const shown = route(answerCardOf(...input('propose_hike', { ...base, waypoints: [start, hut, summit] })));
|
||||
expect(shown.stages).toBe(2);
|
||||
expect(shown.stops.filter((stop) => stop.stageEnd).map((stop) => stop.name)).toEqual(['Hut']);
|
||||
expect(route(answerCardOf(...input('propose_hike', proposal()))).stages).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-002: lists the points and the places along the way in the order walked, timed between', () => {
|
||||
const shown = route(answerCardOf(...input('propose_hike', proposal())));
|
||||
expect(shown.stops.map((stop) => [stop.role, stop.kind, stop.atM, stop.minutes, stop.clock])).toEqual([
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
hikeCreateCommandOf,
|
||||
hikePointsOf,
|
||||
hikeProposalOf,
|
||||
hikeStagesOf,
|
||||
hikeWaypointsOf,
|
||||
keptWayOf,
|
||||
positionsAlong,
|
||||
@@ -35,7 +36,7 @@ import {
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// HIKE-PLAN-001 to HIKE-PLAN-014
|
||||
// HIKE-PLAN-001 to HIKE-PLAN-017
|
||||
|
||||
/** A plan with its line, as the router answers it: without the router's legs and snaps unless given. */
|
||||
const planned = (
|
||||
@@ -393,3 +394,52 @@ describe('where and when a proposed hike reaches its points', () => {
|
||||
expect(alongPlacesOf([], LINE)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a hike over several days', () => {
|
||||
it('HIKE-PLAN-015: keeps a stage end only on a stop between the start and the end, and the way back of a round trip ends none', () => {
|
||||
const hut = { lat: 47.002, lng: 11, name: 'Hut', stage_end: true };
|
||||
expect(hikeWaypointsOf([{ ...START, stage_end: true }, hut, SUMMIT]).map((point) => point.stage_end)).toEqual([
|
||||
undefined,
|
||||
true,
|
||||
undefined,
|
||||
]);
|
||||
expect(hikeStagesOf([START, hut, SUMMIT])).toBe(2);
|
||||
expect(hikeStagesOf([START, SUMMIT])).toBe(1);
|
||||
const loop = hikePointsOf([START, hut, SUMMIT], true);
|
||||
expect(loop.at(-1)).toMatchObject({ lat: START.lat, lng: START.lng });
|
||||
expect(loop.at(-1)?.stage_end).toBeUndefined();
|
||||
});
|
||||
|
||||
it('HIKE-PLAN-016: a proposal over several days names its stage ends and sets no schedule of a single day', () => {
|
||||
const points = [START, { lat: 47.002, lng: 11, name: 'Hut', stage_end: true }, SUMMIT];
|
||||
const day: HikeProposalContext = { dayId: 3, date: '2027-07-10', along: null, weather: null };
|
||||
const proposal = hikeProposalOf({ name: 'Trek', waypoints: points }, points, planned(ROUTED, LINE), day);
|
||||
expect(proposal.waypoints.map((point) => point.stage_end)).toEqual([false, true, false]);
|
||||
expect(proposal.schedule).toBeNull();
|
||||
expect(hikeProposalSchema.safeParse(proposal).success).toBe(true);
|
||||
const single = hikeProposalOf(
|
||||
{ name: 'Day', waypoints: [START, SUMMIT] },
|
||||
[START, SUMMIT],
|
||||
planned(ROUTED, LINE),
|
||||
day,
|
||||
);
|
||||
expect(single.schedule).not.toBeNull();
|
||||
});
|
||||
|
||||
it('HIKE-PLAN-017: new points keep the key of a saved stop they stand on, so a stage keeps its day', () => {
|
||||
const saved = [
|
||||
{ lat: 47, lng: 11, role: 'start' as const, sequence: 0, key: 'k-start' },
|
||||
{ lat: 47.002, lng: 11, role: 'via' as const, sequence: 1, key: 'k-hut', stage_end: true },
|
||||
{ lat: 47.004, lng: 11, role: 'end' as const, sequence: 2, key: 'k-end' },
|
||||
];
|
||||
const repointed = repointedWaypoints(
|
||||
[START, { lat: 47.002, lng: 11, stage_end: true }, { lat: 47.006, lng: 11 }],
|
||||
saved,
|
||||
);
|
||||
expect(repointed.map((point) => [point.key, point.stage_end])).toEqual([
|
||||
['k-start', undefined],
|
||||
['k-hut', true],
|
||||
[undefined, undefined],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
type TourPoiSearchResponse,
|
||||
type UnroutedHikePlan,
|
||||
type WeatherResult,
|
||||
hikePlanRequestSchema,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -464,6 +465,7 @@ describe('HikePlanMcp: propose_hike', () => {
|
||||
snapped_m: 12,
|
||||
minutes: 0,
|
||||
arrival: '08:30',
|
||||
stage_end: false,
|
||||
},
|
||||
{
|
||||
lat: 47.505,
|
||||
@@ -474,6 +476,7 @@ describe('HikePlanMcp: propose_hike', () => {
|
||||
snapped_m: 3,
|
||||
minutes: 26,
|
||||
arrival: '08:56',
|
||||
stage_end: false,
|
||||
},
|
||||
{
|
||||
lat: 47.51,
|
||||
@@ -484,6 +487,7 @@ describe('HikePlanMcp: propose_hike', () => {
|
||||
snapped_m: 40,
|
||||
minutes: 52,
|
||||
arrival: '09:22',
|
||||
stage_end: false,
|
||||
},
|
||||
]);
|
||||
expect(proposal.high_point).toEqual({ lat: 47.51, lng: 11.1, ele_m: 1000, at_m: 1112 });
|
||||
@@ -635,6 +639,29 @@ describe('HikePlanMcp: propose_hike', () => {
|
||||
),
|
||||
).rejects.toThrow('Day not found');
|
||||
});
|
||||
|
||||
it('HIKE-MCP-022: refuses a stage end at the start or the end of a hike, which ends no day', async () => {
|
||||
const f = await fixture();
|
||||
const [first, ...rest] = WAYPOINTS;
|
||||
await expect(
|
||||
mcp.proposeHike(
|
||||
{ tripId: f.trip.id, name: 'Wrong', waypoints: [{ ...first, stage_end: true }, ...rest] },
|
||||
ctx(f.user.id),
|
||||
),
|
||||
).rejects.toThrow('a stage ends at a stop between start and end');
|
||||
});
|
||||
|
||||
it('HIKE-MCP-022b: the router gets the points without their stage ends, which its strict request refuses', async () => {
|
||||
const f = await fixture();
|
||||
const [first, middle, last] = WAYPOINTS;
|
||||
await mcp.proposeHike(
|
||||
{ tripId: f.trip.id, name: 'Two days', waypoints: [first, { ...middle, stage_end: true }, last] },
|
||||
ctx(f.user.id),
|
||||
);
|
||||
const sent = plan.mock.calls.at(-1)?.[2];
|
||||
expect(sent?.waypoints).toEqual(WAYPOINTS);
|
||||
expect(hikePlanRequestSchema.safeParse(sent).success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('HikePlanMcp: create_hike', () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.ways.two': 'طريقان',
|
||||
'assistant.card.tour.ways.few': '{count} طرق',
|
||||
'assistant.card.tour.ways.many': '{count} طريقًا',
|
||||
'assistant.card.tour.waysPick': 'اختر طريقًا',
|
||||
'assistant.card.tour.deltaTo': 'مقارنةً بالطريق {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'مقارنةً بالطريق الحالي',
|
||||
'assistant.card.tour.day': 'اليوم {day}',
|
||||
@@ -66,6 +67,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'الغروب {time}',
|
||||
'assistant.card.tour.nextDay': 'اليوم التالي',
|
||||
'assistant.card.tour.latestStart': 'انطلق قبل {time} لتنتهي قبل الغروب',
|
||||
'assistant.card.tour.latestStartShort': 'الانطلاق قبل {time}',
|
||||
'assistant.card.tour.climate': 'طقس معتاد لهذا التاريخ، لا توقعات بعد',
|
||||
'assistant.card.tour.weatherNear': 'الطقس قرب أعلى نقطة',
|
||||
'assistant.card.tour.warn.thunder': 'عواصف رعدية محتملة من {time}',
|
||||
|
||||
@@ -22,6 +22,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.list': 'Turlar',
|
||||
'assistant.card.tour.ways': '{count} yol',
|
||||
'assistant.card.tour.ways.one': '{count} yol',
|
||||
'assistant.card.tour.waysPick': 'Yol seçin',
|
||||
'assistant.card.tour.deltaTo': '{letter} yolu ilə müqayisədə',
|
||||
'assistant.card.tour.deltaBefore': 'Hazırkı yolla müqayisədə',
|
||||
'assistant.card.tour.day': 'Gün {day}',
|
||||
@@ -58,6 +59,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Gün batır {time}',
|
||||
'assistant.card.tour.nextDay': 'növbəti gün',
|
||||
'assistant.card.tour.latestStart': 'Gün batmazdan əvvəl bitirmək üçün ən gec {time} yola düşün',
|
||||
'assistant.card.tour.latestStartShort': 'Ən gec {time}-da başla',
|
||||
'assistant.card.tour.climate': 'Tarix üçün tipik hava, hələ proqnoz yoxdur',
|
||||
'assistant.card.tour.weatherNear': 'Ən yüksək nöqtə yaxınlığında hava',
|
||||
'assistant.card.tour.warn.thunder': '{time} saatdan ildırım mümkündür',
|
||||
|
||||
@@ -22,6 +22,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.list': 'Passeios',
|
||||
'assistant.card.tour.ways': '{count} caminhos',
|
||||
'assistant.card.tour.ways.one': '{count} caminho',
|
||||
'assistant.card.tour.waysPick': 'Escolha um caminho',
|
||||
'assistant.card.tour.deltaTo': 'Comparado com o caminho {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'Comparado com o caminho atual',
|
||||
'assistant.card.tour.day': 'Dia {day}',
|
||||
@@ -58,6 +59,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Pôr do sol {time}',
|
||||
'assistant.card.tour.nextDay': 'no dia seguinte',
|
||||
'assistant.card.tour.latestStart': 'Saia até as {time} para terminar antes do pôr do sol',
|
||||
'assistant.card.tour.latestStartShort': 'Saída até {time}',
|
||||
'assistant.card.tour.climate': 'Clima típico para a data, ainda sem previsão',
|
||||
'assistant.card.tour.weatherNear': 'Clima perto do ponto mais alto',
|
||||
'assistant.card.tour.warn.thunder': 'Possíveis trovoadas a partir das {time}',
|
||||
|
||||
@@ -22,6 +22,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.list': 'Excursions',
|
||||
'assistant.card.tour.ways': '{count} camins',
|
||||
'assistant.card.tour.ways.one': '{count} camí',
|
||||
'assistant.card.tour.waysPick': 'Tria un camí',
|
||||
'assistant.card.tour.deltaTo': 'Comparat amb el camí {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'Comparat amb el camí actual',
|
||||
'assistant.card.tour.day': 'Dia {day}',
|
||||
@@ -58,6 +59,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Posta de sol {time}',
|
||||
'assistant.card.tour.nextDay': 'l’endemà',
|
||||
'assistant.card.tour.latestStart': 'Surt abans de les {time} per acabar abans de la posta de sol',
|
||||
'assistant.card.tour.latestStartShort': 'Sortida abans de les {time}',
|
||||
'assistant.card.tour.climate': 'Temps típic per a la data, encara sense previsió',
|
||||
'assistant.card.tour.weatherNear': 'Temps a prop del punt més alt',
|
||||
'assistant.card.tour.warn.thunder': 'Possibles tempestes a partir de les {time}',
|
||||
|
||||
@@ -23,6 +23,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.ways': '{count} cest',
|
||||
'assistant.card.tour.ways.one': '{count} cesta',
|
||||
'assistant.card.tour.ways.few': '{count} cesty',
|
||||
'assistant.card.tour.waysPick': 'Vyberte cestu',
|
||||
'assistant.card.tour.deltaTo': 'Ve srovnání s cestou {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'Ve srovnání se stávající cestou',
|
||||
'assistant.card.tour.day': 'Den {day}',
|
||||
@@ -60,6 +61,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Západ slunce {time}',
|
||||
'assistant.card.tour.nextDay': 'další den',
|
||||
'assistant.card.tour.latestStart': 'Vyraz nejpozději v {time}, abys skončil před západem slunce',
|
||||
'assistant.card.tour.latestStartShort': 'Vyrazit do {time}',
|
||||
'assistant.card.tour.climate': 'Typické počasí pro datum, zatím bez předpovědi',
|
||||
'assistant.card.tour.weatherNear': 'Počasí u nejvyššího bodu',
|
||||
'assistant.card.tour.warn.thunder': 'Možné bouřky od {time}',
|
||||
|
||||
@@ -22,6 +22,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.list': 'Touren',
|
||||
'assistant.card.tour.ways': '{count} Wege',
|
||||
'assistant.card.tour.ways.one': '{count} Weg',
|
||||
'assistant.card.tour.waysPick': 'Weg wählen',
|
||||
'assistant.card.tour.deltaTo': 'Verglichen mit Weg {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'Verglichen mit dem bisherigen Weg',
|
||||
'assistant.card.tour.day': 'Tag {day}',
|
||||
@@ -58,6 +59,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Sonnenuntergang {time}',
|
||||
'assistant.card.tour.nextDay': 'am nächsten Tag',
|
||||
'assistant.card.tour.latestStart': 'Spätestens um {time} losgehen, um vor Sonnenuntergang fertig zu sein',
|
||||
'assistant.card.tour.latestStartShort': 'Start spätestens {time}',
|
||||
'assistant.card.tour.climate': 'Typisches Wetter für das Datum, noch keine Vorhersage',
|
||||
'assistant.card.tour.weatherNear': 'Wetter nahe dem höchsten Punkt',
|
||||
'assistant.card.tour.warn.thunder': 'Gewitter möglich ab {time}',
|
||||
|
||||
@@ -22,6 +22,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.list': 'Tours',
|
||||
'assistant.card.tour.ways': '{count} ways',
|
||||
'assistant.card.tour.ways.one': '{count} way',
|
||||
'assistant.card.tour.waysPick': 'Pick a way',
|
||||
'assistant.card.tour.deltaTo': 'Compared with way {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'Compared with the way it has now',
|
||||
'assistant.card.tour.day': 'Day {day}',
|
||||
@@ -58,6 +59,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Sunset {time}',
|
||||
'assistant.card.tour.nextDay': 'next day',
|
||||
'assistant.card.tour.latestStart': 'Start by {time} to finish before sunset',
|
||||
'assistant.card.tour.latestStartShort': 'Start by {time}',
|
||||
'assistant.card.tour.climate': 'Typical weather for the date, no forecast yet',
|
||||
'assistant.card.tour.weatherNear': 'Weather near the highest point',
|
||||
'assistant.card.tour.warn.thunder': 'Thunderstorms possible from {time}',
|
||||
|
||||
@@ -22,6 +22,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.list': 'Excursiones',
|
||||
'assistant.card.tour.ways': '{count} caminos',
|
||||
'assistant.card.tour.ways.one': '{count} camino',
|
||||
'assistant.card.tour.waysPick': 'Elige un camino',
|
||||
'assistant.card.tour.deltaTo': 'Comparado con el camino {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'Comparado con el camino actual',
|
||||
'assistant.card.tour.day': 'Día {day}',
|
||||
@@ -58,6 +59,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Atardecer {time}',
|
||||
'assistant.card.tour.nextDay': 'al día siguiente',
|
||||
'assistant.card.tour.latestStart': 'Sal antes de las {time} para terminar antes del atardecer',
|
||||
'assistant.card.tour.latestStartShort': 'Salida antes de las {time}',
|
||||
'assistant.card.tour.climate': 'Tiempo típico para la fecha, aún sin previsión',
|
||||
'assistant.card.tour.weatherNear': 'Tiempo cerca del punto más alto',
|
||||
'assistant.card.tour.warn.thunder': 'Posibles tormentas desde las {time}',
|
||||
|
||||
@@ -22,6 +22,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.list': 'Retked',
|
||||
'assistant.card.tour.ways': '{count} teed',
|
||||
'assistant.card.tour.ways.one': '{count} tee',
|
||||
'assistant.card.tour.waysPick': 'Vali tee',
|
||||
'assistant.card.tour.deltaTo': 'Võrreldes teega {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'Võrreldes praeguse teega',
|
||||
'assistant.card.tour.day': 'Päev {day}',
|
||||
@@ -58,6 +59,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Päikeseloojang {time}',
|
||||
'assistant.card.tour.nextDay': 'järgmisel päeval',
|
||||
'assistant.card.tour.latestStart': 'Alusta hiljemalt {time}, et jõuda enne päikeseloojangut',
|
||||
'assistant.card.tour.latestStartShort': 'Start hiljemalt {time}',
|
||||
'assistant.card.tour.climate': 'Kuupäevale tüüpiline ilm, prognoosi veel pole',
|
||||
'assistant.card.tour.weatherNear': 'Ilm kõrgeima punkti lähedal',
|
||||
'assistant.card.tour.warn.thunder': 'Äike võimalik alates {time}',
|
||||
|
||||
@@ -22,6 +22,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.list': 'Sorties',
|
||||
'assistant.card.tour.ways': '{count} chemins',
|
||||
'assistant.card.tour.ways.one': '{count} chemin',
|
||||
'assistant.card.tour.waysPick': 'Choisir un itinéraire',
|
||||
'assistant.card.tour.deltaTo': 'Comparé au chemin {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'Comparé au chemin actuel',
|
||||
'assistant.card.tour.day': 'Jour {day}',
|
||||
@@ -58,6 +59,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Coucher du soleil {time}',
|
||||
'assistant.card.tour.nextDay': 'le lendemain',
|
||||
'assistant.card.tour.latestStart': 'Partir avant {time} pour finir avant le coucher du soleil',
|
||||
'assistant.card.tour.latestStartShort': 'Départ avant {time}',
|
||||
'assistant.card.tour.climate': 'Météo habituelle pour cette date, pas encore de prévision',
|
||||
'assistant.card.tour.weatherNear': 'Météo près du point culminant',
|
||||
'assistant.card.tour.warn.thunder': 'Orages possibles dès {time}',
|
||||
|
||||
@@ -22,6 +22,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.list': 'Εκδρομές',
|
||||
'assistant.card.tour.ways': '{count} διαδρομές',
|
||||
'assistant.card.tour.ways.one': '{count} διαδρομή',
|
||||
'assistant.card.tour.waysPick': 'Επιλέξτε διαδρομή',
|
||||
'assistant.card.tour.deltaTo': 'Σε σύγκριση με τη διαδρομή {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'Σε σύγκριση με την τωρινή διαδρομή',
|
||||
'assistant.card.tour.day': 'Ημέρα {day}',
|
||||
@@ -58,6 +59,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Δύση {time}',
|
||||
'assistant.card.tour.nextDay': 'την επόμενη μέρα',
|
||||
'assistant.card.tour.latestStart': 'Ξεκίνα έως τις {time} για να τελειώσεις πριν τη δύση',
|
||||
'assistant.card.tour.latestStartShort': 'Αναχώρηση έως {time}',
|
||||
'assistant.card.tour.climate': 'Τυπικός καιρός για την ημερομηνία, χωρίς πρόγνωση ακόμη',
|
||||
'assistant.card.tour.weatherNear': 'Καιρός κοντά στο υψηλότερο σημείο',
|
||||
'assistant.card.tour.warn.thunder': 'Πιθανές καταιγίδες από τις {time}',
|
||||
|
||||
@@ -22,6 +22,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.list': 'Túrák',
|
||||
'assistant.card.tour.ways': '{count} út',
|
||||
'assistant.card.tour.ways.one': '{count} út',
|
||||
'assistant.card.tour.waysPick': 'Válassz utat',
|
||||
'assistant.card.tour.deltaTo': 'A(z) {letter} úthoz képest',
|
||||
'assistant.card.tour.deltaBefore': 'A jelenlegi úthoz képest',
|
||||
'assistant.card.tour.day': '{day}. nap',
|
||||
@@ -58,6 +59,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Napnyugta {time}',
|
||||
'assistant.card.tour.nextDay': 'másnap',
|
||||
'assistant.card.tour.latestStart': 'Indulj legkésőbb {time}-kor, hogy napnyugta előtt végezz',
|
||||
'assistant.card.tour.latestStartShort': 'Indulás legkésőbb {time}',
|
||||
'assistant.card.tour.climate': 'A dátumra jellemző időjárás, még nincs előrejelzés',
|
||||
'assistant.card.tour.weatherNear': 'Időjárás a legmagasabb pont közelében',
|
||||
'assistant.card.tour.warn.thunder': 'Zivatar lehetséges {time}-tól',
|
||||
|
||||
@@ -21,6 +21,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.peekClose': 'Tutup rute',
|
||||
'assistant.card.tour.list': 'Daftar tur',
|
||||
'assistant.card.tour.ways': '{count} jalan',
|
||||
'assistant.card.tour.waysPick': 'Pilih jalur',
|
||||
'assistant.card.tour.deltaTo': 'Dibandingkan dengan jalan {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'Dibandingkan dengan jalan saat ini',
|
||||
'assistant.card.tour.day': 'Hari {day}',
|
||||
@@ -56,6 +57,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Matahari terbenam {time}',
|
||||
'assistant.card.tour.nextDay': 'hari berikutnya',
|
||||
'assistant.card.tour.latestStart': 'Mulai paling lambat {time} agar selesai sebelum matahari terbenam',
|
||||
'assistant.card.tour.latestStartShort': 'Berangkat paling lambat {time}',
|
||||
'assistant.card.tour.climate': 'Cuaca khas untuk tanggal ini, belum ada prakiraan',
|
||||
'assistant.card.tour.weatherNear': 'Cuaca di dekat titik tertinggi',
|
||||
'assistant.card.tour.warn.thunder': 'Kemungkinan badai petir mulai {time}',
|
||||
|
||||
@@ -22,6 +22,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.list': 'Escursioni',
|
||||
'assistant.card.tour.ways': '{count} percorsi',
|
||||
'assistant.card.tour.ways.one': '{count} percorso',
|
||||
'assistant.card.tour.waysPick': 'Scegli un percorso',
|
||||
'assistant.card.tour.deltaTo': 'Rispetto al percorso {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'Rispetto al percorso attuale',
|
||||
'assistant.card.tour.day': 'Giorno {day}',
|
||||
@@ -58,6 +59,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Tramonto {time}',
|
||||
'assistant.card.tour.nextDay': 'il giorno dopo',
|
||||
'assistant.card.tour.latestStart': 'Parti entro le {time} per finire prima del tramonto',
|
||||
'assistant.card.tour.latestStartShort': 'Partenza entro le {time}',
|
||||
'assistant.card.tour.climate': 'Meteo tipico per la data, ancora nessuna previsione',
|
||||
'assistant.card.tour.weatherNear': 'Meteo vicino al punto più alto',
|
||||
'assistant.card.tour.warn.thunder': 'Possibili temporali dalle {time}',
|
||||
|
||||
@@ -21,6 +21,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.peekClose': 'ルートを閉じる',
|
||||
'assistant.card.tour.list': 'ツアー一覧',
|
||||
'assistant.card.tour.ways': '{count}本の道',
|
||||
'assistant.card.tour.waysPick': 'ルートを選択',
|
||||
'assistant.card.tour.deltaTo': '道{letter}との比較',
|
||||
'assistant.card.tour.deltaBefore': '今の道との比較',
|
||||
'assistant.card.tour.day': '{day}日目',
|
||||
@@ -56,6 +57,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': '日の入り{time}',
|
||||
'assistant.card.tour.nextDay': '翌日',
|
||||
'assistant.card.tour.latestStart': '日没前に終えるには{time}までに出発',
|
||||
'assistant.card.tour.latestStartShort': '{time}までに出発',
|
||||
'assistant.card.tour.climate': 'この日付の平年の天気、予報はまだありません',
|
||||
'assistant.card.tour.weatherNear': '最高地点付近の天気',
|
||||
'assistant.card.tour.warn.thunder': '{time}から雷雨の恐れ',
|
||||
|
||||
@@ -21,6 +21,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.peekClose': '경로 닫기',
|
||||
'assistant.card.tour.list': '투어 목록',
|
||||
'assistant.card.tour.ways': '길 {count}개',
|
||||
'assistant.card.tour.waysPick': '경로 선택',
|
||||
'assistant.card.tour.deltaTo': '길 {letter}과 비교',
|
||||
'assistant.card.tour.deltaBefore': '지금 길과 비교',
|
||||
'assistant.card.tour.day': '{day}일차',
|
||||
@@ -56,6 +57,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': '일몰 {time}',
|
||||
'assistant.card.tour.nextDay': '다음 날',
|
||||
'assistant.card.tour.latestStart': '일몰 전에 끝내려면 {time}까지 출발하세요',
|
||||
'assistant.card.tour.latestStartShort': '{time}까지 출발',
|
||||
'assistant.card.tour.climate': '이 날짜의 평년 날씨, 아직 예보 없음',
|
||||
'assistant.card.tour.weatherNear': '최고 지점 근처 날씨',
|
||||
'assistant.card.tour.warn.thunder': '{time}부터 뇌우 가능성',
|
||||
|
||||
@@ -22,6 +22,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.list': 'Tochten',
|
||||
'assistant.card.tour.ways': '{count} wegen',
|
||||
'assistant.card.tour.ways.one': '{count} weg',
|
||||
'assistant.card.tour.waysPick': 'Kies een route',
|
||||
'assistant.card.tour.deltaTo': 'Vergeleken met weg {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'Vergeleken met de huidige weg',
|
||||
'assistant.card.tour.day': 'Dag {day}',
|
||||
@@ -58,6 +59,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Zonsondergang {time}',
|
||||
'assistant.card.tour.nextDay': 'volgende dag',
|
||||
'assistant.card.tour.latestStart': 'Vertrek uiterlijk om {time} om voor zonsondergang klaar te zijn',
|
||||
'assistant.card.tour.latestStartShort': 'Vertrek uiterlijk {time}',
|
||||
'assistant.card.tour.climate': 'Gebruikelijk weer voor deze datum, nog geen verwachting',
|
||||
'assistant.card.tour.weatherNear': 'Weer bij het hoogste punt',
|
||||
'assistant.card.tour.warn.thunder': 'Kans op onweer vanaf {time}',
|
||||
|
||||
@@ -24,6 +24,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.ways.one': '{count} droga',
|
||||
'assistant.card.tour.ways.few': '{count} drogi',
|
||||
'assistant.card.tour.ways.many': '{count} dróg',
|
||||
'assistant.card.tour.waysPick': 'Wybierz drogę',
|
||||
'assistant.card.tour.deltaTo': 'W porównaniu z drogą {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'W porównaniu z obecną drogą',
|
||||
'assistant.card.tour.day': 'Dzień {day}',
|
||||
@@ -62,6 +63,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Zachód słońca {time}',
|
||||
'assistant.card.tour.nextDay': 'następnego dnia',
|
||||
'assistant.card.tour.latestStart': 'Wyrusz najpóźniej o {time}, aby skończyć przed zachodem słońca',
|
||||
'assistant.card.tour.latestStartShort': 'Start najpóźniej {time}',
|
||||
'assistant.card.tour.climate': 'Typowa pogoda na tę datę, jeszcze bez prognozy',
|
||||
'assistant.card.tour.weatherNear': 'Pogoda przy najwyższym punkcie',
|
||||
'assistant.card.tour.warn.thunder': 'Możliwe burze od {time}',
|
||||
|
||||
@@ -24,6 +24,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.ways.one': '{count} путь',
|
||||
'assistant.card.tour.ways.few': '{count} пути',
|
||||
'assistant.card.tour.ways.many': '{count} путей',
|
||||
'assistant.card.tour.waysPick': 'Выберите путь',
|
||||
'assistant.card.tour.deltaTo': 'По сравнению с путём {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'По сравнению с нынешним путём',
|
||||
'assistant.card.tour.day': 'День {day}',
|
||||
@@ -62,6 +63,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Закат {time}',
|
||||
'assistant.card.tour.nextDay': 'на следующий день',
|
||||
'assistant.card.tour.latestStart': 'Выйдите не позже {time}, чтобы закончить до заката',
|
||||
'assistant.card.tour.latestStartShort': 'Выход до {time}',
|
||||
'assistant.card.tour.climate': 'Обычная погода для этой даты, прогноза пока нет',
|
||||
'assistant.card.tour.weatherNear': 'Погода у высшей точки',
|
||||
'assistant.card.tour.warn.thunder': 'Возможны грозы с {time}',
|
||||
|
||||
@@ -23,6 +23,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.ways': '{count} ciest',
|
||||
'assistant.card.tour.ways.one': '{count} cesta',
|
||||
'assistant.card.tour.ways.few': '{count} cesty',
|
||||
'assistant.card.tour.waysPick': 'Vyberte cestu',
|
||||
'assistant.card.tour.deltaTo': 'V porovnaní s cestou {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'V porovnaní so súčasnou cestou',
|
||||
'assistant.card.tour.day': 'Deň {day}',
|
||||
@@ -60,6 +61,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Západ slnka {time}',
|
||||
'assistant.card.tour.nextDay': 'ďalší deň',
|
||||
'assistant.card.tour.latestStart': 'Vyraz najneskôr o {time}, aby si skončil pred západom slnka',
|
||||
'assistant.card.tour.latestStartShort': 'Vyraziť do {time}',
|
||||
'assistant.card.tour.climate': 'Typické počasie pre dátum, zatiaľ bez predpovede',
|
||||
'assistant.card.tour.weatherNear': 'Počasie pri najvyššom bode',
|
||||
'assistant.card.tour.warn.thunder': 'Možné búrky od {time}',
|
||||
|
||||
@@ -22,6 +22,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.list': 'Turer',
|
||||
'assistant.card.tour.ways': '{count} vägar',
|
||||
'assistant.card.tour.ways.one': '{count} väg',
|
||||
'assistant.card.tour.waysPick': 'Välj en väg',
|
||||
'assistant.card.tour.deltaTo': 'Jämfört med väg {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'Jämfört med den nuvarande vägen',
|
||||
'assistant.card.tour.day': 'Dag {day}',
|
||||
@@ -58,6 +59,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Solnedgång {time}',
|
||||
'assistant.card.tour.nextDay': 'nästa dag',
|
||||
'assistant.card.tour.latestStart': 'Gå senast {time} för att vara klar före solnedgången',
|
||||
'assistant.card.tour.latestStartShort': 'Start senast {time}',
|
||||
'assistant.card.tour.climate': 'Typiskt väder för datumet, ingen prognos än',
|
||||
'assistant.card.tour.weatherNear': 'Väder nära högsta punkten',
|
||||
'assistant.card.tour.warn.thunder': 'Åska möjlig från {time}',
|
||||
|
||||
@@ -21,6 +21,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.peekClose': 'ปิดเส้นทาง',
|
||||
'assistant.card.tour.list': 'รายการทัวร์',
|
||||
'assistant.card.tour.ways': '{count} เส้นทาง',
|
||||
'assistant.card.tour.waysPick': 'เลือกเส้นทาง',
|
||||
'assistant.card.tour.deltaTo': 'เทียบกับเส้นทาง {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'เทียบกับเส้นทางปัจจุบัน',
|
||||
'assistant.card.tour.day': 'วันที่ {day}',
|
||||
@@ -56,6 +57,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'พระอาทิตย์ตก {time}',
|
||||
'assistant.card.tour.nextDay': 'วันถัดไป',
|
||||
'assistant.card.tour.latestStart': 'ออกเดินภายใน {time} เพื่อให้เสร็จก่อนพระอาทิตย์ตก',
|
||||
'assistant.card.tour.latestStartShort': 'ออกเดินก่อน {time}',
|
||||
'assistant.card.tour.climate': 'สภาพอากาศตามปกติของวันนี้ ยังไม่มีพยากรณ์',
|
||||
'assistant.card.tour.weatherNear': 'สภาพอากาศใกล้จุดสูงสุด',
|
||||
'assistant.card.tour.warn.thunder': 'อาจมีพายุฝนฟ้าคะนองตั้งแต่ {time}',
|
||||
|
||||
@@ -22,6 +22,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.list': 'Turlar',
|
||||
'assistant.card.tour.ways': '{count} yol',
|
||||
'assistant.card.tour.ways.one': '{count} yol',
|
||||
'assistant.card.tour.waysPick': 'Bir yol seçin',
|
||||
'assistant.card.tour.deltaTo': '{letter} yoluyla karşılaştırıldığında',
|
||||
'assistant.card.tour.deltaBefore': 'Şimdiki yolla karşılaştırıldığında',
|
||||
'assistant.card.tour.day': '{day}. gün',
|
||||
@@ -58,6 +59,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Gün batımı {time}',
|
||||
'assistant.card.tour.nextDay': 'ertesi gün',
|
||||
'assistant.card.tour.latestStart': 'Gün batımından önce bitirmek için en geç {time} yola çık',
|
||||
'assistant.card.tour.latestStartShort': 'En geç {time} başla',
|
||||
'assistant.card.tour.climate': 'Bu tarih için tipik hava, henüz tahmin yok',
|
||||
'assistant.card.tour.weatherNear': 'En yüksek nokta yakınındaki hava',
|
||||
'assistant.card.tour.warn.thunder': '{time} itibarıyla gök gürültülü fırtına olası',
|
||||
|
||||
@@ -24,6 +24,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.ways.one': '{count} шлях',
|
||||
'assistant.card.tour.ways.few': '{count} шляхи',
|
||||
'assistant.card.tour.ways.many': '{count} шляхів',
|
||||
'assistant.card.tour.waysPick': 'Оберіть шлях',
|
||||
'assistant.card.tour.deltaTo': 'Порівняно зі шляхом {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'Порівняно з нинішнім шляхом',
|
||||
'assistant.card.tour.day': 'День {day}',
|
||||
@@ -62,6 +63,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Захід сонця {time}',
|
||||
'assistant.card.tour.nextDay': 'наступного дня',
|
||||
'assistant.card.tour.latestStart': 'Вирушайте не пізніше {time}, щоб закінчити до заходу сонця',
|
||||
'assistant.card.tour.latestStartShort': 'Вийти до {time}',
|
||||
'assistant.card.tour.climate': 'Звичайна погода для цієї дати, прогнозу ще немає',
|
||||
'assistant.card.tour.weatherNear': 'Погода біля найвищої точки',
|
||||
'assistant.card.tour.warn.thunder': 'Можливі грози з {time}',
|
||||
|
||||
@@ -21,6 +21,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.peekClose': 'Đóng lộ trình',
|
||||
'assistant.card.tour.list': 'Chuyến tham quan',
|
||||
'assistant.card.tour.ways': '{count} lối đi',
|
||||
'assistant.card.tour.waysPick': 'Chọn một lối đi',
|
||||
'assistant.card.tour.deltaTo': 'So với lối đi {letter}',
|
||||
'assistant.card.tour.deltaBefore': 'So với lối đi hiện tại',
|
||||
'assistant.card.tour.day': 'Ngày {day}',
|
||||
@@ -56,6 +57,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': 'Mặt trời lặn {time}',
|
||||
'assistant.card.tour.nextDay': 'ngày hôm sau',
|
||||
'assistant.card.tour.latestStart': 'Xuất phát muộn nhất lúc {time} để xong trước khi mặt trời lặn',
|
||||
'assistant.card.tour.latestStartShort': 'Xuất phát trước {time}',
|
||||
'assistant.card.tour.climate': 'Thời tiết thường gặp vào ngày này, chưa có dự báo',
|
||||
'assistant.card.tour.weatherNear': 'Thời tiết gần điểm cao nhất',
|
||||
'assistant.card.tour.warn.thunder': 'Có thể có giông từ {time}',
|
||||
|
||||
@@ -21,6 +21,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.peekClose': '關閉路線',
|
||||
'assistant.card.tour.list': '路線清單',
|
||||
'assistant.card.tour.ways': '{count}條道路',
|
||||
'assistant.card.tour.waysPick': '選擇路線',
|
||||
'assistant.card.tour.deltaTo': '與道路{letter}相比',
|
||||
'assistant.card.tour.deltaBefore': '與現有道路相比',
|
||||
'assistant.card.tour.day': '第{day}天',
|
||||
@@ -56,6 +57,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': '日落{time}',
|
||||
'assistant.card.tour.nextDay': '隔天',
|
||||
'assistant.card.tour.latestStart': '最晚{time}出發,才能在日落前走完',
|
||||
'assistant.card.tour.latestStartShort': '最晚 {time} 出發',
|
||||
'assistant.card.tour.climate': '該日期的典型天氣,暫無預報',
|
||||
'assistant.card.tour.weatherNear': '最高點附近的天氣',
|
||||
'assistant.card.tour.warn.thunder': '{time}起可能有雷雨',
|
||||
|
||||
@@ -21,6 +21,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.peekClose': '关闭路线',
|
||||
'assistant.card.tour.list': '路线列表',
|
||||
'assistant.card.tour.ways': '{count}条道路',
|
||||
'assistant.card.tour.waysPick': '选择路线',
|
||||
'assistant.card.tour.deltaTo': '与道路{letter}相比',
|
||||
'assistant.card.tour.deltaBefore': '与现有道路相比',
|
||||
'assistant.card.tour.day': '第{day}天',
|
||||
@@ -56,6 +57,7 @@ const assistantTour: TranslationStrings = {
|
||||
'assistant.card.tour.sunset': '日落{time}',
|
||||
'assistant.card.tour.nextDay': '次日',
|
||||
'assistant.card.tour.latestStart': '最晚{time}出发,才能在日落前走完',
|
||||
'assistant.card.tour.latestStartShort': '最晚 {time} 出发',
|
||||
'assistant.card.tour.climate': '该日期的典型天气,暂无预报',
|
||||
'assistant.card.tour.weatherNear': '最高点附近的天气',
|
||||
'assistant.card.tour.warn.thunder': '{time}起可能有雷暴',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
HIKE_MAX_WAYPOINTS,
|
||||
HIKE_PROPOSAL_SAC_SEGMENTS,
|
||||
hikeCreateRequestSchema,
|
||||
hikeStageIssues,
|
||||
hikeProposalSchema,
|
||||
hikeProposeRequestSchema,
|
||||
hikeRouteOutcomeSchema,
|
||||
@@ -50,6 +51,7 @@ const proposal = {
|
||||
snapped_m: 12,
|
||||
minutes: 0,
|
||||
arrival: '08:30',
|
||||
stage_end: false,
|
||||
},
|
||||
{
|
||||
lat: 47.41,
|
||||
@@ -60,6 +62,7 @@ const proposal = {
|
||||
snapped_m: 4,
|
||||
minutes: 200,
|
||||
arrival: '11:50',
|
||||
stage_end: false,
|
||||
},
|
||||
],
|
||||
along: [{ name: 'Hut', category: 'shelter', lat: 47.405, lng: 11.101, at_m: 4100, off_m: 40 }],
|
||||
@@ -237,3 +240,25 @@ describe('a hike created, changed or taken in from a file over MCP', () => {
|
||||
expect(Object.keys(tourGpxImportRequestSchema.shape)).toContain('tour_type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('a hike over several days', () => {
|
||||
const three = [point(0), { ...point(1), stage_end: true }, point(2)];
|
||||
|
||||
it('TOUR-HIKE-SCHEMA-010: takes a stage end on a stop between start and end, in a proposal, a create and a change', () => {
|
||||
expect(hikeProposeRequestSchema.safeParse({ name: 'Trek', waypoints: three }).success).toBe(true);
|
||||
expect(hikeCreateRequestSchema.safeParse({ name: 'Trek', waypoints: three }).success).toBe(true);
|
||||
expect(tourUpdateRequestSchema.safeParse({ placeId: 4, expected_revision: 2, waypoints: three }).success).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('TOUR-HIKE-SCHEMA-011: refuses a stage end at the start or the end; the last point given of a round trip is a stop on the way', () => {
|
||||
const atStart = [{ ...point(0), stage_end: true }, point(1)];
|
||||
const atEnd = [point(0), { ...point(1), stage_end: true }];
|
||||
expect(hikeProposeRequestSchema.safeParse({ name: 'X', waypoints: atStart }).success).toBe(false);
|
||||
expect(hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: atEnd }).success).toBe(false);
|
||||
expect(hikeProposeRequestSchema.safeParse({ name: 'X', waypoints: atEnd, round_trip: true }).success).toBe(true);
|
||||
expect(hikeStageIssues(atEnd, false).map((issue) => issue.path)).toEqual([['waypoints', 1, 'stage_end']]);
|
||||
expect(hikeStageIssues(atEnd, true)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { hikeScheduleSchema, hikeWarningSchema, HIKE_WARNING_KINDS } from './hik
|
||||
import { includeGeometryFlagSchema } from './tour-display.schema';
|
||||
import { tourGpxImportFieldsSchema } from './tour-gpx-import.schema';
|
||||
import { tourPoiCategorySchema, type TourPoiCategory } from './tour-poi-category';
|
||||
import { tourStageIssues } from './tour-stages';
|
||||
import {
|
||||
hikingPreferencesSchema,
|
||||
plannedTourDurationMinutesSchema,
|
||||
@@ -54,14 +55,57 @@ export const HIKE_ALONG_MAX = 12;
|
||||
/** The largest GPX text a tool takes in, as the road trip's GPX import does. */
|
||||
export const TOUR_GPX_TEXT_MAX = 1_000_000;
|
||||
|
||||
/**
|
||||
* A point of a hike the assistant plans: the planning point, and on a hike
|
||||
* over several days whether a day's stage ends there (MDT-01), where the
|
||||
* night is spent and the next stage starts.
|
||||
*/
|
||||
const hikeStopSchema = hikePlanPointSchema
|
||||
.extend({
|
||||
stage_end: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'On a hike over several days: true on the point where a day ends (the hut or village the night is spent at), where the next stage starts. Only on points between the start and the end; leave it out on a day hike',
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
export type HikeStop = z.infer<typeof hikeStopSchema>;
|
||||
|
||||
const hikeWaypointsSchema = z
|
||||
.array(hikePlanPointSchema)
|
||||
.array(hikeStopSchema)
|
||||
.min(2)
|
||||
.max(HIKE_MAX_WAYPOINTS)
|
||||
.describe(
|
||||
'The points the hike goes through in order: its start, the points on the way (a hut, a summit, a lake) and its end. For a round trip leave the end out and set round_trip',
|
||||
'The points the hike goes through in order: its start, the points on the way (a hut, a summit, a lake) and its end. For a round trip leave the end out and set round_trip. A hike over several days marks the stop of each night with stage_end',
|
||||
);
|
||||
|
||||
/**
|
||||
* Where a hike's stage ends do not hold, as the tour's own rules read them
|
||||
* (`tourStageIssues`): never at the start or the end, at most MAX_TOUR_STAGES
|
||||
* stages. A round trip comes back to its start, so its last point given is a
|
||||
* stop on the way.
|
||||
*/
|
||||
export function hikeStageIssues(
|
||||
points: readonly Pick<HikeStop, 'lat' | 'lng' | 'stage_end'>[],
|
||||
roundTrip: boolean | undefined,
|
||||
): ReturnType<typeof tourStageIssues> {
|
||||
const last = roundTrip === true ? points.length : points.length - 1;
|
||||
return tourStageIssues(
|
||||
points.map((point, index) => ({
|
||||
role: index === 0 ? ('start' as const) : index === last ? ('end' as const) : ('via' as const),
|
||||
stage_end: point.stage_end,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function refineStages(value: { waypoints?: readonly HikeStop[]; round_trip?: boolean }, ctx: z.RefinementCtx): void {
|
||||
if (!value.waypoints) return;
|
||||
for (const issue of hikeStageIssues(value.waypoints, value.round_trip)) {
|
||||
ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message });
|
||||
}
|
||||
}
|
||||
|
||||
const hikeLimitSchema = tourMaxHikingDifficultySchema.describe(
|
||||
'The hardest SAC grade the way may take, 1 (T1, hiking) to 6 (T6, difficult alpine hiking); 2 (T2, mountain hiking) when left out',
|
||||
);
|
||||
@@ -114,7 +158,8 @@ export const hikeProposeRequestSchema = z
|
||||
),
|
||||
include_geometry: includeGeometryFlagSchema,
|
||||
})
|
||||
.strict();
|
||||
.strict()
|
||||
.superRefine(refineStages);
|
||||
export type HikeProposeRequest = z.infer<typeof hikeProposeRequestSchema>;
|
||||
|
||||
/** Whether the way was planned, and why not: the plan's reasons, with the point no way reaches. */
|
||||
@@ -175,6 +220,8 @@ export const hikeProposalWaypointSchema = z.object({
|
||||
minutes: z.number().int().nonnegative().nullable(),
|
||||
/** When it is reached, HH:MM; null without a start. */
|
||||
arrival: clockTimeSchema.nullable(),
|
||||
/** Whether a day's stage ends here, on a hike over several days. */
|
||||
stage_end: z.boolean(),
|
||||
});
|
||||
export type HikeProposalWaypoint = z.infer<typeof hikeProposalWaypointSchema>;
|
||||
|
||||
@@ -240,7 +287,8 @@ export const hikeCreateRequestSchema = z
|
||||
.optional()
|
||||
.describe('When the hike starts on that day, HH:MM, kept as the start of its visit; only with day_id'),
|
||||
})
|
||||
.strict();
|
||||
.strict()
|
||||
.superRefine(refineStages);
|
||||
export type HikeCreateRequest = z.infer<typeof hikeCreateRequestSchema>;
|
||||
|
||||
export const tourUpdateRequestSchema = z
|
||||
@@ -260,7 +308,8 @@ export const tourUpdateRequestSchema = z
|
||||
hiking_preferences: hikePreferencesSchema.optional(),
|
||||
planned_duration_minutes: plannedMinutesSchema.optional(),
|
||||
})
|
||||
.strict();
|
||||
.strict()
|
||||
.superRefine(refineStages);
|
||||
export type TourUpdateRequest = z.infer<typeof tourUpdateRequestSchema>;
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user