feat(assistant): hike, walk and route cards with their lines on the map, and "Plan this"

This commit is contained in:
Maurice
2026-10-22 15:50:47 +02:00
parent a43049eb5d
commit 085c5b4263
135 changed files with 12185 additions and 234 deletions
@@ -1,5 +1,5 @@
/**
* FE-AI-ENTITY-001 to FE-AI-ENTITY-004
* FE-AI-ENTITY-001 to FE-AI-ENTITY-005
*
* What a receipt offers for the thing it changed, read off the entity's
* look: the map pin where the plan can show it, "Open" where it lives in
@@ -20,6 +20,8 @@ vi.mock('./cardLooks', async (importOriginal) => {
...actual.ENTITY_LOOK.tour,
openLabelKey: 'assistant.change.viewOnMap',
phoneOpenLabelKey: 'assistant.history.open',
onMap: undefined,
phoneOpen: undefined,
open: (applied: AssistantApplied) => ({
kind: 'tab',
tab: 'tour-planner',
@@ -27,6 +29,12 @@ vi.mock('./cardLooks', async (importOriginal) => {
}),
},
budget: { ...actual.ENTITY_LOOK.budget, open: () => ({ kind: 'tab', tab: 'finanzplan' }) },
reservation: {
...actual.ENTITY_LOOK.reservation,
openLabelKey: undefined,
open: () => ({ kind: 'tab', tab: 'buchungen' }),
phoneOpen: () => null,
},
};
return { ...actual, ENTITY_LOOK: looks, entityLookOf: (entity: AssistantApplied['entity']) => looks[entity] };
});
@@ -67,7 +75,7 @@ describe('EntityActions', () => {
});
it('FE-AI-ENTITY-003: what has no place on the map gets no pin', () => {
const { container } = renderActions(applied('packing', [2], null), { onOpen: vi.fn() });
const { container } = renderActions(applied('memory', [2], null), { onOpen: vi.fn() });
expect(container).toBeEmptyDOMElement();
});
@@ -87,4 +95,15 @@ describe('EntityActions', () => {
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'tab', tab: 'finanzplan' });
});
it('FE-AI-ENTITY-005: on the phone "Open" goes where the look names for the phone, and shows none where it names nothing', () => {
const onOpen = vi.fn();
const desktop = renderActions(applied('reservation', [5], null), { onOpen });
fireEvent.click(screen.getByRole('button', { name: 'Open' }));
expect(onOpen).toHaveBeenLastCalledWith({ kind: 'tab', tab: 'buchungen' });
desktop.unmount();
renderActions(applied('reservation', [5], null), { onOpen, variant: 'fullscreen' });
expect(screen.queryByRole('button', { name: 'Open' })).toBeNull();
});
});
@@ -11,8 +11,9 @@ import { ICON_BUTTON } from './parts/assistantTones';
/**
* What a receipt offers for the thing it changed, as its entity's look says:
* the map pin where the plan's map can show it, and "Open" where it lives
* somewhere else in TREK (a tab of the trip, another page). Neither shows
* where the shell around the chat cannot open anything.
* somewhere else in TREK (a tab of the trip, another page; on the phone what
* its look names for it there). Neither shows where the shell around the
* chat cannot open anything.
*/
export default function EntityActions({ applied, card }: { applied: AssistantApplied; card: TrekCard | null }) {
const { t } = useTranslation();
@@ -20,8 +21,9 @@ export default function EntityActions({ applied, card }: { applied: AssistantApp
if (!onOpen) return null;
const look = entityLookOf(applied.entity);
const onMap = showOnMapTarget(applied, card);
const open = look.open?.(applied, card) ?? null;
const openKey = variant === 'fullscreen' ? (look.phoneOpenLabelKey ?? look.openLabelKey) : look.openLabelKey;
const phone = variant === 'fullscreen';
const open = (phone && look.phoneOpen ? look.phoneOpen(applied, card) : look.open?.(applied, card)) ?? null;
const openKey = phone ? (look.phoneOpenLabelKey ?? look.openLabelKey) : look.openLabelKey;
const openLabel = openKey ? t(openKey) : t('assistant.card.common.open');
return (
<>
@@ -49,6 +49,8 @@ export interface EntityLook {
phoneOpenLabelKey?: string;
onMap?(applied: AssistantApplied, card: TrekCard | null): { placeId: number | null; dayId: number | null } | null;
open?(applied: AssistantApplied, card: TrekCard | null): EntityTarget | null;
/** Where "Open" goes on the phone instead, where the tab `open` names may not exist; null shows none there. */
phoneOpen?(applied: AssistantApplied, card: TrekCard | null): EntityTarget | null;
}
/**
@@ -0,0 +1,81 @@
/**
* FE-AI-TOUR-035 to FE-AI-TOUR-036, FE-AI-TOUR-044
*
* The saved tours of a trip as the Tours sidebar lists them: the avatar, the
* name, time, length and climb, the days a tour is on or that it is on none
* yet, the rest counted as a way to the Tours tab (on the phone, to the plan);
* resting on a row lights the tour's line on the map, a tap shows it there,
* and a card that goes puts out the line it lit.
*/
import type { TourListCard as TourList } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TOUR_LIST } from '../../../../../tests/helpers/assistantTour';
import { act, fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { useAssistantStore } from '../../../../store/assistantStore';
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
import TourListCard from './TourListCard';
const ASSISTANT = useAssistantStore.getState();
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
});
function renderList(view: Partial<AssistantView> = {}, card: TourList = TOUR_LIST) {
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
return render(
<AssistantViewContext.Provider value={value}>
<TourListCard cards={[{ toolCallId: 'call_3', card }]} />
</AssistantViewContext.Provider>
);
}
describe('TourListCard', () => {
it('FE-AI-TOUR-035: lists the tours with their figures and days, and counts the rest as a way to the Tours tab', () => {
const onOpen = vi.fn();
renderList({ onOpen });
expect(screen.getByRole('region', { name: 'Tours' })).toBeInTheDocument();
expect(screen.getByText('Hochfelln from Bergen')).toBeInTheDocument();
expect(screen.getByText('8.2 km')).toBeInTheDocument();
expect(screen.getByText('640 m')).toBeInTheDocument();
expect(screen.getByText('Day 2')).toBeInTheDocument();
expect(screen.getByText('Not on a day yet')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '2 more' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'tour-planner' });
});
it('FE-AI-TOUR-036: resting on a row lights the tour on the map, a tap shows it there', () => {
const onOpen = vi.fn();
renderList({ onOpen });
const row = screen.getByRole('button', { name: /Chiemsee by kayak/ });
fireEvent.mouseEnter(row);
expect(useAssistantStore.getState().mapLink).toMatchObject({ tripId: 4, hoveredId: 'tour:42', source: 'card' });
fireEvent.mouseLeave(row);
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
fireEvent.click(row);
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 42, dayId: null });
});
it('FE-AI-TOUR-044: names several days at once, counts the rest towards the plan on the phone, and puts out its light when it goes', () => {
const onOpen = vi.fn();
const first = TOUR_LIST.rows[0] as TourList['rows'][number];
const card: TourList = { ...TOUR_LIST, dayId: 22, rows: [{ ...first, days: [2, 4] }] };
const view = renderList({ onOpen, variant: 'fullscreen' }, card);
expect(screen.getByText('Days 2, 4')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '2 more' }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: null, dayId: 22 });
fireEvent.mouseEnter(screen.getByRole('button', { name: /Hochfelln from Bergen/ }));
expect(useAssistantStore.getState().mapLink?.hoveredId).toBe('tour:41');
view.unmount();
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
// A line lit from the map is the map's: a card that goes leaves it lit.
const again = renderList({ onOpen }, card);
act(() => useAssistantStore.getState().setMapHover(4, 'tour:41', 'map'));
again.unmount();
expect(useAssistantStore.getState().mapLink).toMatchObject({ hoveredId: 'tour:41', source: 'map' });
});
});
@@ -0,0 +1,128 @@
import type { TourListCard as TourList, TourListRow } from '@trek/shared';
import { CalendarDays, Clock, MoveHorizontal, Route, TrendingUp } from 'lucide-react';
import { useEffect } from 'react';
import { useTranslation } from '../../../../i18n';
import { useAssistantStore } from '../../../../store/assistantStore';
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
import { formatTourDuration } from '../../../Tours/planner/tourDurationInput';
import { TourDayRowAvatar } from '../../../Tours/TourDayRowFacts';
import { useAssistantView } from '../../assistantViewContext';
import type { CardEntry, EntityTarget } from '../cardTypes';
import CardFrame from '../parts/CardFrame';
import CardHeader from '../parts/CardHeader';
import { CardMore, CardRow, CardRows } from '../parts/CardRows';
import { useCardFormat } from '../parts/useCardFormat';
import { useLineHover } from './useTourRouteCard';
/**
* The saved tours of a trip, or of one of its days, as the Tours sidebar
* lists them: the tour's avatar with its grade, its name, its time, length
* and climb, and the days it is on or that it is on none yet. Resting on a
* row lights the tour's line on the map; a tap shows it there. What the card
* leaves out is counted at its end, a way to the Tours tab (on the phone, to
* the plan).
*/
/** The map's key for a saved tour's line, the one the plan draws it under. */
const tourRef = (placeId: number) => `tour:${placeId}`;
/** Where the tours the card leaves out are listed; the phone has no Tours tab and shows them on the plan. */
const TOURS_TAB: EntityTarget = { kind: 'tab', tab: 'tour-planner' };
/** The days a tour is on, "Day 2" or "Days 2, 4", or that it is on none yet. */
function dayWords(days: readonly number[], t: (key: string, params?: Record<string, string | number>) => string) {
if (days.length === 0) return t('assistant.card.tour.unplanned');
if (days.length === 1) return t('assistant.card.tour.day', { day: days[0] ?? '' });
return t('assistant.card.tour.days', { days: days.join(', ') });
}
function TourRow({ row, lit, onHover }: { row: TourListRow; lit: boolean; onHover: (on: boolean) => void }) {
const { t } = useTranslation();
const format = useCardFormat();
const { onOpen } = useAssistantView();
const glyph = 'flex-none text-content-faint';
const facts = (
<span className="mt-1 flex flex-wrap items-center gap-1">
{row.minutes !== null && (
<WhiteBadge icon={<Clock size={10} strokeWidth={2.2} className={glyph} />}>
{formatTourDuration(row.minutes)}
</WhiteBadge>
)}
{row.distanceM !== null && (
<WhiteBadge icon={<MoveHorizontal size={10} strokeWidth={2.2} className={glyph} />}>
{format.distance(row.distanceM / 1000)}
</WhiteBadge>
)}
{row.ascentM !== null && (
<WhiteBadge icon={<TrendingUp size={10} strokeWidth={2.2} className={glyph} />}>
{format.elevation(row.ascentM)}
</WhiteBadge>
)}
<SoftPill icon={<CalendarDays size={10} strokeWidth={2.2} aria-hidden="true" />}>
{dayWords(row.days, t)}
</SoftPill>
</span>
);
return (
<CardRow
lead={<TourDayRowAvatar tour={{ tour_type: row.activity, max_hiking_difficulty: row.limit }} size={28} />}
title={<bdi className="line-clamp-2">{row.name}</bdi>}
facts={facts}
lit={lit}
onHover={onHover}
onActivate={onOpen ? () => onOpen({ kind: 'map', placeId: row.placeId, dayId: null }) : undefined}
/>
);
}
function TourListBody({ card }: { card: TourList }) {
const { t } = useTranslation();
const { tripId, variant } = useAssistantView();
const hovered = useAssistantStore((s) => (s.mapLink?.tripId === tripId ? s.mapLink.hoveredId : null));
const setMapHover = useAssistantStore((s) => s.setMapHover);
const hover = useLineHover();
// A card that goes puts out the line it lit.
useEffect(
() => () => {
const link = useAssistantStore.getState().mapLink;
const lit = link?.tripId === tripId && link.source === 'card' && link.hoveredId?.startsWith('tour:');
if (lit) setMapHover(tripId, null, 'card');
},
[tripId, setMapHover]
);
return (
<CardFrame label={t('assistant.card.tour.list')}>
<CardHeader icon={Route} tone="info" title={t('assistant.card.tour.list')} count={card.rows.length + card.more} />
<div className="mt-1.5">
<CardRows>
{card.rows.map((row) => (
<TourRow
key={row.placeId}
row={row}
lit={hovered === tourRef(row.placeId)}
onHover={(on) => hover(tourRef(row.placeId), on)}
/>
))}
</CardRows>
<CardMore
count={card.more}
labelKey="assistant.place.more"
target={variant === 'fullscreen' ? { kind: 'map', placeId: null, dayId: card.dayId } : TOURS_TAB}
/>
</div>
</CardFrame>
);
}
export default function TourListCard({ cards }: { cards: readonly CardEntry[] }) {
return (
<div className="flex flex-col gap-2">
{cards.map(({ toolCallId, card }) =>
card.kind === 'tourList' ? <TourListBody key={toolCallId} card={card} /> : null
)}
</div>
);
}
@@ -0,0 +1,83 @@
import type { TourRouteCard } from '@trek/shared';
import { Check, Loader2, MapPin, Plus } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { SoftPill } from '../../../Planner/planParts';
import { useAssistantView } from '../../assistantViewContext';
import { CardOpenButton } from '../parts/CardOpen';
import { tourTarget } from './tourLook';
import { useTakeProposal } from './useTourRouteCard';
/**
* The foot of a route card: "On map" always; "Plan this" on a proposed hike
* or city walk for someone who may add to the trip, off with its reason said
* while it cannot be taken, and once taken a note that the tour waits below
* for a decision (or, once applied, that it was added); "Open in Tours" on a saved tour. Taller on the phone, as
* tall as the card's own "Open"; the phone has no Tours tab, so there a
* saved tour opens as the place it is on the plan.
*/
export default function TourRouteActions({
toolCallId,
card,
letter,
onShow,
}: {
toolCallId: string;
card: TourRouteCard;
/** The chosen way's letter in a ways card: "Plan way B". */
letter?: string;
onShow: () => void;
}) {
const { t } = useTranslation();
const { variant } = useAssistantView();
const take = useTakeProposal(toolCallId, card);
const phone = variant === 'fullscreen';
const tall = phone ? 'h-10' : 'h-7';
const planLabel = letter ? t('assistant.card.tour.planWay', { letter }) : t('assistant.card.tour.plan');
let caption: string | null = null;
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">
<div className="flex flex-wrap items-center justify-end gap-1.5">
<button
type="button"
onClick={onShow}
className={`inline-flex items-center gap-1 rounded-full px-2.5 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover hover:text-content ${tall}`}
>
<MapPin size={12} aria-hidden="true" />
{t('assistant.place.showOnMap')}
</button>
{card.source === 'saved' && card.placeId !== null && (
<CardOpenButton
target={phone ? { kind: 'map', placeId: card.placeId, dayId: null } : tourTarget(card.placeId)}
labelKey={phone ? 'assistant.card.tour.openPhone' : 'assistant.card.tour.open'}
/>
)}
{take.visible && take.state === 'taken' && (
<SoftPill tone="success" icon={<Check size={10} strokeWidth={2.4} aria-hidden="true" />}>
{take.applied ? t('assistant.card.tour.added') : t('assistant.card.tour.taken')}
</SoftPill>
)}
{take.visible && take.state !== 'taken' && (
<button
type="button"
disabled={take.disabled}
onClick={() => void take.take()}
className={`inline-flex items-center gap-1 rounded-full bg-accent px-3 text-caption font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:cursor-default disabled:opacity-40 ${tall}`}
>
{take.state === 'busy' ? (
<Loader2 size={12} className="animate-spin" aria-hidden="true" />
) : (
<Plus size={12} aria-hidden="true" />
)}
{planLabel}
</button>
)}
</div>
{take.visible && caption && (
<p className="m-0 text-end text-caption text-content-muted" role="status">{caption}</p>
)}
</div>
);
}
@@ -0,0 +1,120 @@
/**
* FE-AI-TOUR-029 to FE-AI-TOUR-032, FE-AI-TOUR-043
*
* The ways of one answer as the planner offers a section's ways: one choice
* per way with how it differs from the chosen one, one profile with the
* others drawn thin, the choice kept per answer and lit on the map, side by
* side where the window is roomy, and any other route as a card of its own;
* the card lit with its chosen line both ways, and "On map" framing them all.
*/
import type { TourRouteCard } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GENTLE, HIKE, SAVED } from '../../../../../tests/helpers/assistantTour';
import { buildTrip, buildUser } from '../../../../../tests/helpers/factories';
import { act, fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
import { useAuthStore } from '../../../../store/authStore';
import { useTripStore } from '../../../../store/tripStore';
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
import TourRouteBlock from './TourRouteBlock';
const TRIP = 4;
const ASSISTANT = useAssistantStore.getState();
const TRIPS = useTripStore.getState();
const AUTH = useAuthStore.getState();
beforeEach(() => {
useAuthStore.setState({ user: buildUser({ id: 1 }) });
useTripStore.setState({ trip: buildTrip({ id: TRIP, user_id: 1 }) });
useAssistantStore.setState({
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 7 } },
});
});
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
useTripStore.setState(TRIPS, true);
useAuthStore.setState(AUTH, true);
});
function renderBlock(cards: { toolCallId: string; card: TourRouteCard }[], view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: TRIP, variant: 'window', density: 'compact', onOpen: vi.fn(), ...view };
return render(
<AssistantViewContext.Provider value={value}>
<TourRouteBlock cards={cards} />
</AssistantViewContext.Provider>
);
}
const WAYS = [
{ toolCallId: 'call_1', card: { ...HIKE, label: 'Short and steep' } },
{ toolCallId: 'call_2', card: GENTLE },
];
describe('TourRouteBlock', () => {
it('FE-AI-TOUR-029: two ways read as one choice, the other way compared with the chosen one on one profile', () => {
const { container } = renderBlock(WAYS);
const group = screen.getByRole('radiogroup', { name: '2 ways' });
const ways = group.querySelectorAll('[role="radio"]');
expect(ways).toHaveLength(2);
expect(ways[0]).toHaveAttribute('aria-checked', 'true');
expect(ways[0]).toHaveAccessibleName('Short and steep');
expect(ways[1]).toHaveAccessibleName('Long and gentle');
expect(ways[1]?.querySelector('[data-testid="leg-variant-delta"]')).not.toBeNull();
expect(ways[0]?.querySelector('[data-testid="leg-variant-delta"]')).toBeNull();
expect(container.querySelector('[data-profile-compare="call_2"]')).not.toBeNull();
expect(screen.getByRole('button', { name: 'Plan way A' })).toBeInTheDocument();
});
it('FE-AI-TOUR-030: choosing a way keeps it for the answer and plans it; pointing at a way lights its line', () => {
renderBlock(WAYS);
const second = screen.getByRole('radio', { name: 'Long and gentle' });
fireEvent.mouseEnter(second);
expect(useAssistantStore.getState().mapLink).toMatchObject({ tripId: TRIP, hoveredId: 'call_2', source: 'card' });
fireEvent.mouseLeave(second);
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
fireEvent.click(second);
expect(chatOf(useAssistantStore.getState(), TRIP).picks).toEqual({ 'tour:call_1': 'call_2' });
expect(second).toHaveAttribute('aria-checked', 'true');
expect(screen.getByRole('button', { name: 'Plan way B' })).toBeInTheDocument();
expect(document.querySelector('[data-profile-compare="call_1"]')).not.toBeNull();
});
it('FE-AI-TOUR-031: the ways stand side by side in a roomy window and stay rows on the phone', () => {
renderBlock(WAYS, { density: 'roomy' });
expect(screen.getByRole('radiogroup')).toHaveClass('grid-cols-2');
renderBlock(WAYS, { density: 'roomy', variant: 'fullscreen' });
expect(screen.getAllByRole('radiogroup')[1]).not.toHaveClass('grid-cols-2');
});
it('FE-AI-TOUR-032: a saved tour beside a proposal, or one proposal alone, is a card of its own', () => {
renderBlock([
{ toolCallId: 'call_1', card: SAVED },
{ toolCallId: 'call_2', card: { ...HIKE, name: 'Wank' } },
]);
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Hochfelln from Bergen' })).toBeInTheDocument();
expect(screen.getByRole('region', { name: 'Wank' })).toBeInTheDocument();
});
it('FE-AI-TOUR-043: resting on the ways card lights the chosen line, a line lit on the map rings it, "On map" frames every way', () => {
const requestCamera = vi.fn();
useAssistantStore.setState({ requestCamera });
const far: TourRouteCard = { ...GENTLE, line: [[47.9, 12.8, 600], ...GENTLE.line.slice(1)] };
renderBlock([WAYS[0] as (typeof WAYS)[number], { toolCallId: 'call_2', card: far }]);
const card = screen.getByRole('region', { name: '2 ways' });
fireEvent.mouseEnter(card);
expect(useAssistantStore.getState().mapLink).toMatchObject({ tripId: TRIP, hoveredId: 'call_1', source: 'card' });
fireEvent.mouseLeave(card);
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
act(() => useAssistantStore.getState().setMapHover(TRIP, 'call_1', 'map'));
expect(card.parentElement).toHaveClass('ring-1');
fireEvent.click(screen.getByRole('button', { name: 'On map' }));
const points = requestCamera.mock.calls[0]?.[1] as [number, number][];
expect(points).toHaveLength(HIKE.line.length + far.line.length);
expect(points).toContainEqual([47.9, 12.8]);
});
});
@@ -0,0 +1,23 @@
import type { CardEntry } from '../cardTypes';
import TourRouteCard from './TourRouteCard';
import { routeEntries, wayGroups } from './tourRouteModel';
import TourWays from './TourWays';
/**
* The routes of one answer: two or three ways of one route proposed together
* as one card to choose from, every other route (a saved tour, a paddle or a
* ski plan, a single proposal) as a card of its own.
*/
export default function TourRouteBlock({ cards }: { cards: readonly CardEntry[] }) {
const groups = wayGroups(routeEntries(cards));
return (
<div className="flex flex-col gap-2">
{groups.map((group) => {
const first = group[0];
if (!first) return null;
if (group.length >= 2) return <TourWays key={first.toolCallId} entries={group} />;
return <TourRouteCard key={first.toolCallId} toolCallId={first.toolCallId} card={first.card} />;
})}
</div>
);
}
@@ -0,0 +1,346 @@
/**
* FE-AI-TOUR-021 to FE-AI-TOUR-028, FE-AI-TOUR-037 to FE-AI-TOUR-042
*
* A route in the chat as the Tours tab shows a hike: its figures and grade,
* a profile only where the heights are known, the profile, the stop chips
* and the map moving one spot, the day with the sun, the hours and the
* warnings, "Plan this" with its gate (the right to add, the thread already
* holding the tour, what came of a take), "Open in Tours" on a saved tour,
* and the phone's readout row, taller buttons and open on the plan.
*/
import type { AssistantMessage, AssistantMessagePart, TourRouteCard as Route } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GENTLE, HIKE, HIKE_LINE, SAVED } from '../../../../../tests/helpers/assistantTour';
import { buildTrip, buildUser } from '../../../../../tests/helpers/factories';
import { act, fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { chatOf, type HoldOutcome, useAssistantStore } from '../../../../store/assistantStore';
import { useAuthStore } from '../../../../store/authStore';
import { usePermissionsStore } from '../../../../store/permissionsStore';
import { useTripStore } from '../../../../store/tripStore';
import { setForcedOffline } from '../../../../sync/networkMode';
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
import TourRouteCard from './TourRouteCard';
const TRIP = 4;
const ASSISTANT = useAssistantStore.getState();
const TRIPS = useTripStore.getState();
const AUTH = useAuthStore.getState();
const PERMISSIONS = usePermissionsStore.getState();
const takeProposal = vi.fn(async (): Promise<HoldOutcome> => 'held');
const requestCamera = vi.fn();
const onRevealMap = vi.fn();
/** A waiting change in another answer, which holds "Plan this" back. */
const UNDECIDED: AssistantMessage = {
id: 'msg-9',
role: 'assistant',
status: 'awaiting_approval',
createdAt: '2026-10-21T08:00:00.000Z',
parts: [{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_place', args: {}, approval: { id: 'w1' } }],
};
/** The answer that proposed the hike under `call_1`, as the thread keeps it. */
function proposed(): AssistantMessage {
return {
id: 'msg-1',
role: 'assistant',
status: 'complete',
createdAt: '2026-10-21T07:00:00.000Z',
parts: [{ type: 'data-trek-card', data: { toolCallId: 'call_1', card: HIKE } }],
};
}
/** The change "Plan this" held for that hike: applied (`true`) or discarded (`false`). */
function heldTake(approved: boolean): AssistantMessage {
const parts: AssistantMessagePart[] = [
{ type: 'tool-call', toolCallId: 't1', toolName: 'take_proposal', args: {}, approval: { id: 't1', approved } },
{
type: 'data-trek-change',
data: {
toolCallId: 't1',
verbKey: 'addTour',
subject: 'Hochfelln from Bergen',
rows: [],
dayId: 22,
placeIds: [],
undoable: true,
card: { ...HIKE, source: 'pending' },
},
},
];
return { id: 'msg-2', role: 'assistant', status: 'complete', createdAt: '2026-10-21T07:05:00.000Z', parts };
}
function seed(messages: AssistantMessage[] = []) {
useAuthStore.setState({ user: buildUser({ id: 1 }) });
useTripStore.setState({ trip: buildTrip({ id: TRIP, user_id: 1 }) });
useAssistantStore.setState({
takeProposal,
requestCamera,
byTrip: { [TRIP]: { ...chatOf(useAssistantStore.getState(), TRIP), threadId: 7, messages } },
});
}
function renderCard(card: Route, view: Partial<AssistantView> = {}) {
const value: AssistantView = {
tripId: TRIP,
variant: 'window',
density: 'compact',
onRevealMap,
onOpen: vi.fn(),
...view,
};
return render(
<AssistantViewContext.Provider value={value}>
<TourRouteCard toolCallId="call_1" card={card} />
</AssistantViewContext.Provider>
);
}
beforeEach(() => {
setForcedOffline(false);
seed();
});
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
useTripStore.setState(TRIPS, true);
useAuthStore.setState(AUTH, true);
usePermissionsStore.setState(PERMISSIONS, true);
setForcedOffline(false);
vi.clearAllMocks();
});
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', () => {
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(screen.getByText('8.2 km')).toBeInTheDocument();
expect(screen.getAllByText('640 m')).toHaveLength(2);
expect(screen.getByText(/^Day 2, /)).toBeInTheDocument();
expect(screen.getByTestId('tour-grade')).toHaveTextContent('T3');
expect(screen.getByTestId('tour-grade')).toHaveAccessibleName(/Highest known: T3 Demanding mountain hiking/);
renderCard({ ...GENTLE, name: 'Gentle way' });
expect(screen.getAllByTestId('tour-grade')[1]).toHaveTextContent('T2?');
});
it('FE-AI-TOUR-022: draws a profile only where every height is known, and says why a route is drawn straight', () => {
renderCard(HIKE);
expect(screen.getByRole('group', { name: 'Elevation profile of Hochfelln from Bergen' })).toBeInTheDocument();
const gap = HIKE_LINE.map((point, index): [number, number, number | null] =>
index === 2 ? [point[0], point[1], null] : point
);
renderCard({ ...HIKE, line: gap });
expect(screen.getByText('No heights for this route, so there is no profile')).toBeInTheDocument();
renderCard({ ...HIKE, routed: false, problem: { why: 'no_way', position: null, snappedM: null } });
expect(screen.getByText('No path within T3 joins these points')).toBeInTheDocument();
expect(screen.getAllByRole('group', { name: /Elevation profile/ })).toHaveLength(1);
renderCard({ ...HIKE, routed: false, limit: null, problem: { why: 'no_way', position: null, snappedM: null } });
expect(screen.getByText('No path joins these points')).toBeInTheDocument();
});
it('FE-AI-TOUR-023: the profile sets the spot in focus for the map, and a spot from the map is read with its clock', () => {
const { container } = renderCard(HIKE);
const profile = screen.getByRole('group', { name: 'Elevation profile of Hochfelln from Bergen' });
fireEvent.focus(profile);
expect(useAssistantStore.getState().routeFocus).toMatchObject({ tripId: TRIP, ref: 'call_1', source: 'card' });
expect(useAssistantStore.getState().routeFocus?.focus.distanceMeters).toBe(0);
const focus = { distanceMeters: 4100, elevationMeters: 1400, lat: 47.795, lng: 12.625, sampleIndex: 5 };
act(() => useAssistantStore.getState().setRouteFocus({ tripId: TRIP, ref: 'call_1', focus, source: 'map' }));
const readout = container.querySelector('[data-profile-focus-readout]');
expect(readout).toHaveTextContent('At 4.1 km: 1400 m, about 09:40, T3 Demanding mountain hiking');
});
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' });
fireEvent.mouseEnter(hut);
expect(useAssistantStore.getState().routeFocus?.focus.distanceMeters).toBeCloseTo(2400, 0);
fireEvent.mouseLeave(hut);
expect(useAssistantStore.getState().routeFocus).toBeNull();
expect(screen.getByRole('button', { name: 'Water, 80 m off the way' })).toBeInTheDocument();
expect(screen.getByRole('list', { name: 'Stops along the way' })).toBeInTheDocument();
});
it('FE-AI-TOUR-025: lays out the day with the sun and the walk, the forecast hours with likely rain, and the warnings', () => {
renderCard(HIKE);
expect(
screen.getByRole('img', {
name: 'Daylight and walking time: Sunrise 07:00, Walking 08:00 to 11:20, Sunset 18:00',
})
).toBeInTheDocument();
const hours = screen.getByRole('list', { name: 'Weather near the highest point' });
expect(hours.querySelectorAll('li')).toHaveLength(4);
expect(hours).toHaveTextContent('40 %');
expect(hours).not.toHaveTextContent('10 %');
expect(screen.getByText('Thunderstorms possible from 11:00')).toBeInTheDocument();
renderCard({ ...HIKE, schedule: { ...(HIKE.schedule as NonNullable<Route['schedule']>), weather: 'climate' } });
expect(screen.getByText('Typical weather for the date, no forecast yet')).toBeInTheDocument();
});
it('FE-AI-TOUR-026: "Plan this" holds the hike below, waits with its reason, and asks to ask again when refused', async () => {
renderCard(HIKE);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Plan this' }));
});
expect(takeProposal).toHaveBeenCalledWith(TRIP, 'call_1');
expect(screen.getByText('Added below for you to confirm')).toBeInTheDocument();
takeProposal.mockImplementationOnce(async () => {
const chat = chatOf(useAssistantStore.getState(), TRIP);
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, error: 'generic' } } });
return 'refused';
});
renderCard({ ...HIKE, name: 'Second' });
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Plan this' }));
});
expect(screen.getByText('This can no longer be planned from here. Ask for it again.')).toBeInTheDocument();
// Any other refusal is the chat's to say: the button is offered again.
takeProposal.mockImplementationOnce(async () => {
const chat = chatOf(useAssistantStore.getState(), TRIP);
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, error: 'too_many_requests' } } });
return 'refused';
});
renderCard({ ...HIKE, name: 'Third' });
const third = screen.getAllByRole('button', { name: 'Plan this' })[1] as HTMLElement;
await act(async () => {
fireEvent.click(third);
});
expect(third).toBeEnabled();
expect(screen.getAllByText('This can no longer be planned from here. Ask for it again.')).toHaveLength(1);
});
it('FE-AI-TOUR-027: "Plan this" is off while offline or while another change waits, and absent where nothing can be taken', () => {
seed([UNDECIDED]);
renderCard(HIKE);
expect(screen.getByRole('button', { name: 'Plan this' })).toBeDisabled();
expect(screen.getByText('Apply or discard the changes above first.')).toBeInTheDocument();
act(() => setForcedOffline(true));
expect(screen.getByText('Offline. The assistant needs internet.')).toBeInTheDocument();
renderCard({ ...HIKE, routed: false, name: 'Straight' });
expect(screen.getAllByRole('button', { name: 'Plan this' })).toHaveLength(1);
});
it('FE-AI-TOUR-028: a saved tour opens in Tours; on the phone the readout is a row, buttons are taller and the map peeks', () => {
const onOpen = vi.fn();
renderCard(SAVED, { onOpen });
fireEvent.click(screen.getByRole('button', { name: /Open in Tours/ }));
expect(onOpen).toHaveBeenCalledWith({ kind: 'tab', tab: 'tour-planner', params: { tour: '41' } });
expect(screen.queryByRole('button', { name: 'Plan this' })).not.toBeInTheDocument();
renderCard({ ...HIKE, name: 'On the phone' }, { variant: 'fullscreen' });
expect(screen.getByText('Slide along the profile to read it')).toBeInTheDocument();
const onMap = screen.getAllByRole('button', { name: 'On map' })[1] as HTMLElement;
expect(onMap).toHaveClass('h-10');
expect(screen.getByRole('button', { name: 'Plan this' })).toHaveClass('h-10');
fireEvent.click(onMap);
expect(onRevealMap).toHaveBeenCalled();
expect(requestCamera).toHaveBeenCalledWith(TRIP, expect.any(Array), 'fit', 'user');
expect(useAssistantStore.getState().routePeek).toEqual({ tripId: TRIP, toolCallId: 'call_1' });
const focus = { distanceMeters: 2400, elevationMeters: 920, lat: 47.78, lng: 12.61, sampleIndex: 2 };
act(() => useAssistantStore.getState().setRouteFocus({ tripId: TRIP, ref: 'call_1', focus, source: 'map' }));
expect(screen.getByTestId('tour-profile-readout')).toHaveTextContent('920 m');
expect(screen.getByTestId('tour-profile-readout')).toHaveTextContent('T2');
});
it('FE-AI-TOUR-037: on the phone a saved tour opens as its place on the plan, since the phone has no Tours tab', () => {
const onOpen = vi.fn();
renderCard(SAVED, { onOpen, variant: 'fullscreen' });
const open = screen.getByRole('button', { name: /Open tour/ });
expect(open).toHaveClass('h-10');
fireEvent.click(open);
expect(onOpen).toHaveBeenCalledWith({ kind: 'map', placeId: 41, dayId: null });
});
it('FE-AI-TOUR-038: "Plan this" is absent for a member who may not add places to the trip', () => {
usePermissionsStore.setState({ permissions: { place_edit: 'trip_owner' } });
useTripStore.setState({ trip: buildTrip({ id: TRIP, user_id: 2 }) });
renderCard(HIKE);
expect(screen.queryByRole('button', { name: 'Plan this' })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'On map' })).toBeInTheDocument();
});
it('FE-AI-TOUR-039: a card drawn again does not offer a tour the thread already holds or applied, only one discarded', () => {
const view = renderCard(HIKE);
expect(screen.getByRole('button', { name: 'Plan this' })).toBeEnabled();
view.unmount();
seed([proposed(), heldTake(true)]);
const again = renderCard(HIKE);
expect(screen.queryByRole('button', { name: 'Plan this' })).not.toBeInTheDocument();
expect(screen.getByText('Added to the trip')).toBeInTheDocument();
again.unmount();
seed([proposed(), heldTake(false)]);
renderCard(HIKE);
expect(screen.getByRole('button', { name: 'Plan this' })).toBeEnabled();
});
it('FE-AI-TOUR-040: what came of a take decides the card, not an error the chat still shows from before', async () => {
const chat = chatOf(useAssistantStore.getState(), TRIP);
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chat, error: 'generic' } } });
renderCard(HIKE);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Plan this' }));
});
expect(screen.getByText('Added below for you to confirm')).toBeInTheDocument();
// Held nothing (a turn started, the thread changed): the button is offered again, nothing said taken.
takeProposal.mockResolvedValueOnce('skipped');
renderCard({ ...HIKE, name: 'Skipped' });
const second = screen.getByRole('button', { name: 'Plan this' });
await act(async () => {
fireEvent.click(second);
});
expect(second).toBeEnabled();
expect(screen.getAllByText('Added below for you to confirm')).toHaveLength(1);
});
it('FE-AI-TOUR-041: says why a route is drawn straight for each reason, and keeps two warnings of one kind', () => {
const straight = (why: NonNullable<Route['problem']>['why'], position: number | null = null): Route => ({
...HIKE,
name: why,
routed: false,
problem: { why, position, snappedM: null },
});
renderCard(straight('point_off_the_way', 2));
expect(screen.getByText('Point 2 is too far from any path')).toBeInTheDocument();
renderCard(straight('ferry_only'));
expect(
screen.getByText('Some stations can only be reached by boat, so the walk is drawn straight')
).toBeInTheDocument();
renderCard(straight('too_long'));
expect(screen.getByText('This way is longer than one hike is planned for')).toBeInTheDocument();
renderCard(straight('not_planned'));
expect(screen.queryByText('The way is planned when the tour is added')).not.toBeInTheDocument();
const rain = [
{ kind: 'rain' as const, at: '10:00', count: null },
{ kind: 'rain' as const, at: '14:00', count: null },
];
renderCard({ ...HIKE, name: 'Wet', warnings: rain });
expect(screen.getByText('Rain likely from 10:00')).toBeInTheDocument();
expect(screen.getByText('Rain likely from 14:00')).toBeInTheDocument();
});
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();
const { container } = renderCard({
...HIKE,
name: 'Early',
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);
});
});
@@ -0,0 +1,76 @@
import type { TourRouteCard as Route } from '@trek/shared';
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 TourRouteActions from './TourRouteActions';
import TourRouteDay from './TourRouteDay';
import TourRouteHead from './TourRouteHead';
import { profileSamples } from './tourRouteModel';
import { TourRouteProblem, TourRouteWarnings } from './TourRouteNotes';
import TourRouteProfile from './TourRouteProfile';
import TourRouteStops from './TourRouteStops';
import { routeName } from './tourRouteWords';
import { useTourRouteCard } from './useTourRouteCard';
/**
* One route in the chat, the way the Tours tab shows a hike: drawn small
* beside its name, its grade and figures, its height profile, its stops along
* the way, its day with the sun and the weather, what to look out for, and
* the ways onward ("On map", "Plan this", "Open in Tours"). Resting on the
* card lights its line on the map; resting on the line lights the card.
*/
/** Everything under a route's head, shared by a single route and the chosen way of a ways card. */
export function TourRouteBody({
toolCallId,
card,
route,
color,
compare,
letter,
}: {
toolCallId: string;
card: Route;
route: ReturnType<typeof useTourRouteCard>;
color: string;
compare?: ReadonlyArray<{ key: string; samples: readonly DistanceIndexedProfileSample[]; color: string }>;
letter?: string;
}) {
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} />
<TourRouteDay card={card} />
<TourRouteWarnings card={card} />
<TourRouteProblem card={card} />
<TourRouteActions toolCallId={toolCallId} card={card} letter={letter} onShow={route.showOnMap} />
</>
);
}
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} />
<TourRouteBody toolCallId={toolCallId} card={card} route={route} color={variantPaintCss('current')} />
</CardFrame>
</div>
);
}
@@ -0,0 +1,115 @@
import type { TourRouteCard, TourRouteSchedule } from '@trek/shared';
import { Clock, Droplet, Sunrise, Sunset } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { SoftPill } from '../../../Planner/planParts';
import { weatherIconFor } from '../../../Weather/weatherIcons';
import { useCardFormat } from '../parts/useCardFormat';
import { type BarSpan, dayBarOf, forecastHours, RAIN_SHOWN_PCT } from './tourRouteDayModel';
/**
* A tour's day in its card: one bar with the daylight and the walk on it
* (the walk in the dark, before sunrise or after sunset, in red), sunrise and
* sunset at its ends, the latest start when no start is set, and the
* forecast hour by hour for the walk near its highest point. A day only a
* climate is known for says so and shows no hours, since those are last
* year's.
*/
/** A stretch of the bar, placed along the reading direction. */
function Span({ span, className }: { span: BarSpan; className: string }) {
return (
<span
aria-hidden="true"
className={`absolute inset-y-0 ${className}`}
style={{ insetInlineStart: `${span.start}%`, width: `${span.size}%` }}
/>
);
}
/** One hour of the forecast: the hour, the sky, the temperature, and the rain when it is likely. */
function HourCell({ hour }: { hour: TourRouteSchedule['hours'][number] }) {
const Icon = weatherIconFor(hour.main);
const rain = hour.rainPct !== null && hour.rainPct >= RAIN_SHOWN_PCT ? hour.rainPct : null;
return (
<li className="flex w-9 flex-col items-center gap-0.5 text-caption tabular-nums text-content-secondary">
<span className="text-content-faint">{String(hour.hour).padStart(2, '0')}</span>
<Icon size={14} strokeWidth={2} aria-hidden="true" />
<span>{`${hour.tempC}°`}</span>
{rain !== null && (
<span className="inline-flex items-center text-info">
<Droplet size={9} strokeWidth={2.4} aria-hidden="true" />
{`${rain} %`}
</span>
)}
</li>
);
}
export default function TourRouteDay({ card }: { card: TourRouteCard }) {
const { t } = useTranslation();
const format = useCardFormat();
const schedule = card.schedule;
if (!schedule) return null;
const bar = dayBarOf(schedule);
const hours = forecastHours(schedule);
const arrive = schedule.arrive ? format.clock(schedule.arrive) : null;
const walk =
schedule.start && arrive
? t('assistant.card.tour.walkTime', {
start: format.clock(schedule.start),
end: schedule.nextDay ? `${arrive} (${t('assistant.card.tour.nextDay')})` : arrive,
})
: null;
const sunrise = schedule.sunrise ? t('assistant.card.tour.sunrise', { time: format.clock(schedule.sunrise) }) : null;
const sunset = schedule.sunset ? t('assistant.card.tour.sunset', { time: format.clock(schedule.sunset) }) : null;
const barLabel = `${t('assistant.card.tour.daylight')}: ${[sunrise, walk, sunset].filter(Boolean).join(', ')}`;
const end = 'inline-flex flex-none items-center gap-1 text-caption tabular-nums text-content-muted';
return (
<div className="mt-2" data-testid="tour-day">
{bar && (
<div className="flex items-center gap-2" role="img" aria-label={barLabel}>
{schedule.sunrise && (
<span className={end}>
<Sunrise size={12} strokeWidth={2} aria-hidden="true" />
{format.clock(schedule.sunrise)}
</span>
)}
<span className="relative h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-surface-tertiary">
{bar.daylight && <Span span={bar.daylight} className="bg-warning-soft" />}
{bar.walk && <Span span={bar.walk} className="bg-accent" />}
{bar.dark.map((span) => (
<Span key={span.start} span={span} className="bg-danger" />
))}
</span>
{schedule.sunset && (
<span className={end}>
{format.clock(schedule.sunset)}
<Sunset size={12} strokeWidth={2} aria-hidden="true" />
</span>
)}
</div>
)}
{!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>
</div>
)}
{schedule.weather === 'climate' && (
<p className="m-0 mt-1 text-caption text-content-faint">{t('assistant.card.tour.climate')}</p>
)}
{hours.length > 0 && (
<ul
aria-label={t('assistant.card.tour.weatherNear')}
className="m-0 mt-1.5 flex list-none flex-wrap gap-x-0.5 gap-y-1 p-0"
>
{hours.map((hour) => (
<HourCell key={hour.hour} hour={hour} />
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,164 @@
import type { TourRouteCard } from '@trek/shared';
import { CalendarDays, Clock, MoveHorizontal, TrendingDown, TrendingUp, type LucideIcon } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
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 RouteSketch from '../parts/RouteSketch';
import { useCardFormat } from '../parts/useCardFormat';
import { type FactKey, factKeysOf, factValue, percentText } from './tourRouteModel';
import { sketchOf } from './tourRouteSketchModel';
import { routeName, SAC_WORDS, timeLabelKey, TOUR_TYPE_WORDS } from './tourRouteWords';
/**
* The head of a route card: the route drawn small beside its name, the
* hardest grade it reaches (or what kind of tour it is), and its figures as
* the white badges every tour card uses, with the day it is planned on.
*/
/** Each figure's glyph and its tooltip's name, as the planner's way cards name them; the time's by its basis. */
const FACT_LOOK: Readonly<Record<FactKey, { Icon: LucideIcon; labelKey: string | null }>> = {
time: { Icon: Clock, labelKey: null },
distance: { Icon: MoveHorizontal, labelKey: 'tours.detail.distance' },
ascent: { Icon: TrendingUp, labelKey: 'tours.detail.ascent' },
descent: { Icon: TrendingDown, labelKey: 'tours.detail.descent' },
};
/** The figures the route's activity states, in its order. */
export function TourRouteFigures({ card }: { card: TourRouteCard }) {
const { t } = useTranslation();
const format = useCardFormat();
const text = (key: FactKey, value: number) => {
if (key === 'time') return formatTourDuration(value);
return key === 'distance' ? format.distance(value / 1000) : format.elevation(value);
};
return (
<>
{factKeysOf(card).map((key) => {
const { Icon, labelKey } = FACT_LOOK[key];
const value = factValue(card, key);
if (value === null) return null;
return (
<Tooltip key={key} label={t(labelKey ?? timeLabelKey(card))}>
<span className="inline-flex" data-figure={key}>
<WhiteBadge icon={<Icon size={10} strokeWidth={2.2} className="flex-none text-content-faint" />}>
{text(key, value)}
</WhiteBadge>
</span>
</Tooltip>
);
})}
</>
);
}
/**
* The grade of a hike, as the hike's own card shows it: the hardest SAC step
* along the way, uncertain when too little of it is graded, with what it
* means, the routing limit and where grades come from in its tooltip. Any
* other tour says what kind it is instead.
*/
export function TourGrade({ card }: { card: TourRouteCard }) {
const { t } = useTranslation();
if (card.activity !== 'hike' || !card.grades) {
const Icon = tourDayRowIcon(card.activity);
return (
<SoftPill icon={<Icon size={10} strokeWidth={2.2} aria-hidden="true" />}>
{t(TOUR_TYPE_WORDS[card.activity].labelKey)}
</SoftPill>
);
}
const { highest, unknownShare } = card.grades;
const uncertain = unknownShare >= 0.5;
const meaning = highest === null ? undefined : SAC_WORDS[highest - 1];
const parts = [
highest !== null && meaning && `${t('tours.hike.sac.highest')}: T${highest} ${t(meaning.labelKey)}`,
unknownShare > 0 && t('assistant.card.tour.sacUnknown', { percent: percentText(unknownShare) }),
card.limit !== null && `${t('tours.hike.sac.routingLimit')}: T${card.limit}`,
t('tours.hike.sac.source'),
].filter((part): part is string => typeof part === 'string');
const label = parts.join('. ');
return (
<Tooltip label={label}>
<span role="img" aria-label={label} className="inline-flex flex-none" data-testid="tour-grade">
<SacStepChip step={highest} uncertain={uncertain} />
</span>
</Tooltip>
);
}
/** The day a route is planned on, as words: "Day 2, Tue 14 Oct", or the date alone. */
function DayPill({ card }: { card: TourRouteCard }) {
const { t } = useTranslation();
const format = useCardFormat();
const schedule = card.schedule;
if (!schedule) return null;
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>;
}
/** The route drawn small: a button that shows it on the map. */
export function TourRouteSketch({
card,
height,
paint = variantPaintCss('current'),
onShow,
}: {
card: TourRouteCard;
height: number;
paint?: string;
onShow?: () => void;
}) {
const { t } = useTranslation();
const { lines, marks } = sketchOf(card, paint);
const name = routeName(card, t);
return (
<RouteSketch
lines={lines}
marks={marks}
height={height}
ariaLabel={t('assistant.card.tour.sketch', { name })}
onActivate={onShow}
/>
);
}
export default function TourRouteHead({
card,
sketchHeight,
onShow,
}: {
card: TourRouteCard;
sketchHeight: number;
onShow: () => void;
}) {
const { t } = useTranslation();
const name = routeName(card, t);
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} />
<DayPill card={card} />
</CardFacts>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
/**
* FE-AI-TOUR-033 to FE-AI-TOUR-034
*
* A held tour change in its row: the route drawn small with its figures and
* grade and a small profile, the kind of tour as the row's tile; a tour whose
* way is planned only once added says so without a profile; a changed route
* says how the new way differs from the one it replaces.
*/
import type { TourRouteCard } from '@trek/shared';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HIKE } from '../../../../../tests/helpers/assistantTour';
import { fireEvent, render, screen } from '../../../../../tests/helpers/render';
import { useAssistantStore } from '../../../../store/assistantStore';
import type { ChangeItem } from '../../assistantParts';
import { type AssistantView, AssistantViewContext } from '../../assistantViewContext';
import TourRouteInRow, { TourRouteLead } from './TourRouteInRow';
const ASSISTANT = useAssistantStore.getState();
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
vi.clearAllMocks();
});
const PENDING: TourRouteCard = { ...HIKE, source: 'pending' };
function item(card: TourRouteCard, state: ChangeItem['state'] = 'pending'): ChangeItem {
return { toolCallId: 'call_7', preview: null, applied: null, state, card };
}
function renderRow(card: TourRouteCard, view: Partial<AssistantView> = {}) {
const value: AssistantView = { tripId: 4, variant: 'window', ...view };
return render(
<AssistantViewContext.Provider value={value}>
<TourRouteLead card={card} item={item(card)} />
<TourRouteInRow card={card} item={item(card)} />
</AssistantViewContext.Provider>
);
}
describe('TourRouteInRow', () => {
it('FE-AI-TOUR-033: draws the route with its figures, grade and a small profile; the sketch shows it on the map', () => {
const requestCamera = vi.fn();
useAssistantStore.setState({ requestCamera });
const { container } = renderRow(PENDING);
expect(container.querySelector('[data-figure="distance"]')).toHaveTextContent('8.2 km');
expect(screen.getByTestId('tour-grade')).toHaveTextContent('T3');
expect(container.querySelectorAll('[data-testid="tour-route-row"] svg path[stroke]').length).toBeGreaterThan(0);
fireEvent.click(screen.getByRole('button', { name: 'Show Hochfelln from Bergen on the map' }));
expect(requestCamera).toHaveBeenCalledWith(4, expect.any(Array), 'fit', 'user');
});
it('FE-AI-TOUR-034: a way planned only once added says so without a profile; a changed way states its difference', () => {
const straight: TourRouteCard = {
...PENDING,
routed: false,
problem: { why: 'not_planned', position: null, snappedM: null },
};
const { container } = renderRow(straight);
expect(screen.getByText('The way is planned when the tour is added')).toBeInTheDocument();
expect(container.querySelector('[data-profile-band]')).toBeNull();
const previous = {
line: HIKE.line.map(([lat, lng]): [number, number] => [lat, lng]),
distanceM: 6600,
ascentM: 700,
minutes: 180,
};
renderRow({ ...PENDING, name: 'Changed', previous });
const delta = screen.getByTestId('leg-variant-delta');
expect(delta).toHaveTextContent('+1.6 km');
expect(delta).toHaveTextContent('+20 min');
});
});
@@ -0,0 +1,76 @@
import type { TourRouteCard } from '@trek/shared';
import { useMemo } from 'react';
import { useTranslation } from '../../../../i18n';
import { useSettingsStore } from '../../../../store/settingsStore';
import ElevationProfile from '../../../shared/ElevationProfile';
import { variantPaintCss } from '../../../Tours/planner/legVariantPaint';
import { VariantDelta } from '../../../Tours/planner/LegVariantsCard';
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 { legVariantOf, previousVariantOf, profileBands, profileSamples } from './tourRouteModel';
import { useTourRouteCard } from './useTourRouteCard';
/**
* A held tour change in its row, and the receipt it turns into: the route
* drawn small with its figures and grade, a small profile under it, and for a
* changed route how the new way differs from the one it replaces (the old
* line faint in the sketch). A tour whose way is planned only once it is
* added is drawn straight and says so. The receipt keeps all of it; "On map"
* and "Open in Tours" sit at the receipt's end.
*/
/** The tile in front of the row: what kind of tour, in the tone of what happens to it. */
export function TourRouteLead({ card, item }: { card: TourRouteCard; item: ChangeItem }) {
const tone = verbLookOf(item.preview?.verbKey ?? 'addTour').tone;
const settled = item.state === 'discarded' || item.state === 'expired';
return <KindTile icon={tourDayRowIcon(card.activity)} tone={tone} size="sm" dimmed={settled} />;
}
export default function TourRouteInRow({ card, item }: { card: TourRouteCard; item: ChangeItem }) {
const { t } = useTranslation();
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
const { showOnMap } = useTourRouteCard(item.toolCallId, card);
const samples = useMemo(() => profileSamples(card), [card]);
const previous = previousVariantOf(card);
const notPlanned = card.problem?.why === 'not_planned';
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')}
/>
)}
</span>
{notPlanned && (
<p className="m-0 mt-1 text-caption text-content-faint">{t('assistant.card.tour.notPlanned')}</p>
)}
</div>
</div>
{!notPlanned && samples.length >= 2 && (
<ElevationProfile
samples={samples}
color={variantPaintCss('current')}
height={36}
gradientId={`assistant-tour-row-${item.toolCallId.replace(/[^\w-]/g, '-')}`}
bands={profileBands(card, samples)}
/>
)}
</div>
);
}
@@ -0,0 +1,55 @@
import type { TourRouteCard } from '@trek/shared';
import { AlertTriangle } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import { SoftPill } from '../../../Planner/planParts';
import { TourNotice } from '../../../Tours/tourParts';
import { useCardFormat } from '../parts/useCardFormat';
import { PROBLEM_LOOK, WARNING_LOOK } from './tourRouteWords';
/**
* What to look out for on a route: its warnings as pills (thunder, rain, the
* dark; legs of a plan that failed), and why a route is drawn straight as a
* notice, the way the Tours tab says it. A held change whose way is planned
* only once applied says so in its row instead.
*/
export function TourRouteWarnings({ card }: { card: TourRouteCard }) {
const { t } = useTranslation();
const format = useCardFormat();
if (card.warnings.length === 0) return null;
return (
<div className="mt-2 flex flex-wrap gap-1" data-testid="tour-warnings">
{card.warnings.map((warning, index) => {
const look = WARNING_LOOK[warning.kind];
return (
<SoftPill
key={`${warning.kind}-${index}`}
tone={look.tone}
icon={<AlertTriangle size={10} strokeWidth={2.2} aria-hidden="true" />}
>
{t(look.labelKey, { time: warning.at ? format.clock(warning.at) : '', count: warning.count ?? 1 })}
</SoftPill>
);
})}
</div>
);
}
export function TourRouteProblem({ card }: { card: TourRouteCard }) {
const { t } = useTranslation();
const problem = card.problem;
if (!problem || problem.why === 'not_planned') return null;
// No path within a limit the card does not know is said without one.
const text =
problem.why === 'no_way' && card.limit === null
? t('assistant.card.tour.problem.no_way_any')
: t(PROBLEM_LOOK[problem.why].labelKey, { grade: card.limit ?? '', point: problem.position ?? 1 });
return (
<div className="mt-2">
<TourNotice tone="warning" icon={<AlertTriangle size={12} strokeWidth={2} aria-hidden="true" />}>
{text}
</TourNotice>
</div>
);
}
@@ -0,0 +1,134 @@
import type { TourRouteCard } from '@trek/shared';
import { Clock, Mountain, MoveHorizontal, TrendingUp } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from '../../../../i18n';
import { useSettingsStore } from '../../../../store/settingsStore';
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../../utils/routeGeometry';
import { WhiteBadge } from '../../../Planner/planParts';
import ElevationProfile from '../../../shared/ElevationProfile';
import { SacStepChip } from '../../../Tours/hike/SacStepChip';
import { profileFocusLabel } from '../../../Tours/tourPresentation';
import { useAssistantView } from '../../assistantViewContext';
import { useCardFormat } from '../parts/useCardFormat';
import { timeAt } from './tourRouteDayModel';
import { gradeAt, profileBands, profileGrades, profileMarks, sacAt, STEEP_GRADE } from './tourRouteModel';
import { routeName, SAC_WORDS } from './tourRouteWords';
/**
* A route's height profile in its card: the hard grades (or else the steep
* stretches) tinted, the named stops and stage ends marked, the other ways of
* an answer drawn thin on the same axes. The spot in focus is the one the map
* and the stop chips move too; its words add the time on the way and the
* grade of the path there, or else how steep it is where it is steep. On
* the phone a finger scrubs along it while the chat still scrolls, and the
* spot is read in a row under it instead of a box over it. A route without
* heights says so instead of drawing a flat line.
*/
interface ProfileProps {
toolCallId: string;
card: TourRouteCard;
samples: DistanceIndexedProfileSample[];
color: string;
focus: RouteProfileFocus | null;
onFocus: (focus: RouteProfileFocus | null) => void;
compare?: ReadonlyArray<{ key: string; samples: readonly DistanceIndexedProfileSample[]; color: string }>;
}
/** What the phone's row under the profile says about the spot in focus. */
function ReadoutRow({
card,
focus,
grade,
sac,
}: {
card: TourRouteCard;
focus: RouteProfileFocus | null;
grade: number | null;
sac: number | null;
}) {
const { t } = useTranslation();
const format = useCardFormat();
if (!focus) return <p className="m-0 mt-1 text-caption text-content-faint">{t('assistant.card.tour.scrubHint')}</p>;
const time = timeAt(card, focus.distanceMeters);
const glyph = 'flex-none text-content-faint';
return (
<div className="mt-1 flex flex-wrap items-center gap-1" aria-live="polite" data-testid="tour-profile-readout">
<WhiteBadge icon={<MoveHorizontal size={10} strokeWidth={2.2} className={glyph} />}>
{format.distance(focus.distanceMeters / 1000)}
</WhiteBadge>
{focus.elevationMeters !== null && (
<WhiteBadge icon={<Mountain size={10} strokeWidth={2.2} className={glyph} />}>
{format.elevation(focus.elevationMeters)}
</WhiteBadge>
)}
{time && (
<WhiteBadge icon={<Clock size={10} strokeWidth={2.2} className={glyph} />}>
{time.clock ? format.clock(time.clock) : format.minutes(time.minutes)}
</WhiteBadge>
)}
{sac !== null && <SacStepChip step={sac} />}
{sac === null && grade !== null && grade >= STEEP_GRADE && (
<WhiteBadge icon={<TrendingUp size={10} strokeWidth={2.2} className={glyph} />}>
{t('assistant.card.tour.steep', { grade: `${Math.round(grade)} %` })}
</WhiteBadge>
)}
</div>
);
}
export default function TourRouteProfile({ toolCallId, card, samples, color, focus, onFocus, compare }: ProfileProps) {
const { t } = useTranslation();
const { variant, density } = useAssistantView();
const format = useCardFormat();
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
const grades = useMemo(() => profileGrades(samples), [samples]);
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>;
}
const phone = variant === 'fullscreen';
const focusGrade = focus ? gradeAt(grades, focus.distanceMeters) : null;
const focusSac = focus ? sacAt(card, focus.distanceMeters) : null;
/** The spot in words: how far, how high, about when, and the grade there, else how steep where it is steep. */
const describe = (spot: RouteProfileFocus): string => {
const time = timeAt(card, spot.distanceMeters);
const text = time
? t('assistant.card.tour.focusTime', {
distance: format.distance(spot.distanceMeters / 1000),
elevation: spot.elevationMeters === null ? '-' : format.elevation(spot.elevationMeters),
time: time.clock ? format.clock(time.clock) : format.minutes(time.minutes),
})
: profileFocusLabel(spot, t, unit);
const sac = sacAt(card, spot.distanceMeters);
const meaning = sac === null ? undefined : SAC_WORDS[sac - 1];
if (meaning) return `${text}, T${sac} ${t(meaning.labelKey)}`;
const grade = gradeAt(grades, spot.distanceMeters);
if (grade === null || grade < STEEP_GRADE) return text;
return `${text}, ${t('assistant.card.tour.steep', { grade: `${Math.round(grade)} %` })}`;
};
return (
<div className="mt-2">
<ElevationProfile
samples={samples}
color={color}
height={phone || density === 'roomy' ? 64 : 52}
gradientId={`assistant-tour-${toolCallId.replace(/[^\w-]/g, '-')}`}
ariaLabel={t('assistant.card.tour.profile', { name: routeName(card, t) })}
focus={focus}
onFocusChange={onFocus}
formatFocus={describe}
bands={bands}
marks={profileMarks(card)}
compare={compare}
readout={phone ? 'none' : 'tooltip'}
keepFocusOnLift={phone}
/>
{phone && <ReadoutRow card={card} focus={focus} grade={focusGrade} sac={focusSac} />}
</div>
);
}
@@ -0,0 +1,164 @@
import type { TourRouteCard, TourRouteStop } from '@trek/shared';
import { AlertTriangle, Flag, MapPin, MountainSnow, type LucideIcon } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
import {
type DistanceIndexedProfileSample,
focusRouteProfileAtDistance,
type RouteProfileFocus,
} from '../../../../utils/routeGeometry';
import { TOUR_POI_LOOKS } from '../../../Map/poiCategories';
import { SoftPill, WhiteBadge } from '../../../Planner/planParts';
import { Tooltip } from '../../../shared/Tooltip';
import { StageEndCorner } from '../../../Tours/stages/StageParts';
import { useCardFormat } from '../parts/useCardFormat';
import { chipStops } from './tourRouteModel';
import { FLAG_LOOK, stopName } from './tourRouteWords';
/**
* The stops of a route as chips in the order they lie along it: the start
* and the end, the huts, summits and stations on the way with their clock,
* the walking time between them faint, and what lies a little off the way
* quieter still. Pointing at a chip or tapping it moves the spot in focus
* there, on the profile and on the map alike. Past eight the middle ones
* fold into a count that names them.
*/
/** A stop's glyph: the flag at either end, the mountain at the highest point, the place's own for a kind. */
function iconOf(stop: TourRouteStop): LucideIcon {
if (stop.kind === 'highest') return MountainSnow;
if (stop.kind && stop.kind !== 'station') return TOUR_POI_LOOKS[stop.kind].Icon;
return stop.role === 'start' || stop.role === 'end' ? Flag : MapPin;
}
/** The spot in focus for a stop: on the profile where it has one, else the stop itself at its distance. */
function focusOf(stop: TourRouteStop, samples: readonly DistanceIndexedProfileSample[]): RouteProfileFocus {
return (
focusRouteProfileAtDistance(samples, stop.atM) ?? {
distanceMeters: stop.atM,
elevationMeters: null,
lat: stop.lat,
lng: stop.lng,
sampleIndex: 0,
}
);
}
function StopChip({
stop,
samples,
onFocus,
}: {
stop: TourRouteStop;
samples: readonly DistanceIndexedProfileSample[];
onFocus: (focus: RouteProfileFocus | null) => void;
}) {
const { t } = useTranslation();
const format = useCardFormat();
const Icon = iconOf(stop);
const name = stopName(stop, t);
const along = stop.role === 'along';
const off =
along && stop.offM !== null
? t('assistant.card.tour.alongOff', { distance: format.distance(stop.offM / 1000) })
: 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 = <Icon 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>}
</>
);
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>
);
}
/** The walking time from one chip to the next, faint between them; nothing where it is not known. */
function Between({ from, to }: { from: TourRouteStop; to: TourRouteStop }) {
const format = useCardFormat();
if (from.minutes === null || to.minutes === null || to.minutes <= from.minutes) return null;
return (
<span className="text-caption tabular-nums text-content-faint">{format.minutes(to.minutes - from.minutes)}</span>
);
}
export default function TourRouteStops({
card,
samples,
onFocus,
}: {
card: TourRouteCard;
samples: readonly DistanceIndexedProfileSample[];
onFocus: (focus: RouteProfileFocus | null) => void;
}) {
const { t } = useTranslation();
const { head, folded, tail } = chipStops(card);
if (head.length === 0) return null;
const chip = (stop: TourRouteStop, index: number, list: readonly TourRouteStop[]) => {
// The time is walked from the last chip that has one: a place beside the way has none.
const before = list
.slice(0, index)
.reverse()
.find((other) => other.minutes !== null);
return (
<span className="inline-flex items-center gap-1.5">
{before && <Between from={before} to={stop} />}
<StopChip stop={stop} samples={samples} onFocus={onFocus} />
</span>
);
};
const names = folded.map((stop) => stopName(stop, t)).join(', ');
return (
<div className="mt-2 flex flex-wrap items-center gap-1.5" role="list" aria-label={t('assistant.card.tour.stops')}>
{head.map((stop, index) => (
<span key={`h-${index}`} role="listitem" className="inline-flex">
{chip(stop, index, head)}
</span>
))}
{folded.length > 0 && (
<span role="listitem" className="inline-flex">
<Tooltip label={names}>
<span className="inline-flex">
<WhiteBadge>
{t('assistant.card.tour.stopsMore', { count: folded.length })}
<span className="sr-only">{`: ${names}`}</span>
</WhiteBadge>
</span>
</Tooltip>
</span>
)}
{tail.map((stop, index) => (
<span key={`t-${index}`} role="listitem" className="inline-flex">
{chip(stop, index, tail)}
</span>
))}
</div>
);
}
@@ -0,0 +1,121 @@
import { useMemo } from 'react';
import { useTranslation } from '../../../../i18n';
import { chatOf, useAssistantStore } from '../../../../store/assistantStore';
import { useSettingsStore } from '../../../../store/settingsStore';
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 { useAssistantView } from '../../assistantViewContext';
import CardFrame from '../parts/CardFrame';
import { TourRouteBody } from './TourRouteCard';
import { legVariantOf, profileSamples, type RouteEntry, WAY_KEYS } from './tourRouteModel';
import { useLineHover, useTourRouteCard } from './useTourRouteCard';
/** The ways of one answer with the chosen one known: its choices, then the chosen way's body. */
function WaysCard({
entries,
chosen,
chosenIndex,
blockKey,
}: {
entries: readonly RouteEntry[];
chosen: RouteEntry;
chosenIndex: number;
blockKey: string;
}) {
const { t } = useTranslation();
const { tripId, variant, density } = useAssistantView();
const unit = useSettingsStore((s) => s.settings.distance_unit ?? 'metric');
const pick = useAssistantStore((s) => s.pick);
const hover = useLineHover();
const all = useMemo(() => entries.map((entry) => entry.card), [entries]);
const route = useTourRouteCard(chosen.toolCallId, chosen.card, all);
const chosenKey = WAY_KEYS[chosenIndex] ?? 'a';
const compare = useMemo(
() =>
entries.flatMap((entry, index) => {
const samples = profileSamples(entry.card);
if (index === chosenIndex || samples.length < 2) return [];
return [{ key: entry.toolCallId, samples, color: variantPaintCss(WAY_KEYS[index] ?? 'a') }];
}),
[entries, chosenIndex]
);
const current = 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 });
return (
<div {...route.handlers} className={`rounded-xl ${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
key={entry.toolCallId}
way={way}
name={entry.card.label ?? t('tours.variants.option', { letter })}
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} />}
</>
}
onSelect={() => pick(tripId, blockKey, entry.toolCallId)}
onHover={(on) => hover(entry.toolCallId, on)}
>
<VariantFigures variant={way} unit={unit} t={t} />
</WayChoice>
);
})}
</div>
<TourRouteBody
toolCallId={chosen.toolCallId}
card={chosen.card}
route={route}
color={variantPaintCss(chosenKey)}
compare={compare}
letter={chosenKey.toUpperCase()}
/>
</CardFrame>
</div>
);
}
/**
* 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.
*/
export default function TourWays({ entries }: { entries: readonly RouteEntry[] }) {
const { tripId } = useAssistantView();
const blockKey = `tour:${entries[0]?.toolCallId ?? ''}`;
const picked = useAssistantStore((s) => chatOf(s, tripId).picks[blockKey] ?? null);
const index = entries.findIndex((entry) => entry.toolCallId === picked);
const chosenIndex = index < 0 ? 0 : index;
const chosen = entries[chosenIndex];
if (!chosen) return null;
return <WaysCard entries={entries} chosen={chosen} chosenIndex={chosenIndex} blockKey={blockKey} />;
}
@@ -0,0 +1,90 @@
/**
* FE-AI-TOUR-017 to FE-AI-TOUR-020
*
* The tours' part of the chat's pure look: the verbs drawn with the route
* icon, the reading steps named, an applied tour shown by its line and opened
* in the Tours tab (on the phone, which has none, by its pin), proposals of
* one activity grouped as ways, and the lines the plan's map draws for a
* route and for the way a change replaces.
*/
import type { AssistantApplied } from '@trek/shared';
import { Route } from 'lucide-react';
import { describe, expect, it } from 'vitest';
import { GENTLE, HIKE, HIKE_LINE, SAVED } from '../../../../../tests/helpers/assistantTour';
import { CARD_MAP_LOOK, CARD_RULES, ENTITY_LOOK, STEP_LOOKS, VERB_LOOK } from '../cardLooks';
import { tourRouteLines } from './tourLook';
const applied = (ids: number[]): AssistantApplied => ({
toolCallId: 'c1',
entity: 'tour',
ids,
dayId: 3,
undoUntil: null,
});
describe('tourLook', () => {
it('FE-AI-TOUR-017: adding and changing a tour wear the route icon, and the tour reads have their steps', () => {
expect(VERB_LOOK.addTour).toMatchObject({ Icon: Route, tone: 'success' });
expect(VERB_LOOK.updateTour).toMatchObject({ Icon: Route, tone: 'info' });
expect(STEP_LOOKS.propose_city_walk?.stepKey).toBe('assistant.step.planningWalk');
expect(STEP_LOOKS.plan_ski_route?.stepKey).toBe('assistant.step.planningRoute');
expect(STEP_LOOKS.find_tours_for_day?.stepKey).toBe('assistant.step.readingTours');
});
it('FE-AI-TOUR-018: an applied tour shows its line on the plan and opens in the Tours tab, on the phone by its pin alone', () => {
const tour = ENTITY_LOOK.tour;
expect(tour.openLabelKey).toBe('assistant.card.tour.open');
expect(tour.phoneOpen?.(applied([41]), null)).toBeNull();
expect(tour.onMap?.(applied([41]), null)).toEqual({ placeId: 41, dayId: null });
expect(tour.open?.(applied([41]), null)).toEqual({ kind: 'tab', tab: 'tour-planner', params: { tour: '41' } });
expect(tour.onMap?.(applied([]), SAVED)).toEqual({ placeId: 41, dayId: null });
expect(tour.onMap?.(applied([]), null)).toBeNull();
expect(tour.open?.(applied([]), HIKE)).toBeNull();
});
it('FE-AI-TOUR-019: proposals of one activity share a group, so an answer shows them as ways', () => {
expect(CARD_RULES.group(HIKE)).toBe('tourRoute:ways:hike');
expect(CARD_RULES.group(GENTLE)).toBe('tourRoute:ways:hike');
expect(CARD_RULES.group({ ...HIKE, activity: 'walk' })).toBe('tourRoute:ways:walk');
expect(CARD_RULES.group(SAVED)).toBe('tourRoute');
});
it('FE-AI-TOUR-020: the map draws the route with its named stops and hard grades, dashed while straight, the replaced way faint, and no pin over a routed way', () => {
expect(CARD_MAP_LOOK.lines(HIKE, 'look')).toEqual([
{
id: 'route',
lines: [HIKE_LINE.map(([lat, lng]) => [lat, lng])],
color: 'var(--hike-variant-current)',
casing: 'var(--bike-highlight-casing)',
dashed: false,
stops: [
{ lat: 47.77, lng: 12.59, label: 'Bergen' },
{ lat: 47.78, lng: 12.59, label: 'Strausshütte' },
{ lat: 47.795, lng: 12.59, label: 'Hochfelln' },
{ lat: 47.77, lng: 12.59, label: 'Bergen' },
],
tints: [
{
line: [
[47.79, 12.62],
[47.795, 12.625],
],
color: 'var(--hike-sac-t3)',
},
],
},
]);
expect(tourRouteLines({ ...HIKE, routed: false }, 'pending')[0]?.dashed).toBe(true);
expect(tourRouteLines({ ...HIKE, routed: false }, 'pending')[0]?.tints).toBeUndefined();
expect(tourRouteLines(HIKE, 'before')).toEqual([]);
const line = HIKE_LINE.map(([lat, lng]): [number, number] => [lat, lng]);
const previous = { line, distanceM: 1, ascentM: null, minutes: null };
expect(tourRouteLines({ ...HIKE, previous }, 'before')).toEqual([
{ id: 'previous', lines: [line], color: 'var(--text-faint)', casing: 'var(--bike-highlight-casing)', dim: true },
]);
expect(tourRouteLines({ ...HIKE, line: [] }, 'look')).toEqual([]);
expect(CARD_MAP_LOOK.hidesEffectGhost(HIKE)).toBe(true);
expect(CARD_MAP_LOOK.hidesEffectGhost({ ...HIKE, routed: false })).toBe(false);
});
});
@@ -1,23 +1,132 @@
import { type AssistantApplied, cumulativeMetres, type TourRouteCard, type TrekCard } from '@trek/shared';
import { Route } from 'lucide-react';
import type { PackLook } from '../cardTypes';
import type { RouteLineSpec, RouteUse } from '../../../../types/assistantMap';
import type { EntityTarget, PackLook } from '../cardTypes';
/**
* What the tours bring to the chat's pure look: the verbs, tool names and steps
* they add, what an applied change of theirs opens, and the look of each of
* their kinds. An applied tour has no way to open yet, as before. A look stays
* pure, icons and literals only: the chat's main chunk reads every look, so
* whatever a look imports ships with it.
* What the tours bring to the chat's pure look: the verbs of their changes,
* the names of their reading steps, what an applied tour opens (its line on
* the plan, the tour in the Tours tab) and the look of each of their kinds:
* how an answer groups the ways it proposed, what to ask next and the lines
* the plan's map draws for a route. A look stays pure, icons and literals
* only: the chat's main chunk reads every look, so whatever a look imports
* ships with it.
*/
/** The colours a route is drawn in on the plan, the planner's own for the way taken now (styles/hike.css). */
const ROUTE_PAINT = { line: 'var(--hike-variant-current)', casing: 'var(--bike-highlight-casing)' } as const;
/** The tour a change or a card is about: the applied change's id, else the saved tour the card shows. */
function tourIdOf(applied: AssistantApplied, card: TrekCard | null): number | null {
return applied.ids[0] ?? (card?.kind === 'tourRoute' ? card.placeId : null);
}
/** A saved tour opened in the Tours tab; the planner takes `?tour=` once and opens it. The phone has no such tab. */
export function tourTarget(placeId: number): EntityTarget {
return { kind: 'tab', tab: 'tour-planner', params: { tour: String(placeId) } };
}
/**
* What the plan's map draws for a route: the way itself, dashed while it is
* only its stops joined straight; for the way a held change replaces, the old
* line faint beside it.
*/
export function tourRouteLines(card: TourRouteCard, use: RouteUse): RouteLineSpec[] {
if (use === 'before') {
if (!card.previous) return [];
const lines = [card.previous.line];
return [{ id: 'previous', lines, color: 'var(--text-faint)', casing: ROUTE_PAINT.casing, dim: true }];
}
if (card.line.length < 2) return [];
const line = card.line.map(([lat, lng]): [number, number] => [lat, lng]);
const spec: RouteLineSpec = {
id: 'route',
lines: [line],
color: ROUTE_PAINT.line,
casing: ROUTE_PAINT.casing,
dashed: !card.routed,
};
const stops = card.stops.flatMap((stop) => (stop.name ? [{ lat: stop.lat, lng: stop.lng, label: stop.name }] : []));
const tints = card.routed ? hardStretches(card, line) : [];
if (stops.length > 0) spec.stops = stops;
if (tints.length > 0) spec.tints = tints;
return [spec];
}
/** The hard grades' colours (styles/hike.css), from T3 on: the easy ones stay the way's own colour. */
const HARD_GRADE_PAINT: Readonly<Record<number, string>> = {
3: 'var(--hike-sac-t3)',
4: 'var(--hike-sac-t4)',
5: 'var(--hike-sac-t5)',
6: 'var(--hike-sac-t6)',
};
/**
* The stretches graded T3 or harder, cut out of the line by how far along
* it they lie (scaled to the route's own length, as the card measures its
* stops), each in its grade's colour.
*/
function hardStretches(card: TourRouteCard, line: [number, number][]): NonNullable<RouteLineSpec['tints']> {
const along = cumulativeMetres(card.line);
const total = along[along.length - 1] ?? 0;
const scale = card.distanceM !== null && total > 0 ? card.distanceM / total : 1;
return card.stretches.flatMap((stretch) => {
const color = stretch.facet === 'sac' ? HARD_GRADE_PAINT[stretch.step] : undefined;
if (!color) return [];
const part = line.filter((_, index) => {
const at = (along[index] ?? 0) * scale;
return at >= stretch.fromM && at <= stretch.toM;
});
return part.length >= 2 ? [{ line: part, color }] : [];
});
}
export const TOUR_LOOK = {
verbs: {},
verbs: {
addTour: { Icon: Route, tone: 'success', labelKey: 'assistant.change.verb.addTour' },
updateTour: { Icon: Route, tone: 'info', labelKey: 'assistant.change.verb.updateTour' },
},
tools: {},
steps: {},
steps: {
propose_city_walk: { stepKey: 'assistant.step.planningWalk' },
plan_paddle_route: { stepKey: 'assistant.step.planningRoute' },
plan_ski_route: { stepKey: 'assistant.step.planningRoute' },
get_tour_plan: { stepKey: 'assistant.step.readingTour' },
list_tours: { stepKey: 'assistant.step.readingTours' },
find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
search_tour_pois: { stepKey: 'assistant.step.tourPois' },
},
entities: {
tour: { Icon: Route },
tour: {
Icon: Route,
openLabelKey: 'assistant.card.tour.open',
onMap: (applied, card) => {
const placeId = tourIdOf(applied, card);
return placeId === null ? null : { placeId, dayId: null };
},
open: (applied, card) => {
const placeId = tourIdOf(applied, card);
return placeId === null ? null : tourTarget(placeId);
},
// The phone opens a tour as its place on the plan, which the map pin beside it already does.
phoneOpen: () => null,
},
},
cards: {
tourRoute: {},
tourList: {},
// Two or three ways of one activity proposed in one answer read as one card to choose from.
tourRoute: {
group: (card) => (card.source === 'proposal' ? `tourRoute:ways:${card.activity}` : 'tourRoute'),
followUps: [
{ id: 'tourEasier', labelKey: 'assistant.followUp.tourEasier' },
{ id: 'tourPacking', labelKey: 'assistant.followUp.tourPacking' },
],
lines: tourRouteLines,
// A routed way draws its own line and stops: the pin of the place it adds would only cover its start.
hidesEffectGhost: (card) => card.routed && card.line.length >= 2,
},
tourList: {
followUps: [{ id: 'tourFit', labelKey: 'assistant.followUp.tourFit' }],
},
},
} satisfies PackLook<'tourRoute' | 'tourList'>;
@@ -0,0 +1,50 @@
/**
* FE-AI-TOUR-013 to FE-AI-TOUR-016
*
* A tour's day on its card: the daylight and the walk on one bar, the walk
* in the dark marked apart, an arrival past midnight on the next day, the
* forecast's hours only for a forecast, and the time on the way at a spot.
*/
import type { TourRouteSchedule } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { HIKE, SAVED } from '../../../../../tests/helpers/assistantTour';
import { clockAfter, clockMinutes, dayBarOf, forecastHours, timeAt } from './tourRouteDayModel';
const DAY = HIKE.schedule as TourRouteSchedule;
describe('tourRouteDayModel', () => {
it('FE-AI-TOUR-013: lays the daylight and the walk on the hours from one before sunrise to one after sunset', () => {
expect(dayBarOf(DAY)).toEqual({
daylight: { start: 7.69, size: 84.62 },
walk: { start: 15.38, size: 25.64 },
dark: [],
});
});
it('FE-AI-TOUR-014: marks the walk before sunrise and after sunset, and puts an arrival past midnight on the next day', () => {
const long = { ...DAY, start: '06:00', arrive: '19:00' };
expect(dayBarOf(long)?.dark).toEqual([
{ start: 6.67, size: 6.67 },
{ start: 86.67, size: 6.67 },
]);
const night = { ...DAY, start: '22:00', arrive: '01:30', nextDay: true, sunrise: null, sunset: null };
expect(dayBarOf(night)).toEqual({ daylight: null, walk: { start: 16.67, size: 58.33 }, dark: [] });
expect(dayBarOf({ ...night, start: null, arrive: null })).toBeNull();
});
it('FE-AI-TOUR-015: shows the hours of a forecast only, never last year’s climate hours', () => {
expect(forecastHours(DAY)).toHaveLength(4);
expect(forecastHours({ ...DAY, weather: 'climate' })).toEqual([]);
expect(forecastHours({ ...DAY, weather: null })).toEqual([]);
});
it('FE-AI-TOUR-016: the time at a spot is shared out by distance, as a clock when the day has a start', () => {
expect(clockMinutes('08:05')).toBe(485);
expect(clockAfter('23:30', 45)).toBe('00:15');
expect(timeAt(HIKE, 4100)).toEqual({ clock: '09:40', minutes: 100 });
expect(timeAt(HIKE, 99_999)).toEqual({ clock: '11:20', minutes: 200 });
expect(timeAt(SAVED, 4100)).toEqual({ clock: null, minutes: 100 });
expect(timeAt({ ...HIKE, minutes: null }, 4100)).toBeNull();
});
});
@@ -0,0 +1,85 @@
import type { TourRouteCard, TourRouteSchedule } from '@trek/shared';
/**
* A tour's day as its card draws it, pure: where the daylight and the walk
* lie on one bar (and the parts of the walk out in the dark), which hours of
* the forecast to show, and the time on the way at a spot of it.
*/
const DAY_MINUTES = 24 * 60;
/** From which likelihood of rain an hour shows its drop. */
export const RAIN_SHOWN_PCT = 30;
/** Minutes after midnight of an `HH:MM` clock. */
export function clockMinutes(clock: string): number {
const [hours, minutes] = clock.split(':').map(Number);
return (hours ?? 0) * 60 + (minutes ?? 0);
}
/** The `HH:MM` clock `minutes` after `clock`, past midnight round again. */
export function clockAfter(clock: string, minutes: number): string {
const total = (((clockMinutes(clock) + Math.round(minutes)) % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES;
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
}
/** A stretch of the bar, as shares of its length in percent. */
export interface BarSpan {
start: number;
size: number;
}
/** The day's bar: the daylight, the walk, the parts of the walk in the dark, each placed on the hours shown. */
interface DayBar {
daylight: BarSpan | null;
walk: BarSpan | null;
dark: BarSpan[];
}
const round = (value: number) => Math.round(value * 100) / 100;
/**
* The bar of a scheduled day, from an hour before the first thing it shows
* to an hour after the last (the sun, the start, the arrival), so the walk
* reads against the daylight. An arrival past midnight lies on the next day.
* Null when the day knows neither the sun nor a start.
*/
export function dayBarOf(schedule: TourRouteSchedule): DayBar | null {
const sunrise = schedule.sunrise ? clockMinutes(schedule.sunrise) : null;
const sunset = schedule.sunset ? clockMinutes(schedule.sunset) : null;
const start = schedule.start ? clockMinutes(schedule.start) : null;
let arrive = schedule.arrive ? clockMinutes(schedule.arrive) : null;
if (start !== null && arrive !== null && (schedule.nextDay || arrive < start)) arrive += DAY_MINUTES;
const known = [sunrise, sunset, start, arrive].filter((value): value is number => value !== null);
if (known.length === 0) return null;
const from = Math.max(0, Math.floor(Math.min(...known) / 60) * 60 - 60);
const to = Math.min(2 * DAY_MINUTES, Math.ceil(Math.max(...known) / 60) * 60 + 60);
const span = (a: number, b: number): BarSpan => ({
start: round(((a - from) / (to - from)) * 100),
size: round(((b - a) / (to - from)) * 100),
});
const daylight = sunrise !== null && sunset !== null && sunset > sunrise ? span(sunrise, sunset) : null;
const walk = start !== null && arrive !== null && arrive > start ? span(start, arrive) : null;
const dark: BarSpan[] = [];
if (walk && start !== null && arrive !== null && sunrise !== null && sunset !== null) {
if (start < sunrise) dark.push(span(start, Math.min(arrive, sunrise)));
if (arrive > sunset) dark.push(span(Math.max(start, sunset), arrive));
}
return { daylight, walk, dark };
}
/** The forecast's hours to show as cells: only a forecast's, since a climate's are last year's. */
export function forecastHours(schedule: TourRouteSchedule): TourRouteSchedule['hours'] {
return schedule.weather === 'forecast' ? schedule.hours : [];
}
/**
* The time on the way at a spot along it, shared out by distance: the clock
* when the day has a start, else the minutes since setting off. Null for a
* route without a time or a length.
*/
export function timeAt(card: TourRouteCard, atM: number): { clock: string | null; minutes: number } | null {
if (card.minutes === null || !card.distanceM) return null;
const minutes = Math.round(card.minutes * Math.min(1, Math.max(0, atM / card.distanceM)));
const start = card.schedule?.start ?? null;
return { clock: start ? clockAfter(start, minutes) : null, minutes };
}
@@ -0,0 +1,212 @@
/**
* FE-AI-TOUR-001 to FE-AI-TOUR-012, FE-AI-TOUR-045 to FE-AI-TOUR-046
*
* A tour card's numbers: the profile scaled to the route's own length, the
* hard grades tinted before the steep stretches, the stops worth a chip, the
* figures each activity states, a way as the planner's way cards read one,
* what "Plan this" can take, how an answer's routes are grouped, the words of
* a stop and of a route's time, the grade under a spot and the sketch's lines
* and marks.
*/
import type { TourRouteCard, TourRouteStop } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { GENTLE, HIKE, HIKE_LINE, SAVED, TOUR_LIST, tourStop } from '../../../../../tests/helpers/assistantTour';
import {
canTake,
chipStops,
factKeysOf,
gradeAt,
legVariantOf,
percentText,
previousVariantOf,
profileBands,
profileGrades,
profileMarks,
profileSamples,
routeEntries,
routePoints,
sacAt,
sacPaint,
steepStretches,
wayGroups,
} from './tourRouteModel';
import { sketchOf } from './tourRouteSketchModel';
import { routeName, stopName, timeLabelKey } from './tourRouteWords';
/** A kilometre of way, flat, then 40 % steep from 300 m to 700 m, then flat again. */
const RAMP = [0, 0, 0, 0, 40, 80, 120, 160, 160, 160, 160].map((elevationMeters, index) => ({
distanceMeters: index * 100,
elevationMeters,
lat: 47,
lng: 11 + index * 0.001,
}));
/** The way a held change replaces, in brief. */
const PREVIOUS_LINE: [number, number][] = [
[47.7, 12.5],
[47.8, 12.6],
];
const named = (name: string, atM: number): TourRouteStop => tourStop({ name, atM });
const t = (key: string) => key;
describe('tourRouteModel', () => {
it('FE-AI-TOUR-001: the profile follows the line, scaled so it ends at the route length the card states', () => {
const samples = profileSamples(HIKE);
expect(samples).toHaveLength(HIKE_LINE.length);
expect(samples[0]).toEqual({ distanceMeters: 0, elevationMeters: 600, lat: 47.77, lng: 12.59 });
expect(samples[samples.length - 1]?.distanceMeters).toBeCloseTo(8200, 6);
expect(samples[4]?.distanceMeters).toBeCloseTo(3132.1, 0);
expect(samples.map((sample) => sample.elevationMeters)).toEqual(HIKE_LINE.map((point) => point[2]));
});
it('FE-AI-TOUR-002: no profile for a route joined straight or one where a single height is missing', () => {
expect(profileSamples({ ...HIKE, routed: false })).toEqual([]);
const gap = HIKE_LINE.map((point, index): [number, number, number | null] =>
index === 3 ? [point[0], point[1], null] : point
);
expect(profileSamples({ ...HIKE, line: gap })).toEqual([]);
});
it('FE-AI-TOUR-003: tints the T3 and harder stretches in their SAC colour, and the steep ones only without them', () => {
expect(profileBands(HIKE, RAMP)).toEqual([{ fromM: 3000, toM: 4100, color: 'var(--hike-sac-t3)' }]);
expect(profileBands(GENTLE, RAMP)).toEqual([{ fromM: 300, toM: 700, color: 'var(--warning)' }]);
expect(sacPaint(5)).toBe('var(--hike-sac-t5)');
});
it('FE-AI-TOUR-004: finds the steep stretches over 27 % and reads the grade at a spot of the profile', () => {
expect(steepStretches(RAMP)).toEqual([{ fromM: 300, toM: 700 }]);
const grades = profileGrades(RAMP);
expect(gradeAt(grades, 450)).toBe(40);
expect(gradeAt(grades, 50)).toBe(0);
expect(gradeAt(grades, 2000)).toBeNull();
// A ramp shorter than 100 m is no steep stretch.
const short = RAMP.map((sample) => ({ ...sample, distanceMeters: sample.distanceMeters / 20 }));
expect(steepStretches(short)).toEqual([]);
});
it('FE-AI-TOUR-005: marks the named stops and the stage ends across the profile, six at most', () => {
expect(profileMarks(HIKE)).toEqual([2400, 4100]);
const many = Array.from({ length: 9 }, (_, index) => named(`Hut ${index}`, index * 100));
expect(profileMarks({ ...HIKE, stops: many })).toHaveLength(6);
expect(profileMarks({ ...HIKE, stops: [tourStop({ role: 'end', atM: 900, stageEnd: true })] })).toEqual([900]);
});
it('FE-AI-TOUR-006: a chip for every stop but a shaping point, the middle folded past eight', () => {
const shaping = tourStop({ role: 'via', atM: 50 });
expect(chipStops({ ...HIKE, stops: [...HIKE.stops, shaping] }).head).toHaveLength(5);
const vias = Array.from({ length: 10 }, (_, index) => named(`Station ${index}`, (index + 1) * 100));
const stops = [tourStop({ role: 'start' }), ...vias, tourStop({ role: 'end', atM: 2000 })];
const { head, folded, tail } = chipStops({ ...HIKE, stops });
expect(head.map((stop) => stop.name ?? stop.role)).toEqual(['start', 'Station 0', 'Station 1', 'Station 2']);
expect(folded).toHaveLength(6);
expect(tail.map((stop) => stop.name ?? stop.role)).toEqual(['Station 9', 'end']);
});
it('FE-AI-TOUR-007: each activity states its own figures, leaving out what the route does not know', () => {
expect(factKeysOf(HIKE)).toEqual(['time', 'distance', 'ascent', 'descent']);
expect(factKeysOf({ ...HIKE, activity: 'ski' })).toEqual(['time', 'distance', 'descent']);
const paddle: TourRouteCard = { ...HIKE, activity: 'kayak', minutes: null, ascentM: null, descentM: null };
expect(factKeysOf(paddle)).toEqual(['distance']);
expect(percentText(0.4)).toBe('40 %');
});
it('FE-AI-TOUR-008: reads a route and the way a change replaces as the planner reads its ways', () => {
expect(legVariantOf(HIKE, 'b')).toEqual({
key: 'b',
current: false,
coordinates: HIKE_LINE.map(([lat, lng]) => [lat, lng]),
distanceM: 8200,
gainM: 640,
lossM: 640,
minutes: 200,
heights: 'known',
});
expect(legVariantOf({ ...HIKE, ascentM: null }, 'current').heights).toBe('unknown');
expect(previousVariantOf(HIKE)).toBeNull();
const previous = { line: PREVIOUS_LINE, distanceM: 7000, ascentM: 500, minutes: 180 };
expect(previousVariantOf({ ...HIKE, previous })).toMatchObject({
key: 'current',
distanceM: 7000,
gainM: 500,
lossM: null,
minutes: 180,
heights: 'known',
});
});
it('FE-AI-TOUR-009: "Plan this" takes a planned hike or a city walk, nothing else', () => {
expect(canTake(HIKE)).toBe(true);
expect(canTake({ ...HIKE, routed: false })).toBe(false);
expect(canTake({ ...HIKE, activity: 'walk', routed: false })).toBe(true);
expect(canTake(SAVED)).toBe(false);
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(false);
expect(canTake({ ...HIKE, source: 'pending' })).toBe(false);
});
it('FE-AI-TOUR-010: proposals of one answer form ways of three at most; anything else stands alone', () => {
const entry = (toolCallId: string, card: TourRouteCard) => ({ toolCallId, card });
const routes = routeEntries([entry('a', HIKE), { toolCallId: 'list', card: TOUR_LIST }, entry('b', GENTLE)]);
expect(routes.map((route) => route.toolCallId)).toEqual(['a', 'b']);
expect(wayGroups(routes).map((group) => group.length)).toEqual([2]);
const four = ['a', 'b', 'c', 'd'].map((id) => entry(id, HIKE));
expect(wayGroups(four).map((group) => group.length)).toEqual([3, 1]);
expect(wayGroups([entry('s', SAVED), entry('h', HIKE)]).map((group) => group.length)).toEqual([1, 1]);
expect(routePoints(HIKE)).toHaveLength(HIKE_LINE.length);
expect(routePoints({ ...HIKE, line: [] })).toEqual(HIKE.stops.map((stop) => [stop.lat, stop.lng]));
});
it('FE-AI-TOUR-011: a stop without a name says what it is or where it lies; a walk without a name is its kind', () => {
expect(stopName(named('Strausshütte', 10), t)).toBe('Strausshütte');
expect(stopName(tourStop({ kind: 'shelter' }), t)).toBe('assistant.card.tour.stop.shelter');
expect(stopName(tourStop({ role: 'start' }), t)).toBe('assistant.card.tour.stop.start');
expect(stopName(tourStop({ kind: 'station' }), t)).toBe('assistant.card.tour.stop.other');
expect(stopName(tourStop({ kind: 'cafe' }), t)).toBe('assistant.card.tour.stop.other');
expect(routeName({ name: null, activity: 'walk' }, t)).toBe('tourTypes.walk');
expect(routeName(HIKE, t)).toBe('Hochfelln from Bergen');
});
it('FE-AI-TOUR-012: the sketch tints the hard stretch, dashes a straight route and draws one disc for a loop', () => {
const { lines, marks } = sketchOf(HIKE, 'var(--hike-variant-a)');
expect(lines.map((line) => line.id)).toEqual(['route', 'sac-1']);
expect(lines[0]).toMatchObject({
color: 'var(--hike-variant-a)',
casing: 'var(--bike-highlight-casing)',
dashed: false,
});
expect(lines[1]?.points).toEqual([
[47.785, 12.615],
[47.79, 12.62],
[47.795, 12.625],
[47.79, 12.63],
]);
expect(marks.map((mark) => mark.id)).toEqual(['start']);
const oneWay = { ...HIKE, routed: false, stops: [HIKE.stops[0] as TourRouteStop, named('Hochfelln', 4100)] };
const end = { ...(oneWay.stops[1] as TourRouteStop), role: 'end' as const, lat: 47.795, lng: 12.625 };
const straight = sketchOf({ ...oneWay, stops: [oneWay.stops[0] as TourRouteStop, end] }, 'red');
expect(straight.lines[0]?.dashed).toBe(true);
expect(straight.marks.map((mark) => mark.shape)).toEqual(['service', 'stop']);
const previous = { line: PREVIOUS_LINE, distanceM: 1, ascentM: null, minutes: null };
const changed = sketchOf({ ...HIKE, previous }, 'red');
expect(changed.lines[0]).toMatchObject({ id: 'previous', dim: true });
});
it('FE-AI-TOUR-045: reads the SAC step under a spot, the harder where two grades meet, none where nothing is graded', () => {
expect(sacAt(HIKE, 1200)).toBe(2);
expect(sacAt(HIKE, 3000)).toBe(3);
expect(sacAt(HIKE, 4100)).toBe(3);
expect(sacAt(HIKE, 6000)).toBeNull();
expect(sacAt({ stretches: [{ facet: 'mark', fromM: 0, toM: 100, kind: 'ferry' }] }, 50)).toBeNull();
});
it('FE-AI-TOUR-046: names the time of a route by how it was reckoned, and a paddle tour by its own without a basis', () => {
expect(timeLabelKey(HIKE)).toBe('tours.planner.walkingEstimatedInfo');
expect(timeLabelKey({ ...HIKE, minutesBasis: 'router' })).toBe('tours.scope.routed');
expect(timeLabelKey(SAVED)).toBe('tours.scope.planned');
expect(timeLabelKey({ ...HIKE, activity: 'walk', minutesBasis: 'stations' })).toBe('tours.scope.plannedWithStays');
expect(timeLabelKey({ ...HIKE, activity: 'ski', minutesBasis: 'piste' })).toBe('tours.ski.time');
expect(timeLabelKey({ ...HIKE, activity: 'kayak', minutesBasis: null })).toBe('tours.paddle.timeEstimated');
expect(timeLabelKey({ ...HIKE, minutesBasis: null })).toBe('tours.planner.walkingEstimated');
});
});
@@ -0,0 +1,240 @@
import { cumulativeMetres, type TourRouteCard, type TourRouteStop } from '@trek/shared';
import type { DistanceIndexedProfileSample } from '../../../../utils/routeGeometry';
import { hikePaintCss } from '../../../Tours/hike/hikePaint';
import type { LegVariant, LegVariantKey } from '../../../Tours/planner/legVariants';
import { gradeSegments } from '../../../Tours/terrain/tourTerrainModel';
import type { CardEntry } from '../cardTypes';
/**
* A tour card's numbers, pure: the height profile scaled to the route's own
* length, the stretches to tint in it, the stops worth a chip, the figures an
* activity states, a way as the planner's way cards read one, and the words
* of every warning, problem and stop as literal tables.
*/
/** The letters the ways of one answer go by, in the order the answer gave them. */
export const WAY_KEYS = ['a', 'b', 'c'] as const;
/** A route card of an answer, under the tool call it came from. */
export interface RouteEntry {
toolCallId: string;
card: TourRouteCard;
}
/** The route cards among an answer's cards of the tours' group. */
export function routeEntries(cards: readonly CardEntry[]): RouteEntry[] {
return cards.flatMap(({ toolCallId, card }) => (card.kind === 'tourRoute' ? [{ toolCallId, card }] : []));
}
/**
* How an answer's route cards are laid out: proposals of one activity as one
* card of ways to choose from, three at most each; anything else (a saved
* tour, a paddle or ski plan) on its own.
*/
export function wayGroups(entries: readonly RouteEntry[]): RouteEntry[][] {
if (entries.length < 2 || entries.some((entry) => entry.card.source !== 'proposal')) {
return entries.map((entry) => [entry]);
}
const groups: RouteEntry[][] = [];
for (let index = 0; index < entries.length; index += WAY_KEYS.length) {
groups.push(entries.slice(index, index + WAY_KEYS.length));
}
return groups;
}
/** From how steep a stretch counts as steep, in percent, and how long it must be to be marked. */
export const STEEP_GRADE = 27;
const STEEP_MIN_M = 100;
/** The stretch a grade is measured over, as the 3D view measures it. */
const GRADE_WINDOW_M = 200;
/** How many stop chips show before the middle ones fold into a count: the first four, the last two. */
const CHIP_LIMIT = 8;
const SAC_PAINTS = ['t1', 't2', 't3', 't4', 't5', 't6'] as const;
/** An SAC step's colour as a CSS variable, as the hike's own card draws it. */
export function sacPaint(step: number): string {
return hikePaintCss(SAC_PAINTS[Math.min(6, Math.max(1, step)) - 1] ?? 't1');
}
/** Whether every point of the line has its height, which a profile needs. */
function hasHeights(card: TourRouteCard): boolean {
return card.line.length >= 2 && card.line.every((point) => point[2] !== null);
}
/** Metres along the line at each point, scaled to the route's own length so the stops' positions meet it. */
export function alongOf(card: TourRouteCard): number[] {
const along = cumulativeMetres(card.line);
const total = along[along.length - 1] ?? 0;
const scale = card.distanceM !== null && total > 0 ? card.distanceM / total : 1;
return along.map((metres) => metres * scale);
}
/** The height profile's samples; none for a route joined straight or one without heights. */
export function profileSamples(card: TourRouteCard): DistanceIndexedProfileSample[] {
if (!card.routed || !hasHeights(card)) return [];
const along = alongOf(card);
return card.line.map(([lat, lng, height], index) => ({
distanceMeters: along[index] ?? 0,
elevationMeters: height ?? 0,
lat,
lng,
}));
}
/** A stretch to tint behind the profile, in metres along the route. */
interface ProfileBand {
fromM: number;
toM: number;
color: string;
}
/** The grade of each stretch of the profile, where it ends: what `gradeAt` reads. */
export function profileGrades(samples: readonly DistanceIndexedProfileSample[]): { toM: number; grade: number }[] {
return gradeSegments(samples, GRADE_WINDOW_M).map((segment, index) => ({
toM: samples[index + 1]?.distanceMeters ?? 0,
grade: segment.grade,
}));
}
/** The SAC step of the way at a spot, the hardest where graded stretches meet; null where it is not graded. */
export function sacAt(card: Pick<TourRouteCard, 'stretches'>, atM: number): number | null {
let step: number | null = null;
for (const stretch of card.stretches) {
if (stretch.facet !== 'sac' || atM < stretch.fromM || atM > stretch.toM) continue;
step = Math.max(step ?? 0, stretch.step);
}
return step;
}
/** How steep the way is at a spot, in percent; null past its end or without a profile. */
export function gradeAt(grades: readonly { toM: number; grade: number }[], atM: number): number | null {
return grades.find((stretch) => stretch.toM >= atM)?.grade ?? null;
}
/** The stretches at least `STEEP_GRADE` steep for at least `STEEP_MIN_M`, uphill or down, joined where they touch. */
export function steepStretches(samples: readonly DistanceIndexedProfileSample[]): { fromM: number; toM: number }[] {
const runs: { fromM: number; toM: number }[] = [];
let open: { fromM: number; toM: number } | null = null;
profileGrades(samples).forEach(({ toM, grade }, index) => {
const fromM = samples[index]?.distanceMeters ?? 0;
if (grade < STEEP_GRADE) {
open = null;
} else if (open) {
open.toM = toM;
} else {
open = { fromM, toM };
runs.push(open);
}
});
return runs.filter((run) => run.toM - run.fromM >= STEEP_MIN_M);
}
/**
* What the profile tints: the stretches graded T3 and harder in their SAC
* colour, else the steep ones in the warning colour. Never both, so no two
* tints lie over each other.
*/
export function profileBands(card: TourRouteCard, samples: readonly DistanceIndexedProfileSample[]): ProfileBand[] {
const graded = card.stretches.flatMap((stretch): ProfileBand[] =>
stretch.facet === 'sac' && stretch.step >= 3
? [{ fromM: stretch.fromM, toM: stretch.toM, color: sacPaint(stretch.step) }]
: []
);
if (graded.length > 0) return graded;
return steepStretches(samples).map((run) => ({ ...run, color: 'var(--warning)' }));
}
/** Where the profile marks a spot across it: the named stops on the way and the stage ends, six at most. */
export function profileMarks(card: TourRouteCard): number[] {
const marked = card.stops.filter((stop) => stop.stageEnd || (stop.role === 'via' && stop.name !== null));
// One mark per spot: the profile keys its marks by where they stand.
return [...new Set(marked.map((stop) => stop.atM))].slice(0, 6);
}
/** The stop chips in three runs: the first ones, the middle folded into a count, the last ones. */
interface StopChips {
head: TourRouteStop[];
folded: TourRouteStop[];
tail: TourRouteStop[];
}
/** The stops as chips: the start, the end, the named vias and what lies along the way, the middle folded past eight. */
export function chipStops(card: TourRouteCard): StopChips {
const chips = card.stops.filter((stop) => stop.role !== 'via' || stop.name !== null || stop.kind !== null);
if (chips.length <= CHIP_LIMIT) return { head: chips, folded: [], tail: [] };
return { head: chips.slice(0, 4), folded: chips.slice(4, -2), tail: chips.slice(-2) };
}
/** A figure a card can state. */
export type FactKey = 'time' | 'distance' | 'ascent' | 'descent';
/** A figure's value, null where the route does not know it. */
export function factValue(card: TourRouteCard, key: FactKey): number | null {
if (key === 'time') return card.minutes;
if (key === 'distance') return card.distanceM;
return key === 'ascent' ? card.ascentM : card.descentM;
}
/** The figures an activity states, in its order: on skis the descent matters, on the water the distance. */
export function factKeysOf(card: TourRouteCard): FactKey[] {
const order: FactKey[] =
card.activity === 'ski'
? ['time', 'distance', 'descent']
: card.activity === 'kayak'
? ['distance', 'time']
: ['time', 'distance', 'ascent', 'descent'];
return order.filter((key) => factValue(card, key) !== null);
}
/** The route as the planner's way cards read a way, so their badges, marks and differences read it too. */
export function legVariantOf(card: TourRouteCard, key: LegVariantKey): LegVariant {
return {
key,
current: key === 'current',
coordinates: card.line.map(([lat, lng]): [number, number] => [lat, lng]),
distanceM: card.distanceM ?? 0,
gainM: card.ascentM,
lossM: card.descentM,
minutes: card.minutes,
heights: card.ascentM !== null ? 'known' : 'unknown',
};
}
/** The way a held change of a saved tour replaces, as a way to compare the new one with; null without one. */
export function previousVariantOf(card: TourRouteCard): LegVariant | null {
const previous = card.previous;
if (!previous) return null;
return {
key: 'current',
current: true,
coordinates: previous.line.map(([lat, lng]): [number, number] => [lat, lng]),
distanceM: previous.distanceM,
gainM: previous.ascentM,
lossM: null,
minutes: previous.minutes,
heights: previous.ascentM !== null ? 'known' : 'unknown',
};
}
/** The points to frame on the map: the line, else the stops a route without one joins. */
export function routePoints(card: TourRouteCard): [number, number][] {
if (card.line.length >= 2) return card.line.map(([lat, lng]): [number, number] => [lat, lng]);
return card.stops.map((stop): [number, number] => [stop.lat, stop.lng]);
}
/**
* Whether "Plan this" can take the proposal: a hike whose way was planned,
* or a city walk. A paddle or ski plan, a saved tour and a held change cannot.
*/
export function canTake(card: TourRouteCard): boolean {
if (card.source !== 'proposal') return false;
return card.activity === 'walk' || (card.activity === 'hike' && card.routed);
}
/** A share as words take it, "40 %". */
export function percentText(share: number): string {
return `${Math.round(share * 100)} %`;
}
@@ -0,0 +1,60 @@
import { haversineMetres, type TourRouteCard } from '@trek/shared';
import type { SketchLine, SketchMark } from '../parts/routeSketchModel';
import { alongOf, sacPaint } from './tourRouteModel';
/**
* A tour card's sketch, pure: the way in the colour the planner draws it in
* (dashed while it is only its stops joined straight), the stretches graded
* T3 and harder tinted over it, the carried, ferried and lifted stretches
* dotted, the way a held change replaces faint beneath, the start as a disc
* and the end as a ring, one disc when the way comes back to its start.
*/
/** The white rim the planner's highlighted lines wear. */
const CASING = 'var(--bike-highlight-casing)';
/** How close the end may lie to the start for the way to count as a loop, in metres. */
const LOOP_M = 60;
type Point = readonly [number, number];
/** The points of the line between two places along it, with one point either side so the piece joins the way. */
function pieceOf(points: readonly Point[], along: readonly number[], fromM: number, toM: number): Point[] {
const inside = along.flatMap((metres, index) => (metres >= fromM && metres <= toM ? [index] : []));
const first = inside[0];
const last = inside[inside.length - 1];
if (first === undefined || last === undefined) return [];
const piece = points.slice(Math.max(0, first - 1), Math.min(points.length, last + 2));
return piece.length >= 2 ? piece : [];
}
/** The lines and marks of a route's sketch; `paint` is the colour of the way (the chosen way's in a ways card). */
export function sketchOf(card: TourRouteCard, paint: string): { lines: SketchLine[]; marks: SketchMark[] } {
const lines: SketchLine[] = [];
if (card.previous) {
lines.push({ id: 'previous', points: card.previous.line, color: 'var(--text-faint)', casing: CASING, dim: true });
}
const points = card.line.map(([lat, lng]): Point => [lat, lng]);
if (points.length >= 2) {
lines.push({ id: 'route', points, color: paint, casing: CASING, dashed: !card.routed });
const along = alongOf(card);
card.stretches.forEach((stretch, index) => {
if (stretch.facet === 'ski') return;
if (stretch.facet === 'sac' && stretch.step < 3) return;
const piece = pieceOf(points, along, stretch.fromM, stretch.toM);
if (piece.length === 0) return;
lines.push(
stretch.facet === 'sac'
? { id: `sac-${index}`, points: piece, color: sacPaint(stretch.step), casing: CASING }
: { id: `mark-${index}`, points: piece, color: 'var(--text-muted)', casing: CASING, dashed: true }
);
});
}
const start = card.stops.find((stop) => stop.role === 'start');
const end = [...card.stops].reverse().find((stop) => stop.role === 'end');
const marks: SketchMark[] = [];
if (start) marks.push({ id: 'start', lat: start.lat, lng: start.lng, shape: 'service', color: 'var(--accent)' });
const loop = start && end && haversineMetres(start.lat, start.lng, end.lat, end.lng) <= LOOP_M;
if (end && !loop) marks.push({ id: 'end', lat: end.lat, lng: end.lng, shape: 'stop' });
return { lines, marks };
}
@@ -0,0 +1,101 @@
import type { TourRouteCard, TourRouteStop, TourTypeKey } from '@trek/shared';
/**
* The words of a tour card as literal tables, so each key is named where it
* is read: what kind of tour a route is, what its SAC grades mean, what a
* stop without a name is, what each warning says and how loud, why a route
* is drawn straight, why a city walk's station is flagged.
*/
/** What kind of tour each activity is, as the Tours tab names it. */
export const TOUR_TYPE_WORDS: Readonly<Record<TourTypeKey, { labelKey: string }>> = {
hike: { labelKey: 'tourTypes.hike' },
bike: { labelKey: 'tourTypes.bike' },
run: { labelKey: 'tourTypes.run' },
ski: { labelKey: 'tourTypes.ski' },
kayak: { labelKey: 'tourTypes.kayak' },
walk: { labelKey: 'tourTypes.walk' },
food: { labelKey: 'tourTypes.food' },
horse: { labelKey: 'tourTypes.horse' },
};
/** What each SAC step means, T1 first, as the hike's own card says it. */
export const SAC_WORDS = [
{ labelKey: 'tours.hike.sac.t1' },
{ labelKey: 'tours.hike.sac.t2' },
{ labelKey: 'tours.hike.sac.t3' },
{ labelKey: 'tours.hike.sac.t4' },
{ labelKey: 'tours.hike.sac.t5' },
{ labelKey: 'tours.hike.sac.t6' },
] as const;
/** A route's name: its own, else the kind of tour it is (a city walk is proposed without one). */
export function routeName(card: Pick<TourRouteCard, 'name' | 'activity'>, t: (key: string) => string): string {
return card.name ?? t(TOUR_TYPE_WORDS[card.activity].labelKey);
}
type StopWord = NonNullable<TourRouteStop['kind']> | 'start' | 'end';
/** The words for a stop without a name of its own, by its kind or its role. */
const STOP_WORDS: Readonly<Partial<Record<StopWord, { labelKey: string }>>> = {
start: { labelKey: 'assistant.card.tour.stop.start' },
end: { labelKey: 'assistant.card.tour.stop.end' },
peak: { labelKey: 'assistant.card.tour.stop.peak' },
highest: { labelKey: 'assistant.card.tour.stop.highest' },
shelter: { labelKey: 'assistant.card.tour.stop.shelter' },
drinking_water: { labelKey: 'assistant.card.tour.stop.drinking_water' },
viewpoint: { labelKey: 'assistant.card.tour.stop.viewpoint' },
};
/** A stop's name: its own, else what it is, else where it is on the way. */
export function stopName(stop: TourRouteStop, t: (key: string) => string): string {
if (stop.name) return stop.name;
const role = stop.role === 'start' || stop.role === 'end' ? STOP_WORDS[stop.role] : undefined;
const word = (stop.kind ? STOP_WORDS[stop.kind] : undefined) ?? role;
return t(word?.labelKey ?? 'assistant.card.tour.stop.other');
}
type WarningKind = TourRouteCard['warnings'][number]['kind'];
/** What each warning says and how loud: the dark is danger, the rest a warning. */
export const WARNING_LOOK = {
thunder: { labelKey: 'assistant.card.tour.warn.thunder', tone: 'warning' },
rain: { labelKey: 'assistant.card.tour.warn.rain', tone: 'warning' },
dark_end: { labelKey: 'assistant.card.tour.warn.dark_end', tone: 'danger' },
dark_start: { labelKey: 'assistant.card.tour.warn.dark_start', tone: 'danger' },
dusk: { labelKey: 'assistant.card.tour.warn.dusk', tone: 'warning' },
legs_failed: { labelKey: 'assistant.card.tour.warn.legs_failed', tone: 'warning' },
} as const satisfies Record<WarningKind, { labelKey: string; tone: 'warning' | 'danger' }>;
/** Why a route is drawn straight, in words. */
export const PROBLEM_LOOK = {
no_valhalla: { labelKey: 'assistant.card.tour.problem.no_valhalla' },
router_unavailable: { labelKey: 'assistant.card.tour.problem.router_unavailable' },
no_way: { labelKey: 'assistant.card.tour.problem.no_way' },
point_off_the_way: { labelKey: 'assistant.card.tour.problem.point_off_the_way' },
too_long: { labelKey: 'assistant.card.tour.problem.too_long' },
ferry_only: { labelKey: 'assistant.card.tour.problem.ferry_only' },
not_planned: { labelKey: 'assistant.card.tour.notPlanned' },
} as const satisfies Record<NonNullable<TourRouteCard['problem']>['why'], { labelKey: string }>;
/** Why a city walk's station is flagged, in words. */
export const FLAG_LOOK = {
closed: { labelKey: 'assistant.card.tour.flag.closed' },
late: { labelKey: 'assistant.card.tour.flag.late' },
after_frame: { labelKey: 'assistant.card.tour.flag.after_frame' },
} as const satisfies Record<NonNullable<TourRouteStop['flag']>, { labelKey: string }>;
/** What a route's time is, by how it was reckoned, as the Tours tab names each. */
const TIME_WORDS = {
din33466: { labelKey: 'tours.planner.walkingEstimatedInfo' },
router: { labelKey: 'tours.scope.routed' },
planned: { labelKey: 'tours.scope.planned' },
stations: { labelKey: 'tours.scope.plannedWithStays' },
piste: { labelKey: 'tours.ski.time' },
} as const satisfies Record<NonNullable<TourRouteCard['minutesBasis']>, { labelKey: string }>;
/** The name of a route's time: by how it was reckoned, else a paddle's own, else a walking time. */
export function timeLabelKey(card: Pick<TourRouteCard, 'minutesBasis' | 'activity'>): string {
if (card.minutesBasis) return TIME_WORDS[card.minutesBasis].labelKey;
return card.activity === 'kayak' ? 'tours.paddle.timeEstimated' : 'tours.planner.walkingEstimated';
}
@@ -0,0 +1,59 @@
/**
* FE-AI-TOUR-047
*
* Whether a proposal was already taken, read from the thread: a later answer
* holding "Plan this" for the same way, waiting or applied, takes it; one
* discarded, cancelled or expired, another way, or a thread that no longer
* holds the proposal does not.
*/
import type { AssistantMessage, AssistantMessagePart, AssistantToolApproval, TourRouteCard } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { GENTLE, HIKE } from '../../../../../tests/helpers/assistantTour';
import { takeInThread } from './tourTakeModel';
const message = (id: string, parts: AssistantMessagePart[]): AssistantMessage => ({
id,
role: 'assistant',
status: 'complete',
createdAt: '2026-10-22T08:00:00.000Z',
parts,
});
const proposal = message('m1', [{ type: 'data-trek-card', data: { toolCallId: 'call_1', card: HIKE } }]);
/** The answer holding "Plan this" for `card`, decided as `approval` says. */
function take(card: TourRouteCard, approval: Omit<AssistantToolApproval, 'id'> = {}, toolName = 'take_proposal') {
return message('m2', [
{ type: 'tool-call', toolCallId: 't1', toolName, args: {}, approval: { id: 't1', ...approval } },
{
type: 'data-trek-change',
data: {
toolCallId: 't1',
verbKey: 'addTour',
subject: card.name ?? '',
rows: [],
dayId: null,
placeIds: [],
undoable: true,
card: { ...card, source: 'pending' },
},
},
]);
}
describe('takeInThread', () => {
it('FE-AI-TOUR-047: a later take of the same way, waiting or applied, takes the proposal; nothing else does', () => {
expect(takeInThread([proposal], 'call_1', HIKE)).toBeNull();
expect(takeInThread([proposal, take(HIKE)], 'call_1', HIKE)).toBe('waiting');
expect(takeInThread([proposal, take(HIKE, { approved: true })], 'call_1', HIKE)).toBe('applied');
expect(takeInThread([proposal, take(HIKE, { approved: false })], 'call_1', HIKE)).toBeNull();
expect(takeInThread([proposal, take(HIKE, { resolution: 'expired' })], 'call_1', HIKE)).toBeNull();
expect(takeInThread([proposal, take(HIKE, {}, 'create_hike')], 'call_1', HIKE)).toBeNull();
const other = { ...GENTLE, line: [[47.9, 12.8, 600], ...GENTLE.line.slice(1)] } satisfies TourRouteCard;
expect(takeInThread([proposal, take(other)], 'call_1', HIKE)).toBeNull();
expect(takeInThread([take(HIKE), proposal], 'call_1', HIKE)).toBeNull();
expect(takeInThread([take(HIKE)], 'call_1', HIKE)).toBeNull();
});
});
@@ -0,0 +1,58 @@
import type { AssistantMessage, TourRouteCard } from '@trek/shared';
/**
* Whether a proposal was already taken ("Plan this"), read from the thread
* rather than from the card, so a card drawn again (the chat reopened, the
* thread loaded anew, a long answer scrolled away and back) does not offer
* the same tour twice. The change the server holds names the way it creates,
* not the card it came from, so it is matched by its way.
*/
/** The tool a change held by "Plan this" runs under, as the server names it. */
const TAKE_TOOL = 'take_proposal';
/** Whether two routes are one way: one activity, as many points, the same ends. */
function sameWay(a: TourRouteCard, b: TourRouteCard): boolean {
if (a.activity !== b.activity || a.line.length !== b.line.length || a.stops.length !== b.stops.length) return false;
const ends = (card: TourRouteCard) => [card.line[0], card.line.at(-1)].map((point) => point?.slice(0, 2).join(','));
const [aStart, aEnd] = ends(a);
const [bStart, bEnd] = ends(b);
return aStart === bStart && aEnd === bEnd;
}
/** The index of the answer that drew the proposal's card; -1 when the thread no longer holds it. */
function proposalIndex(messages: readonly AssistantMessage[], toolCallId: string): number {
return messages.findIndex((message) =>
message.parts.some((part) => part.type === 'data-trek-card' && part.data.toolCallId === toolCallId)
);
}
/** Where a take of the proposal stands in the thread: waiting for a decision, applied, or none. */
type ThreadTake = 'waiting' | 'applied' | null;
/**
* The take a later answer holds for the same way, still waiting or applied.
* One discarded, cancelled or expired leaves the proposal free to be taken
* again.
*/
export function takeInThread(
messages: readonly AssistantMessage[],
toolCallId: string,
card: TourRouteCard
): ThreadTake {
const from = proposalIndex(messages, toolCallId);
if (from < 0) return null;
for (const message of messages.slice(from + 1)) {
const open = new Map<string, ThreadTake>();
for (const part of message.parts) {
if (part.type !== 'tool-call' || part.toolName !== TAKE_TOOL || part.approval?.resolution) continue;
if (part.approval?.approved !== false) open.set(part.toolCallId, part.approval?.approved ? 'applied' : 'waiting');
}
for (const part of message.parts) {
if (part.type !== 'data-trek-change' || part.data.card?.kind !== 'tourRoute') continue;
const take = open.get(part.data.toolCallId);
if (take && sameWay(part.data.card, card)) return take;
}
}
return null;
}
@@ -1,13 +1,19 @@
import { type CardView, PENDING_VIEW } from '../cardTypes';
import type { CardView } from '../cardTypes';
import TourListCard from './TourListCard';
import TourRouteBlock from './TourRouteBlock';
import TourRouteInRow, { TourRouteLead } from './TourRouteInRow';
/**
* How the kinds of the tours draw in the chat; each one draws nothing until its
* cards are built. This file only maps each kind to its view: the components
* live in sibling files, since a file that exports anything besides components
* breaks fast refresh, and lint:warnings holds
* react-refresh/only-export-components at zero for a new file.
* How the kinds of the tours draw in the chat: routes as the Tours tab's own
* route card (two or three ways of one route as one card to choose from),
* under a held change the route it adds or changes with the kind of tour as
* the row's tile, and saved tours as the sidebar's rows. This file only maps
* each kind to its view: the components live in sibling files, since a file
* that exports anything besides components breaks fast refresh, and
* lint:warnings holds react-refresh/only-export-components at zero for a new
* file.
*/
export const TOUR_VIEWS = {
tourRoute: PENDING_VIEW,
tourList: PENDING_VIEW,
tourRoute: { Block: TourRouteBlock, Lead: TourRouteLead, Body: TourRouteInRow },
tourList: { Block: TourListCard },
} satisfies { [K in 'tourRoute' | 'tourList']: CardView<K> };
@@ -0,0 +1,146 @@
import type { TourRouteCard } from '@trek/shared';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useNetworkMode } from '../../../../hooks/useNetworkMode';
import { chatOf, type HoldOutcome, useAssistantStore } from '../../../../store/assistantStore';
import { useCanDo } from '../../../../store/permissionsStore';
import { useTripStore } from '../../../../store/tripStore';
import type { RouteProfileFocus } from '../../../../utils/routeGeometry';
import { hasUndecided } from '../../assistantParts';
import { useAssistantView } from '../../assistantViewContext';
import { useChangeHover } from '../../useChangeHover';
import { canTake, routePoints } from './tourRouteModel';
import { takeInThread } from './tourTakeModel';
/**
* What a route card and the phone's route peek share: the light between the
* card and its line on the map (resting on one lights the other), the spot
* of the route in focus (the profile, a stop chip and the map's pointer all
* move one), and "On map", which frames the route (a ways card frames all
* its ways, `frame`); on the phone the chat steps aside first and the route's
* peek opens over the map.
*/
export function useTourRouteCard(toolCallId: string, card: TourRouteCard, frame?: readonly TourRouteCard[]) {
const { tripId, variant, onRevealMap } = useAssistantView();
const hover = useChangeHover(tripId, toolCallId);
const focus = useAssistantStore((s) =>
s.routeFocus?.tripId === tripId && s.routeFocus.ref === toolCallId ? s.routeFocus.focus : null
);
const setRouteFocus = useAssistantStore((s) => s.setRouteFocus);
const requestCamera = useAssistantStore((s) => s.requestCamera);
const setRoutePeek = useAssistantStore((s) => s.setRoutePeek);
/** Puts the focus out, if it is still this card's. */
const release = useCallback(() => {
const current = useAssistantStore.getState().routeFocus;
if (current?.tripId === tripId && current.ref === toolCallId) setRouteFocus(null);
}, [tripId, toolCallId, setRouteFocus]);
const setFocus = useCallback(
(next: RouteProfileFocus | null) => {
if (next) setRouteFocus({ tripId, ref: toolCallId, focus: next, source: 'card' });
else release();
},
[tripId, toolCallId, setRouteFocus, release]
);
// A card that goes takes its spot on the map with it.
useEffect(() => release, [release]);
const showOnMap = useCallback(() => {
const points = (frame ?? [card]).flatMap(routePoints);
if (points.length === 0) return;
onRevealMap?.();
requestCamera(tripId, points, 'fit', 'user');
if (variant === 'fullscreen') setRoutePeek({ tripId, toolCallId });
}, [card, frame, tripId, toolCallId, variant, onRevealMap, requestCamera, setRoutePeek]);
return { lit: hover.lit, litFromMap: hover.litFromMap, handlers: hover.handlers, focus, setFocus, showOnMap };
}
/**
* Lights a line on the map while what draws it is pointed at (a way of a
* ways card, a saved tour's row), by the key the map draws it under; only
* what lit it puts it out, since the pointer may already rest on another.
*/
export function useLineHover() {
const { tripId } = useAssistantView();
const setMapHover = useAssistantStore((s) => s.setMapHover);
return useCallback(
(ref: string, on: boolean) => {
if (on) {
setMapHover(tripId, ref, 'card');
return;
}
const link = useAssistantStore.getState().mapLink;
if (link?.tripId === tripId && link.hoveredId === ref) setMapHover(tripId, null, 'card');
},
[tripId, setMapHover]
);
}
/** Where "Plan this" stands: not tapped, asking, held below for a decision, or refused. */
type TakeState = 'idle' | 'busy' | 'taken' | 'failed';
/** Where a take leaves the card: held is taken, a refusal says why, nothing held offers the button again. */
function stateAfter(outcome: HoldOutcome, error: string | null): TakeState {
if (outcome === 'held') return 'taken';
// A proposal the server can no longer find or create comes back as `generic`: the card asks for it again.
// Any other refusal (busy, a limit) is the chat's to say, and the button is offered again.
return outcome === 'refused' && error === 'generic' ? 'failed' : 'idle';
}
/**
* "Plan this" on a proposed hike or city walk: the server holds the tour it
* creates as a change below, to decide like any other, without a model turn.
* It shows for someone who may add places to the trip, and waits (saying
* why) while offline, while the assistant answers or while another change is
* undecided, since one decision answers every open card. A proposal the
* server can no longer find or create is refused; the card then asks for it
* to be asked again. Taken is read from the thread too, so a card drawn again
* after its tour was held or applied does not offer it a second time.
*/
export function useTakeProposal(toolCallId: string, card: TourRouteCard) {
const { tripId } = useAssistantView();
const { offline } = useNetworkMode();
const can = useCanDo();
const trip = useTripStore((s) => (s.trip?.id === tripId ? s.trip : null));
const { isRunning, threadId, waiting, held } = useAssistantStore(
useShallow((s) => {
const chat = chatOf(s, tripId);
return {
isRunning: chat.isRunning,
threadId: chat.threadId,
waiting: hasUndecided(chat.messages),
held: takeInThread(chat.messages, toolCallId, card),
};
})
);
const takeProposal = useAssistantStore((s) => s.takeProposal);
const [tapped, setState] = useState<TakeState>('idle');
const state: TakeState = held !== null && tapped !== 'busy' ? 'taken' : tapped;
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const visible = canTake(card) && !!trip && can('place_edit', trip);
let reasonKey: string | null = null;
if (offline) reasonKey = 'assistant.error.offline';
else if (waiting && state !== 'taken') reasonKey = 'assistant.composer.decideFirst';
const disabled = reasonKey !== null || isRunning || threadId === null || state === 'busy';
const take = useCallback(async () => {
setState('busy');
const outcome = await takeProposal(tripId, toolCallId);
if (!mounted.current) return;
// A refusal is kept as the chat's error, read only when the take was refused.
setState(stateAfter(outcome, chatOf(useAssistantStore.getState(), tripId).error));
}, [takeProposal, tripId, toolCallId]);
return { visible, disabled, reasonKey, state, applied: held === 'applied', take };
}
@@ -0,0 +1,179 @@
/**
* FE-AI-PEEK-001 to FE-AI-PEEK-007
*
* The phone's peek at a route over the map: found where the chat shows the
* route (an answer's card, one of its ways, a held change), its name,
* figures and profile with the readout under it, the ways as marks a tap
* chooses between, the map's spot read in the readout, and a close button;
* loaded on first use. Closing it, or moving it to another way, takes the
* light a tap on the map put on the line along.
*/
import type { AssistantMessage, AssistantMessagePart, TourRouteCard } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GENTLE, HIKE } from '../../../../tests/helpers/assistantTour';
import { buildTrip, buildUser } from '../../../../tests/helpers/factories';
import { act, fireEvent, render, screen } from '../../../../tests/helpers/render';
import { chatOf, useAssistantStore } from '../../../store/assistantStore';
import { useAuthStore } from '../../../store/authStore';
import { useTripStore } from '../../../store/tripStore';
import RoutePeek from './RoutePeek';
import RoutePeekLazy from './RoutePeekLazy';
import { peekRoute } from './routePeekModel';
const TRIP = 4;
const ASSISTANT = useAssistantStore.getState();
const TRIPS = useTripStore.getState();
const AUTH = useAuthStore.getState();
function read(toolCallId: string, card: TourRouteCard): AssistantMessagePart[] {
return [
{ type: 'tool-call', toolCallId, toolName: 'propose_hike', args: {}, result: {} },
{ type: 'data-trek-card', data: { toolCallId, card } },
];
}
function held(toolCallId: string, card: TourRouteCard): AssistantMessagePart[] {
return [
{ type: 'tool-call', toolCallId, toolName: 'create_hike', args: {}, approval: { id: toolCallId } },
{
type: 'data-trek-change',
data: {
toolCallId,
verbKey: 'addTour',
subject: 'Hochfelln',
rows: [],
dayId: null,
placeIds: [],
undoable: true,
card: { ...card, source: 'pending' },
},
} as AssistantMessagePart,
];
}
function message(parts: AssistantMessagePart[]): AssistantMessage {
return { id: 'msg-1', role: 'assistant', parts, status: 'complete', createdAt: '2026-10-21T08:00:00.000Z' };
}
const WAYS = message([...read('call_1', { ...HIKE, label: 'Short and steep' }), ...read('call_2', GENTLE)]);
function setChat(messages: AssistantMessage[], peek: string | null) {
useAssistantStore.setState((s) => ({
byTrip: { [TRIP]: { ...chatOf(s, TRIP), threadId: 7, messages } },
routePeek: peek === null ? null : { tripId: TRIP, toolCallId: peek },
}));
}
function renderPeek() {
return render(<RoutePeek tripId={TRIP} onOpen={vi.fn()} onFocusDay={vi.fn()} onRevealMap={vi.fn()} />);
}
beforeEach(() => {
useAuthStore.setState({ user: buildUser({ id: 1 }) });
useTripStore.setState({ trip: buildTrip({ id: TRIP, user_id: 1 }) });
});
afterEach(() => {
useAssistantStore.setState(ASSISTANT, true);
useTripStore.setState(TRIPS, true);
useAuthStore.setState(AUTH, true);
});
describe('peekRoute', () => {
it('FE-AI-PEEK-001: finds a route among the ways of an answer, on its own, or as a held change, and nothing else', () => {
const ways = peekRoute([WAYS], 'call_2');
expect(ways?.entry.toolCallId).toBe('call_2');
expect(ways?.ways.map((way) => way.toolCallId)).toEqual(['call_1', 'call_2']);
expect(ways?.blockKey).toBe('tour:call_1');
const alone = peekRoute([message(read('call_9', HIKE))], 'call_9');
expect(alone?.ways).toEqual([]);
const change = peekRoute([message(held('call_5', HIKE))], 'call_5');
expect(change?.entry.card.source).toBe('pending');
expect(change?.ways).toEqual([]);
expect(peekRoute([WAYS], 'call_7')).toBeNull();
});
});
describe('RoutePeek', () => {
it('FE-AI-PEEK-002: shows nothing while closed, for another trip, or once the route is gone', () => {
setChat([WAYS], null);
const { container } = renderPeek();
expect(container).toBeEmptyDOMElement();
act(() => useAssistantStore.setState({ routePeek: { tripId: 9, toolCallId: 'call_1' } }));
expect(container).toBeEmptyDOMElement();
act(() => useAssistantStore.setState({ routePeek: { tripId: TRIP, toolCallId: 'call_7' } }));
expect(container).toBeEmptyDOMElement();
});
it('FE-AI-PEEK-003: names the route with its grade and figures over its profile, and closes', () => {
setChat([message(read('call_9', HIKE))], 'call_9');
renderPeek();
const peek = screen.getByTestId('route-peek');
expect(peek).toHaveAccessibleName('Hochfelln from Bergen');
expect(screen.getByTestId('tour-grade')).toBeInTheDocument();
expect(peek.querySelector('[data-figure="distance"]')).not.toBeNull();
expect(screen.getByRole('group', { name: 'Elevation profile of Hochfelln from Bergen' })).toBeInTheDocument();
expect(screen.queryByRole('group', { name: /way/ })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Close the route' }));
expect(useAssistantStore.getState().routePeek).toBeNull();
expect(screen.queryByTestId('route-peek')).not.toBeInTheDocument();
});
it('FE-AI-PEEK-004: the ways are marks with 44 px targets, and a tap chooses one for the answer and shows it', () => {
setChat([WAYS], 'call_1');
renderPeek();
const group = screen.getByRole('group', { name: '2 ways' });
const marks = group.querySelectorAll('button');
expect(marks).toHaveLength(2);
expect(marks[0]).toHaveAttribute('aria-pressed', 'true');
expect(marks[0]).toHaveClass('h-11', 'w-11');
fireEvent.click(screen.getByRole('button', { name: 'Long and gentle' }));
expect(chatOf(useAssistantStore.getState(), TRIP).picks).toEqual({ 'tour:call_1': 'call_2' });
expect(useAssistantStore.getState().routePeek).toEqual({ tripId: TRIP, toolCallId: 'call_2' });
expect(screen.getByRole('button', { name: 'Long and gentle' })).toHaveAttribute('aria-pressed', 'true');
});
it('FE-AI-PEEK-005: a spot picked on the map is read in the row under the profile, and the hint shows otherwise', () => {
setChat([message(read('call_9', HIKE))], 'call_9');
renderPeek();
expect(screen.getByText('Slide along the profile to read it')).toBeInTheDocument();
const focus = { distanceMeters: 4100, elevationMeters: 1400, lat: 47.795, lng: 12.625, sampleIndex: 5 };
act(() => useAssistantStore.getState().setRouteFocus({ tripId: TRIP, ref: 'call_9', focus, source: 'map' }));
expect(screen.getByTestId('tour-profile-readout')).toBeInTheDocument();
});
it('FE-AI-PEEK-006: the peek loads with its route the first time a route is shown over the map', async () => {
setChat([message(read('call_9', HIKE))], 'call_9');
render(<RoutePeekLazy tripId={TRIP} onOpen={vi.fn()} onFocusDay={vi.fn()} onRevealMap={vi.fn()} />);
expect(await screen.findByTestId('route-peek')).toHaveAccessibleName('Hochfelln from Bergen');
});
it('FE-AI-PEEK-007: closing the peek puts out the light a map tap put on its line, not a light from the chat', () => {
setChat([WAYS], 'call_1');
act(() => useAssistantStore.getState().setMapHover(TRIP, 'call_1', 'map'));
renderPeek();
// Another way in the peek moves the light with it.
fireEvent.click(screen.getByRole('button', { name: 'Long and gentle' }));
expect(useAssistantStore.getState().mapLink).toEqual({ tripId: TRIP, hoveredId: 'call_2', source: 'map' });
fireEvent.click(screen.getByRole('button', { name: 'Close the route' }));
expect(useAssistantStore.getState().mapLink).toEqual({ tripId: TRIP, hoveredId: null, source: 'map' });
act(() => {
useAssistantStore.getState().setMapHover(TRIP, 'call_1', 'card');
useAssistantStore.getState().setRoutePeek({ tripId: TRIP, toolCallId: 'call_1' });
});
fireEvent.click(screen.getByRole('button', { name: 'Close the route' }));
expect(useAssistantStore.getState().mapLink).toEqual({ tripId: TRIP, hoveredId: 'call_1', source: 'card' });
expect(useAssistantStore.getState().routePeek).toBeNull();
});
});
@@ -0,0 +1,127 @@
import { X } from 'lucide-react';
import { useMemo } from 'react';
import { useTranslation } from '../../../i18n';
import { chatOf, useAssistantStore } from '../../../store/assistantStore';
import { variantPaintCss } from '../../Tours/planner/legVariantPaint';
import { VariantMark } from '../../Tours/planner/mapCardParts';
import { type AssistantView, AssistantViewContext, useAssistantView } from '../assistantViewContext';
import { TourGrade, TourRouteFigures } from '../cards/tour/TourRouteHead';
import { profileSamples, WAY_KEYS } from '../cards/tour/tourRouteModel';
import TourRouteProfile from '../cards/tour/TourRouteProfile';
import { routeName } from '../cards/tour/tourRouteWords';
import { useTourRouteCard } from '../cards/tour/useTourRouteCard';
import { moveRoutePeek } from './routePeekControl';
import { type PeekRoute, peekRoute } from './routePeekModel';
/** The peek's body, for the route found: its ways, its name and figures, its profile and the way to close it. */
function PeekBody({ route }: { route: PeekRoute }) {
const { t } = useTranslation();
const { tripId } = useAssistantView();
const { entry, ways, blockKey } = route;
const { card, toolCallId } = entry;
const pick = useAssistantStore((s) => s.pick);
const shown = useTourRouteCard(toolCallId, card, ways.length > 0 ? ways.map((way) => way.card) : undefined);
const samples = useMemo(() => profileSamples(card), [card]);
const index = ways.findIndex((way) => way.toolCallId === toolCallId);
const color = variantPaintCss(index >= 0 ? (WAY_KEYS[index] ?? 'a') : 'current');
const name = routeName(card, t);
const waysLabel = t('assistant.card.tour.ways', { count: ways.length });
return (
<section
aria-label={name}
data-testid="route-peek"
className="trek-assistant-enter absolute inset-x-3 bottom-[calc(env(safe-area-inset-bottom,0px)+142px)] z-[42] rounded-2xl border border-edge-faint bg-surface-elevated px-3 pb-3 pt-2 shadow-elevated backdrop-blur-2xl"
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1 pt-1.5">
<h3 className="m-0 line-clamp-2 text-body font-semibold leading-tight text-content">
<bdi>{name}</bdi>
</h3>
<div className="mt-1 flex flex-wrap items-center gap-1">
<TourGrade card={card} />
<TourRouteFigures card={card} />
</div>
</div>
<button
type="button"
onClick={() => moveRoutePeek(tripId, null)}
aria-label={t('assistant.card.tour.peekClose')}
className="-me-1.5 grid h-11 w-11 flex-none place-items-center rounded-full text-content-secondary hover:bg-surface-hover"
>
<X size={18} strokeWidth={2.2} aria-hidden="true" />
</button>
</div>
{ways.length > 0 && (
<div role="group" aria-label={waysLabel} className="mt-1 flex items-center gap-1">
{ways.map((way, position) => {
const key = WAY_KEYS[position] ?? 'a';
const letter = key.toUpperCase();
const chosen = way.toolCallId === toolCallId;
return (
<button
key={way.toolCallId}
type="button"
aria-pressed={chosen}
aria-label={way.card.label ?? t('tours.variants.option', { letter })}
onClick={() => {
pick(tripId, blockKey, way.toolCallId);
moveRoutePeek(tripId, way.toolCallId);
}}
className="grid h-11 w-11 place-items-center rounded-full hover:bg-surface-hover"
>
<VariantMark variant={{ key, current: false }} chosen={chosen} />
</button>
);
})}
</div>
)}
<TourRouteProfile
toolCallId={toolCallId}
card={card}
samples={samples}
color={color}
focus={shown.focus}
onFocus={shown.setFocus}
/>
</section>
);
}
/** Finds the route the peek is open on in this trip's chat; nothing while it is closed or the route is gone. */
function PeekFinder() {
const { tripId } = useAssistantView();
const toolCallId = useAssistantStore((s) => (s.routePeek?.tripId === tripId ? s.routePeek.toolCallId : null));
const messages = useAssistantStore((s) => chatOf(s, tripId).messages);
const route = useMemo(() => (toolCallId ? peekRoute(messages, toolCallId) : null), [messages, toolCallId]);
return route ? <PeekBody route={route} /> : null;
}
interface RoutePeekProps {
tripId: number;
onOpen: NonNullable<AssistantView['onOpen']>;
onFocusDay: NonNullable<AssistantView['onFocusDay']>;
onRevealMap: NonNullable<AssistantView['onRevealMap']>;
}
/**
* The phone's peek at a route over the map, while the chat stands aside:
* opened by "On map" on a route card or a tap on a route line. It shows the
* route as its card does in brief (name, grade, figures) and its height
* profile with the readout under it, a finger scrubbing along it while the
* map's pin follows; the ways of an answer as their marks, 44 px targets, a
* tap choosing one as the card's choice does; and a close button. The map
* stays usable around it, and the chat's pill below brings the chat back.
*/
export default function RoutePeek({ tripId, onOpen, onFocusDay, onRevealMap }: RoutePeekProps) {
const view = useMemo<AssistantView>(
() => ({ tripId, variant: 'fullscreen', density: 'compact', onOpen, onFocusDay, onRevealMap }),
[tripId, onOpen, onFocusDay, onRevealMap]
);
return (
<AssistantViewContext.Provider value={view}>
<PeekFinder />
</AssistantViewContext.Provider>
);
}
@@ -0,0 +1,22 @@
import { type ComponentProps, Suspense } from 'react';
import { lazyWithRetry } from '../../../utils/lazyWithRetry';
import CardBoundary from '../cards/parts/CardBoundary';
/**
* The phone's route peek, loaded the first time a route is shown over the
* map, so the trip screen does not carry the route cards' code before the
* chat ever draws one. A peek that fails to load or to draw shows nothing:
* the map and the chat's pill stay as they are.
*/
const RoutePeek = lazyWithRetry(() => import('./RoutePeek'));
export default function RoutePeekLazy(props: ComponentProps<typeof RoutePeek>) {
return (
<CardBoundary>
<Suspense fallback={null}>
<RoutePeek {...props} />
</Suspense>
</CardBoundary>
);
}
@@ -0,0 +1,18 @@
import { useAssistantStore } from '../../../store/assistantStore';
/**
* Opens the phone's route peek on another route, or closes it, and moves the
* light a tap on the map put on a route with it. On the phone no pointer
* ever leaves a line, so nothing else would put that light out: the line
* would stay lit on the map and its card in the chat after the peek is gone.
* A light the chat itself put on (a card under the finger) is left alone.
* Kept apart from the peek, so the trip screen can close it without loading
* the peek's code.
*/
export function moveRoutePeek(tripId: number, toolCallId: string | null): void {
const { mapLink, setMapHover, setRoutePeek } = useAssistantStore.getState();
if (mapLink?.tripId === tripId && mapLink.source === 'map' && mapLink.hoveredId !== null) {
setMapHover(tripId, toolCallId, 'map');
}
setRoutePeek(toolCallId === null ? null : { tripId, toolCallId });
}
@@ -0,0 +1,36 @@
import type { AssistantMessage } from '@trek/shared';
import { messageBlocks } from '../assistantParts';
import { routeEntries, type RouteEntry, wayGroups } from '../cards/tour/tourRouteModel';
/**
* The route the phone's peek shows, found where the chat shows it: the card,
* and when it is one of the ways of an answer, all of them with the key the
* chat keeps the chosen way under (the ways card's own, `tour:<first call>`).
*/
export interface PeekRoute {
entry: RouteEntry;
/** The ways it is one of, in the answer's order; empty for a route on its own. */
ways: RouteEntry[];
blockKey: string;
}
/** The route card of a call, laid out as the chat lays it out; null when the chat holds no route under it. */
export function peekRoute(messages: readonly AssistantMessage[], toolCallId: string): PeekRoute | null {
for (const message of messages) {
for (const block of messageBlocks(message, false)) {
if (block.kind === 'cards') {
for (const group of wayGroups(routeEntries(block.entries))) {
const entry = group.find((way) => way.toolCallId === toolCallId);
if (!entry) continue;
const ways = group.length >= 2 ? group : [];
return { entry, ways, blockKey: `tour:${group[0]?.toolCallId ?? ''}` };
}
} else if (block.kind === 'change') {
const card = block.items.find((item) => item.toolCallId === toolCallId)?.card;
if (card?.kind === 'tourRoute') return { entry: { toolCallId, card }, ways: [], blockKey: '' };
}
}
}
return null;
}
@@ -0,0 +1,160 @@
/**
* FE-MAP-ROUTE-001 to FE-MAP-ROUTE-004
*
* The chat's routes on the Leaflet map: a rim under each line and nothing
* but an invisible fat line taking the pointer; dashes for a way not chosen,
* faint for the way a change replaces; the hard grades over the line, a disc
* at the start and a ring at the end; the named stops only while lit; and
* the pointer onto, along and off a line reported by its key; a line that
* lights up brought to the front. What Leaflet makes of the options is
* Leaflet's: the paths come back as data here.
*/
import { fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode, Ref } from 'react';
import { describe, expect, it, vi } from 'vitest';
import type { AssistantRouteLine } from '../../types/assistantMap';
import { AssistantGhostLineLayer } from './AssistantGhostLineLayer';
type Handlers = Record<string, ((event?: unknown) => void) | undefined>;
const bringToFront = vi.hoisted(() => vi.fn());
vi.mock('react-leaflet', async () => {
const { useImperativeHandle } = await vi.importActual<typeof import('react')>('react');
return {
FeatureGroup: ({ children, ref }: { children?: ReactNode; ref?: Ref<{ bringToFront: () => void }> }) => {
useImperativeHandle(ref, () => ({ bringToFront }));
return <div data-testid="group">{children}</div>;
},
Polyline: ({
positions,
pathOptions,
interactive,
eventHandlers,
}: {
positions: unknown;
pathOptions?: Record<string, unknown>;
interactive?: boolean;
eventHandlers?: Handlers;
}) => (
<div
data-testid="polyline"
data-points={JSON.stringify(positions)}
data-color={String(pathOptions?.color ?? '')}
data-weight={String(pathOptions?.weight ?? '')}
data-opacity={String(pathOptions?.opacity ?? '')}
data-dash={String(pathOptions?.dashArray ?? '')}
data-dash-named={String(pathOptions ? 'dashArray' in pathOptions : false)}
data-interactive={String(interactive)}
onMouseEnter={() => eventHandlers?.mouseover?.()}
onMouseLeave={() => eventHandlers?.mouseout?.()}
onMouseMove={() => eventHandlers?.mousemove?.({ latlng: { lat: 47.78, lng: 12.6 } })}
onClick={() => eventHandlers?.click?.({ latlng: { lat: 47.79, lng: 12.61 } })}
/>
),
CircleMarker: ({
center,
pathOptions,
children,
}: {
center: [number, number];
pathOptions?: Record<string, unknown>;
children?: ReactNode;
}) => (
<div data-testid="disc" data-center={JSON.stringify(center)} data-fill={String(pathOptions?.fillColor ?? '')}>
{children}
</div>
),
Tooltip: ({ children }: { children?: ReactNode }) => <span data-testid="stop-name">{children}</span>,
};
});
const LINE: AssistantRouteLine = {
id: 'c1:look:route',
ref: 'c1',
lines: [
[
[47.77, 12.59],
[47.78, 12.6],
[47.79, 12.62],
],
],
color: '#0a84ff',
casing: '#ffffff',
ends: { start: [47.77, 12.59], end: [47.79, 12.62] },
stops: [{ lat: 47.78, lng: 12.6, label: 'Strausshütte' }],
tints: [
{
line: [
[47.78, 12.6],
[47.79, 12.62],
],
color: '#7c3aed',
},
],
};
const paths = () => screen.queryAllByTestId('polyline');
/** What a path was asked for, as plain data. */
const asked = (el: HTMLElement | undefined) => ({ ...el?.dataset });
describe('AssistantGhostLineLayer', () => {
it('FE-MAP-ROUTE-001: a rim, the line, its hard grade and its ends; only the invisible line takes the pointer', () => {
render(<AssistantGhostLineLayer lines={[LINE]} />);
const [rim, core, grade, hit] = paths();
expect(asked(rim)).toMatchObject({ color: '#ffffff', weight: '7', interactive: 'false' });
expect(asked(core)).toMatchObject({ color: '#0a84ff', weight: '4', dash: '', interactive: 'false' });
expect(asked(grade)).toMatchObject({ color: '#7c3aed', interactive: 'false' });
expect(asked(hit)).toMatchObject({ weight: '16', opacity: '0', interactive: 'undefined' });
const discs = screen.getAllByTestId('disc');
expect(discs.map((disc) => disc.dataset.fill)).toEqual(['#0a84ff', '#ffffff']);
// The stops show only while the line is lit.
expect(screen.queryByTestId('stop-name')).not.toBeInTheDocument();
});
it('FE-MAP-ROUTE-002: dashed, faint, a loop with one disc, and the lit line with its named stops', () => {
const loop: AssistantRouteLine = { ...LINE, dashed: true, dim: true, ends: { start: [47.77, 12.59], end: null } };
const { rerender } = render(<AssistantGhostLineLayer lines={[loop]} />);
expect(asked(paths()[1])).toMatchObject({ dash: '6 7', opacity: '0.45' });
expect(screen.getAllByTestId('disc')).toHaveLength(1);
rerender(<AssistantGhostLineLayer lines={[{ ...LINE, hot: true }]} />);
expect(screen.getByTestId('stop-name')).toHaveTextContent('Strausshütte');
rerender(<AssistantGhostLineLayer lines={[]} />);
expect(paths()).toHaveLength(0);
});
it('FE-MAP-ROUTE-003: the pointer onto, along and off a line, and a tap on it, report its key and where', () => {
const onHover = vi.fn();
const onPoint = vi.fn();
render(<AssistantGhostLineLayer lines={[LINE]} onHover={onHover} onPoint={onPoint} />);
const hit = paths()[3] as HTMLElement;
fireEvent.mouseEnter(hit);
fireEvent.mouseMove(hit);
fireEvent.click(hit);
fireEvent.mouseLeave(hit);
expect(onHover.mock.calls).toEqual([['c1'], [null]]);
expect(onPoint.mock.calls).toEqual([
['c1', 47.78, 12.6],
['c1', 47.79, 12.61],
]);
});
it('FE-MAP-ROUTE-004: a dashed way that lights up loses its dashes and comes to the front; a calm one stays put', () => {
bringToFront.mockClear();
const way: AssistantRouteLine = { ...LINE, dashed: true };
const other: AssistantRouteLine = { ...LINE, id: 'c2:look:route', ref: 'c2' };
const { rerender } = render(<AssistantGhostLineLayer lines={[way, other]} />);
expect(asked(paths()[1])).toMatchObject({ dash: '6 7' });
expect(bringToFront).not.toHaveBeenCalled();
// The same line, now lit: its dash is asked away by name, so Leaflet's merged style drops it.
rerender(<AssistantGhostLineLayer lines={[other, { ...way, hot: true }]} />);
const lit = screen.getAllByTestId('group')[1];
const core = lit?.querySelectorAll<HTMLElement>('[data-testid="polyline"]')[1];
expect(asked(core)).toMatchObject({ dash: '', dashNamed: 'true' });
expect(bringToFront).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,134 @@
import type { FeatureGroup as LeafletFeatureGroup, LeafletMouseEvent } from 'leaflet';
import { useEffect, useRef } from 'react';
import { CircleMarker, FeatureGroup, Polyline, Tooltip } from 'react-leaflet';
import type { AssistantRouteLine } from '../../types/assistantMap';
/** A rim under the line, the line, and the invisible fat line that takes the pointer. */
const CASING_WEIGHT = 7;
const CORE_WEIGHT = 4;
const HIT_WEIGHT = 16;
/** A way not chosen, or one joined straight between its stops. */
const DASH = '6 7';
/** The way a change replaces, beside the new one. */
const DIM_OPACITY = 0.45;
const END_RADIUS = 5;
const STOP_RADIUS = 4;
const ROUND = { lineCap: 'round', lineJoin: 'round' } as const;
interface LineHandlers {
onHover?: (ref: string | null) => void;
onPoint?: (ref: string, lat: number, lng: number) => void;
}
/**
* One route the chat shows: its rim and its line (dashed while it is a way
* not chosen or only its stops joined straight, faint while it is the way a
* change replaces), its hard grades over it, a disc at its start and a ring
* at its end, its named stops with their names while it is lit, and a wide
* invisible line on top that reports the pointer: onto it, along it (for the route's profile)
* and off it. Nothing but the invisible line takes the pointer, so the plan's
* own lines and pins stay where a click lands. Leaflet keeps each path where
* it was first drawn, whatever order the lines come in later, so a line that
* lights up is brought to the front by hand.
*/
function GhostLine({ line, onHover, onPoint }: { line: AssistantRouteLine } & LineHandlers) {
const group = useRef<LeafletFeatureGroup | null>(null);
useEffect(() => {
if (line.hot) group.current?.bringToFront();
}, [line.hot]);
const opacity = line.dim ? DIM_OPACITY : 1;
const point = (event: LeafletMouseEvent) => onPoint?.(line.ref, event.latlng.lat, event.latlng.lng);
return (
<FeatureGroup ref={group}>
<Polyline
positions={line.lines}
interactive={false}
pathOptions={{ color: line.casing, weight: CASING_WEIGHT, opacity: opacity * 0.9, ...ROUND }}
/>
<Polyline
positions={line.lines}
interactive={false}
pathOptions={{
color: line.color,
weight: CORE_WEIGHT,
opacity,
// Always named: Leaflet merges a new style into the old one, so a dash left out would stay drawn.
dashArray: line.dashed ? DASH : undefined,
...ROUND,
}}
/>
{line.tints?.map((tint, index) => (
<Polyline
key={index}
positions={tint.line}
interactive={false}
pathOptions={{ color: tint.color, weight: CORE_WEIGHT, opacity, ...ROUND }}
/>
))}
{line.ends && (
<CircleMarker
center={line.ends.start}
radius={END_RADIUS}
interactive={false}
pathOptions={{ color: line.casing, weight: 2, fillColor: line.color, fillOpacity: 1, opacity }}
/>
)}
{line.ends?.end && (
<CircleMarker
center={line.ends.end}
radius={END_RADIUS}
interactive={false}
pathOptions={{ color: line.color, weight: 3, fillColor: line.casing, fillOpacity: 1, opacity }}
/>
)}
{line.hot &&
line.stops?.map((stop, index) => (
<CircleMarker
key={index}
center={[stop.lat, stop.lng]}
radius={STOP_RADIUS}
interactive={false}
pathOptions={{ color: line.casing, weight: 2, fillColor: line.color, fillOpacity: 1 }}
>
<Tooltip permanent direction="top" className="map-tooltip">
{stop.label}
</Tooltip>
</CircleMarker>
))}
<Polyline
positions={line.lines}
bubblingMouseEvents={false}
pathOptions={{ color: line.casing, weight: HIT_WEIGHT, opacity: 0 }}
eventHandlers={{
mouseover: () => onHover?.(line.ref),
mouseout: () => onHover?.(null),
mousemove: point,
click: point,
}}
/>
</FeatureGroup>
);
}
interface AssistantGhostLineLayerProps extends LineHandlers {
lines: readonly AssistantRouteLine[] | undefined;
}
/**
* The routes the chat shows on the Leaflet map, in the order they come
* (the store's route model puts the one under the pointer last; here a line
* brings itself to the front when it lights up). Over the plan's lines,
* under its pins. The twin of
* useAssistantGhostLinesGL.
*/
export function AssistantGhostLineLayer({ lines, onHover, onPoint }: AssistantGhostLineLayerProps) {
if (!lines?.length) return null;
return (
<>
{lines.map((line) => (
<GhostLine key={line.id} line={line} onHover={onHover} onPoint={onPoint} />
))}
</>
);
}
@@ -2,6 +2,7 @@ import type { ViewportPadding } from '../../utils/mapViewport';
import { useAssistantMarkerClasses } from './assistantCamera';
import { AssistantCameraController } from './AssistantCameraController';
import { AssistantGhostLayer } from './AssistantGhostLayer';
import { AssistantGhostLineLayer } from './AssistantGhostLineLayer';
import { useReportViewLeaflet } from './assistantViewport';
import type { AssistantMapProps } from './mapViewContract';
@@ -15,15 +16,20 @@ interface AssistantMapLeafletProps {
/**
* Everything the assistant does on the Leaflet map, mounted inside the map
* container as one child, so MapView only hands its props on: the ghosts,
* the camera, the pop and pulse on the real pins, and the part of the map
* the assistant is told is on screen.
* container as one child, so MapView only hands its props on: the routes
* its cards show, the ghosts, the camera, the pop and pulse on the real
* pins, and the part of the map the assistant is told is on screen.
*/
export function AssistantMapLeaflet({ assistant, padding, markerEl }: AssistantMapLeafletProps) {
useAssistantMarkerClasses(markerEl, assistant.poppedPlaceIds, assistant.assistantPulsePlaceId);
useReportViewLeaflet();
return (
<>
<AssistantGhostLineLayer
lines={assistant.assistantRouteLines}
onHover={assistant.onAssistantRouteHover}
onPoint={assistant.onAssistantRoutePoint}
/>
<AssistantGhostLayer
ghosts={assistant.assistantGhosts}
hoveredId={assistant.assistantHoveredId}
+1 -2
View File
@@ -49,6 +49,7 @@ import { pluginsApi, type PluginMapMarker, type PluginMapLayer } from '../../api
import { DEFAULT_MAP_CENTER, DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
import { computeMapViewport, TILE_SIZE_GL } from '../../utils/mapViewport'
import { selectedPlaceTarget } from './selectedPlaceTarget'
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers'
// Marker grouping for the GL map (#1385): MapLibre/Mapbox can't show the rich
// HTML photo markers *and* cluster them natively, so we feed the place points
@@ -57,8 +58,6 @@ import { selectedPlaceTarget } from './selectedPlaceTarget'
// points the source reports as currently unclustered. Grouping is always on,
// matching the Leaflet map's MarkerClusterGroup.
const PLACE_CLUSTER_SOURCE_ID = 'trip-place-clusters'
const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle'
const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count'
const PLACE_UNCLUSTERED_LAYER_ID = 'trip-place-unclustered-hit'
type PlaceWithCoords = Place & { lat: number; lng: number }
@@ -64,6 +64,19 @@
animation: trek-ghost-enter 180ms var(--ease-out-quint) both;
}
/* A spot a card marks (a via of a drive): a small dashed dot in the middle of the ghost's box, no icon. */
.trek-ghost--point {
display: flex;
align-items: center;
justify-content: center;
}
.trek-ghost--point .trek-ghost-face {
width: 12px;
height: 12px;
border-width: 2px;
}
.trek-ghost.is-hot .trek-ghost-face {
border-style: solid;
animation:
@@ -1,4 +1,4 @@
// FE-MAP-GHOST-001 to 003: the assistant's suggested places as map pins, built once for both renderers.
// FE-MAP-GHOST-001 to 004: the assistant's suggested places as map pins, built once for both renderers.
import { describe, expect, it, vi } from 'vitest';
import { GHOST_HOT_CLASS, buildGhostElement, mountGhosts, setGhostHot } from './assistantGhostMarker';
@@ -54,4 +54,28 @@ describe('assistantGhostMarker', () => {
setGhostHot(elements, null);
expect(b.classList.contains(GHOST_HOT_CLASS)).toBe(false);
});
it('FE-MAP-GHOST-004: a spot a card marks is a dot without an icon, and every ghost of one change lights by its call', () => {
const point = buildGhostElement({ id: 'c1:via:0', ref: 'c1', lat: 41.9, lng: 12.5, name: 'Via', variant: 'point' });
expect(point.classList.contains('trek-ghost--point')).toBe(true);
expect(point.querySelector('.trek-ghost-face svg')).toBeNull();
const onHover = vi.fn();
const onClick = vi.fn();
const ghosts = [
{ id: 'c1:stop:0', ref: 'c1', lat: 41.9, lng: 12.5, name: 'Stop', variant: 'pin' as const },
{ id: 'c1:via:0', ref: 'c1', lat: 41.91, lng: 12.5, name: 'Via', variant: 'point' as const },
{ id: 'c2', lat: 41.92, lng: 12.5, name: 'Other' },
];
const mounted = mountGhosts(ghosts, () => ({ remove: vi.fn() }), { current: { onHover, onClick } });
mounted.elements.get('c1:via:0')?.dispatchEvent(new Event('pointerenter'));
mounted.elements.get('c1:stop:0')?.dispatchEvent(new MouseEvent('click'));
expect(onHover).toHaveBeenCalledWith('c1');
expect(onClick).toHaveBeenCalledWith('c1');
setGhostHot(mounted.elements, 'c1');
const hot = [...mounted.elements.values()].map((el) => el.classList.contains(GHOST_HOT_CLASS));
expect(hot).toEqual([true, true, false]);
mounted.dispose();
});
});
@@ -34,17 +34,22 @@ function ghostIcon(name: string | undefined): string {
return markup;
}
/**
* A ghost's element: a dashed pin with its category's icon, or for a spot a
* card marks (`point`) a small dashed dot without one.
*/
export function buildGhostElement(ghost: AssistantGhost): HTMLDivElement {
const el = document.createElement('div');
el.className = 'trek-ghost';
el.className = ghost.variant === 'point' ? 'trek-ghost trek-ghost--point' : 'trek-ghost';
el.dataset.ghostId = ghost.id;
el.dataset.ghostRef = ghost.ref ?? ghost.id;
el.setAttribute('role', 'button');
el.setAttribute('aria-label', ghost.name);
if (ghost.color) el.style.setProperty('--ghost-color', ghost.color);
const face = document.createElement('div');
face.className = 'trek-ghost-face';
// A fixed glyph from the icon table, never anything the ghost carries.
face.innerHTML = ghostIcon(ghost.icon);
if (ghost.variant !== 'point') face.innerHTML = ghostIcon(ghost.icon);
el.appendChild(face);
return el;
}
@@ -79,11 +84,12 @@ export function mountGhosts(
const cleanups: (() => void)[] = [];
for (const ghost of ghosts) {
const el = buildGhostElement(ghost);
const enter = () => handlers.current.onHover?.(ghost.id);
const ref = ghost.ref ?? ghost.id;
const enter = () => handlers.current.onHover?.(ref);
const leave = () => handlers.current.onHover?.(null);
const click = (event: Event) => {
event.stopPropagation();
handlers.current.onClick?.(ghost.id);
handlers.current.onClick?.(ref);
};
const key = (event: KeyboardEvent) => {
if (event.key === 'Enter') click(event);
@@ -111,7 +117,7 @@ export function mountGhosts(
};
}
/** Marks the hovered ghost, and only it, without rebuilding any pin. */
/** Marks the hovered ghosts (every one of a change lit by its call), and only them, without rebuilding any pin. */
export function setGhostHot(elements: ReadonlyMap<string, HTMLElement>, hoveredId: string | null | undefined): void {
elements.forEach((el, id) => el.classList.toggle(GHOST_HOT_CLASS, id === hoveredId));
elements.forEach((el, id) => el.classList.toggle(GHOST_HOT_CLASS, (el.dataset.ghostRef ?? id) === hoveredId));
}
+19 -1
View File
@@ -1,5 +1,6 @@
import type { DawarichTrack, RoadtripHazard, RoadtripVia } from '@trek/shared';
import type { Day, Place, Reservation, RouteVia } from '../../types';
import type { AssistantRouteLine } from '../../types/assistantMap';
import type { ViewportPadding } from '../../utils/mapViewport';
import type { RouteProfileFocus } from '../../utils/routeGeometry';
import type { FerrataMapLayer } from '../Ferratas/useFerrataMapLayer';
@@ -209,6 +210,12 @@ export interface MapViewProps {
assistantCamera?: AssistantCamera | null;
/** Places that just arrived from an applied suggestion; their pins pop once. */
poppedPlaceIds?: readonly number[];
/** The routes the chat shows beside the plan: proposed ways, held changes, the way a change replaces. */
assistantRouteLines?: AssistantRouteLine[];
/** The pointer onto a route line (its `ref`) or off every one; the phone leaves it out, a tap points instead. */
onAssistantRouteHover?: (ref: string | null) => void;
/** The pointer moving along a route line, or a tap on it: where on the map, for the route's profile. */
onAssistantRoutePoint?: (ref: string, lat: number, lng: number) => void;
/** While the map shows the plan before a change, its pins cannot be dragged onto a day. */
markersLocked?: boolean;
}
@@ -224,9 +231,17 @@ export type AssistantMapProps = Pick<
| 'assistantCamera'
| 'poppedPlaceIds'
| 'markersLocked'
| 'assistantRouteLines'
| 'onAssistantRouteHover'
| 'onAssistantRoutePoint'
| 'routeProfileFocus'
>;
/** A suggested place drawn before it exists. `color` and `icon` are its category's. */
/**
* A suggested place drawn before it exists. `color` and `icon` are its
* category's. A spot a card marks (a road trip's new via) is a small
* `point` instead of a pin, and lights with its change's row by `ref`.
*/
export interface AssistantGhost {
id: string;
lat: number;
@@ -234,6 +249,9 @@ export interface AssistantGhost {
name: string;
color?: string;
icon?: string;
variant?: 'pin' | 'point';
/** The key it lights by, when several ghosts belong to one change; its `id` otherwise. */
ref?: string;
}
/**
@@ -0,0 +1,7 @@
/**
* The GL map's cluster bubbles for the trip's places (MapViewGL draws them):
* named here so the layers drawn over them, the assistant's route lines among
* them, can leave a tap on a bubble to the bubble.
*/
export const PLACE_CLUSTER_CIRCLE_LAYER_ID = 'trip-place-clusters-circle';
export const PLACE_CLUSTER_COUNT_LAYER_ID = 'trip-place-clusters-count';
@@ -0,0 +1,218 @@
/**
* FE-MAP-ROUTE-GL-001 to FE-MAP-ROUTE-GL-005
*
* The chat's routes on the GL map, the twin of the Leaflet layer: one
* GeoJSON source (lines with their rims and dashes, grades, ends, the lit
* line's stops) added the first time there is a route and again after a
* basemap rebuilds the style (never into a style still loading), and the
* invisible line reporting the pointer, except where a place pin or a
* cluster bubble over it takes the pointer.
*/
import { renderHook } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import type { AssistantRouteLine } from '../../types/assistantMap';
import {
type LineMap,
ROUTE_LINE_LAYERS,
routeLineFeatures,
useAssistantGhostLinesGL,
} from './useAssistantGhostLinesGL';
const LINE: AssistantRouteLine = {
id: 'c1:look:route',
ref: 'c1',
lines: [
[
[47.77, 12.59],
[47.79, 12.62],
],
],
color: '#0a84ff',
casing: '#ffffff',
ends: { start: [47.77, 12.59], end: [47.79, 12.62] },
stops: [{ lat: 47.78, lng: 12.6, label: 'Strausshütte' }],
tints: [
{
line: [
[47.78, 12.6],
[47.79, 12.62],
],
color: '#7c3aed',
},
],
};
type Listener = (event?: unknown) => void;
/** A stand-in GL map that keeps its sources, layers and listeners, and can rebuild its style. */
function fakeMap() {
const container = document.createElement('div');
document.body.appendChild(container);
const sources = new Map<string, { data: unknown; setData: (data: unknown) => void }>();
const layers: string[] = [];
const listeners: { type: string; layer: string | null; fn: Listener }[] = [];
const map = {
getSource: vi.fn((id: string) => sources.get(id)),
addSource: vi.fn((id: string, source: { data: unknown }) => {
const entry = {
data: source.data,
setData: (data: unknown) => {
entry.data = data;
},
};
sources.set(id, entry);
}),
addLayer: vi.fn((layer: { id: string }) => layers.push(layer.id)),
getLayer: vi.fn((): unknown => undefined),
isStyleLoaded: vi.fn(() => true),
queryRenderedFeatures: vi.fn((): unknown[] => []),
getContainer: () => container,
on: vi.fn((type: string, layerOrFn: string | Listener, fn?: Listener) => {
if (typeof layerOrFn === 'function') listeners.push({ type, layer: null, fn: layerOrFn });
else if (fn) listeners.push({ type, layer: layerOrFn, fn });
}),
off: vi.fn((type: string, layerOrFn: string | Listener, fn?: Listener) => {
const target = typeof layerOrFn === 'function' ? layerOrFn : fn;
const index = listeners.findIndex((l) => l.type === type && l.fn === target);
if (index >= 0) listeners.splice(index, 1);
}),
};
const fire = (type: string, layer: string | null, event?: unknown) =>
listeners.filter((l) => l.type === type && l.layer === layer).forEach((l) => l.fn(event));
const restyle = () => {
sources.clear();
layers.length = 0;
fire('styledata', null);
};
const data = () => sources.get('assistant-route-lines')?.data as ReturnType<typeof routeLineFeatures> | undefined;
return { map, container, layers, listeners, fire, restyle, data };
}
function mount(fake: ReturnType<typeof fakeMap>, lines: AssistantRouteLine[], handlers = {}) {
const mapRef = { current: fake.map as unknown as LineMap };
return renderHook(
(props: { lines: AssistantRouteLine[] }) =>
useAssistantGhostLinesGL({ mapRef, mapReady: true, lines: props.lines, ...handlers }),
{ initialProps: { lines } }
);
}
describe('useAssistantGhostLinesGL', () => {
it('FE-MAP-ROUTE-GL-001: a route is its line, its grade and its ends, in GeoJSON order; its stops only while lit', () => {
const calm = routeLineFeatures([{ ...LINE, dim: true, dashed: true }]);
expect(calm.features.map((f) => f.properties.kind)).toEqual(['line', 'tint', 'end', 'end']);
expect(calm.features[0]?.properties).toMatchObject({ ref: 'c1', dashed: true, hot: false, opacity: 0.45 });
expect(calm.features[0]?.geometry).toEqual({
type: 'MultiLineString',
coordinates: [
[
[12.59, 47.77],
[12.62, 47.79],
],
],
});
expect(calm.features[3]?.properties).toMatchObject({ fill: '#ffffff', stroke: '#0a84ff', strokeWidth: 3 });
const lit = routeLineFeatures([{ ...LINE, hot: true }]);
expect(lit.features[lit.features.length - 1]).toMatchObject({
properties: { kind: 'stop', label: 'Strausshütte' },
geometry: { type: 'Point', coordinates: [12.6, 47.78] },
});
});
it('FE-MAP-ROUTE-GL-002: nothing is added before a route; then one source and its layers, new data on change, back after a restyle', () => {
const fake = fakeMap();
const hook = mount(fake, []);
expect(fake.map.addSource).not.toHaveBeenCalled();
hook.rerender({ lines: [LINE] });
expect(fake.map.addSource).toHaveBeenCalledTimes(1);
expect(fake.layers).toEqual([
ROUTE_LINE_LAYERS.casing,
ROUTE_LINE_LAYERS.line,
ROUTE_LINE_LAYERS.dashed,
ROUTE_LINE_LAYERS.tint,
ROUTE_LINE_LAYERS.ends,
ROUTE_LINE_LAYERS.stops,
ROUTE_LINE_LAYERS.hit,
]);
expect(fake.data()?.features).toHaveLength(4);
hook.rerender({ lines: [{ ...LINE, hot: true }] });
expect(fake.map.addSource).toHaveBeenCalledTimes(1);
expect(fake.data()?.features).toHaveLength(5);
fake.restyle();
expect(fake.map.addSource).toHaveBeenCalledTimes(2);
expect(fake.data()?.features).toHaveLength(5);
hook.rerender({ lines: [] });
expect(fake.data()?.features).toHaveLength(0);
});
it("FE-MAP-ROUTE-GL-003: the pointer onto, along and off a route, a tap on it, and a stop's name; unmounting lets go", () => {
const fake = fakeMap();
const onHover = vi.fn();
const onPoint = vi.fn();
const hook = mount(fake, [LINE], { onHover, onPoint });
const feature = { features: [{ properties: { ref: 'c1' } }] };
fake.fire('mousemove', ROUTE_LINE_LAYERS.hit, { ...feature, lngLat: { lng: 12.6, lat: 47.78 } });
fake.fire('click', ROUTE_LINE_LAYERS.hit, { ...feature, lngLat: { lng: 12.61, lat: 47.79 } });
fake.fire('mouseleave', ROUTE_LINE_LAYERS.hit);
expect(onHover.mock.calls).toEqual([['c1'], [null]]);
expect(onPoint.mock.calls).toEqual([
['c1', 47.78, 12.6],
['c1', 47.79, 12.61],
]);
const stop = { features: [{ properties: { label: 'Strausshütte' } }], lngLat: { lng: 12.6, lat: 47.78 } };
fake.fire('mousemove', ROUTE_LINE_LAYERS.stops, { ...stop, point: { x: 40, y: 50 } });
expect(fake.container.querySelector('[data-testid="assistant-route-label"]')).toHaveTextContent('Strausshütte');
hook.unmount();
expect(fake.listeners).toHaveLength(0);
});
it('FE-MAP-ROUTE-GL-004: a tap or a pointer on a place pin or a cluster bubble over a route is left to the pin', () => {
const fake = fakeMap();
const onHover = vi.fn();
const onPoint = vi.fn();
mount(fake, [LINE], { onHover, onPoint });
const point = { x: 40, y: 50 };
const at = { features: [{ properties: { ref: 'c1' } }], lngLat: { lng: 12.6, lat: 47.78 }, point };
const marker = document.createElement('div');
marker.className = 'maplibregl-marker';
const photo = marker.appendChild(document.createElement('img'));
fake.fire('click', ROUTE_LINE_LAYERS.hit, { ...at, originalEvent: { target: photo } });
fake.fire('mousemove', ROUTE_LINE_LAYERS.hit, { ...at, originalEvent: { target: photo } });
expect(onPoint).not.toHaveBeenCalled();
expect(onHover.mock.calls).toEqual([[null]]);
fake.map.getLayer.mockReturnValue({});
fake.map.queryRenderedFeatures.mockReturnValue([{ id: 7 }]);
fake.fire('click', ROUTE_LINE_LAYERS.hit, at);
expect(onPoint).not.toHaveBeenCalled();
expect(fake.map.queryRenderedFeatures).toHaveBeenCalledWith(at.point, {
layers: ['trip-place-clusters-circle', 'trip-place-clusters-count'],
});
// Off the bubbles the line takes the tap again.
fake.map.queryRenderedFeatures.mockReturnValue([]);
fake.fire('click', ROUTE_LINE_LAYERS.hit, at);
expect(onPoint).toHaveBeenCalledWith('c1', 47.78, 12.6);
});
it('FE-MAP-ROUTE-GL-005: a route that comes while a new basemap is loading is put on once the style is ready', () => {
const fake = fakeMap();
fake.map.isStyleLoaded.mockReturnValue(false);
mount(fake, [LINE]);
expect(fake.map.addSource).not.toHaveBeenCalled();
fake.map.isStyleLoaded.mockReturnValue(true);
fake.fire('styledata', null);
expect(fake.map.addSource).toHaveBeenCalledTimes(1);
expect(fake.data()?.features).toHaveLength(4);
});
});
@@ -0,0 +1,276 @@
import { useEffect, useLayoutEffect, useRef } from 'react';
import type { AssistantRouteLine } from '../../types/assistantMap';
import { mapHoverLabel } from './mapHoverLabel';
import { PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID } from './placeClusterLayers';
const SOURCE_ID = 'assistant-route-lines';
export const ROUTE_LINE_LAYERS = {
casing: `${SOURCE_ID}-casing`,
line: `${SOURCE_ID}-line`,
dashed: `${SOURCE_ID}-line-dashed`,
tint: `${SOURCE_ID}-tint`,
ends: `${SOURCE_ID}-ends`,
stops: `${SOURCE_ID}-stops`,
hit: `${SOURCE_ID}-hit`,
} as const;
/** The way a change replaces, beside the new one. */
const DIM_OPACITY = 0.45;
const ROUND = { 'line-cap': 'round', 'line-join': 'round' } as const;
type Position = [number, number];
interface Feature {
type: 'Feature';
properties: Record<string, string | number | boolean>;
geometry:
| { type: 'MultiLineString'; coordinates: Position[][] }
| { type: 'LineString'; coordinates: Position[] }
| { type: 'Point'; coordinates: Position };
}
interface FeatureCollection {
type: 'FeatureCollection';
features: Feature[];
}
/** What a pointer event on a layer hands over: where, and the feature under it. */
interface LayerEvent {
lngLat: { lng: number; lat: number };
point?: { x: number; y: number };
features?: { properties?: Record<string, unknown> | null }[];
/** The DOM event behind it: a place pin is a DOM marker over the canvas, and its taps reach the layers too. */
originalEvent?: { target?: EventTarget | null };
}
/**
* The slice of the GL map the route lines need. Every call is optional, so a
* stand-in map without them draws nothing instead of throwing; Mapbox and
* MapLibre both fit.
*/
export interface LineMap {
getSource?(id: string): { setData?: (data: FeatureCollection) => void } | undefined;
addSource?(id: string, source: { type: 'geojson'; data: FeatureCollection }): unknown;
addLayer?(layer: Record<string, unknown>): unknown;
getLayer?(id: string): unknown;
isStyleLoaded?(): boolean;
queryRenderedFeatures?(point: { x: number; y: number }, options: { layers: string[] }): unknown[];
getContainer?(): HTMLElement;
getCanvas?(): HTMLElement;
on?(type: 'styledata', listener: () => void): unknown;
on?(type: 'mousemove' | 'mouseleave' | 'click', layer: string, listener: (event: LayerEvent) => void): unknown;
off?(type: 'styledata', listener: () => void): unknown;
off?(type: 'mousemove' | 'mouseleave' | 'click', layer: string, listener: (event: LayerEvent) => void): unknown;
}
/** [lat, lng] as the store keeps points, [lng, lat] as GeoJSON wants them. */
const flip = ([lat, lng]: [number, number]): Position => [lng, lat];
const pointFeature = (properties: Feature['properties'], coordinates: Position): Feature => ({
type: 'Feature',
properties,
geometry: { type: 'Point', coordinates },
});
/**
* The routes as one GeoJSON collection: each route its line (with its rim
* colour, whether it is dashed, how faint, whether it is lit), its hard
* grades, its two ends, and its named stops while it is lit. Pure, for the
* tests.
*/
export function routeLineFeatures(lines: readonly AssistantRouteLine[]): FeatureCollection {
const features: Feature[] = [];
for (const line of lines) {
const opacity = line.dim ? DIM_OPACITY : 1;
features.push({
type: 'Feature',
properties: {
kind: 'line',
ref: line.ref,
color: line.color,
casing: line.casing,
dashed: !!line.dashed,
hot: !!line.hot,
opacity,
},
geometry: { type: 'MultiLineString', coordinates: line.lines.map((part) => part.map(flip)) },
});
for (const tint of line.tints ?? []) {
features.push({
type: 'Feature',
properties: { kind: 'tint', color: tint.color, opacity },
geometry: { type: 'LineString', coordinates: tint.line.map(flip) },
});
}
if (line.ends) {
const start = { kind: 'end', fill: line.color, stroke: line.casing, strokeWidth: 2, opacity };
features.push(pointFeature(start, flip(line.ends.start)));
if (line.ends.end) {
const end = { kind: 'end', fill: line.casing, stroke: line.color, strokeWidth: 3, opacity };
features.push(pointFeature(end, flip(line.ends.end)));
}
}
if (!line.hot) continue;
for (const stop of line.stops ?? []) {
const properties = { kind: 'stop', fill: line.color, stroke: line.casing, label: stop.label };
features.push(pointFeature(properties, [stop.lng, stop.lat]));
}
}
return { type: 'FeatureCollection', features };
}
const kindIs = (kind: string) => ['==', ['get', 'kind'], kind];
/** The layers, bottom first: rims, lines, dashed lines, grades, ends, stops, and the invisible line on top. */
function addRouteLineLayers(map: LineMap): void {
map.addSource?.(SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
const source = SOURCE_ID;
const line = (id: string, filter: unknown[], paint: Record<string, unknown>) =>
map.addLayer?.({ id, type: 'line', source, filter, paint, layout: ROUND });
const solid = ['all', kindIs('line'), ['==', ['get', 'dashed'], false]];
const dashed = ['all', kindIs('line'), ['==', ['get', 'dashed'], true]];
const opacity = ['get', 'opacity'];
line(ROUTE_LINE_LAYERS.casing, kindIs('line'), {
'line-color': ['get', 'casing'],
'line-width': 7,
'line-opacity': ['*', opacity, 0.9],
});
line(ROUTE_LINE_LAYERS.line, solid, { 'line-color': ['get', 'color'], 'line-width': 4, 'line-opacity': opacity });
// Dashes are counted in line widths: 6 px on, 7 px off at a width of 4, as the Leaflet map draws them.
line(ROUTE_LINE_LAYERS.dashed, dashed, {
'line-color': ['get', 'color'],
'line-width': 4,
'line-opacity': opacity,
'line-dasharray': [1.5, 1.75],
});
line(ROUTE_LINE_LAYERS.tint, kindIs('tint'), {
'line-color': ['get', 'color'],
'line-width': 4,
'line-opacity': opacity,
});
const disc = (id: string, filter: unknown[], radius: number) =>
map.addLayer?.({
id,
type: 'circle',
source,
filter,
paint: {
'circle-radius': radius,
'circle-color': ['get', 'fill'],
'circle-stroke-color': ['get', 'stroke'],
'circle-stroke-width': ['coalesce', ['get', 'strokeWidth'], 2],
'circle-opacity': ['coalesce', opacity, 1],
'circle-stroke-opacity': ['coalesce', opacity, 1],
},
});
disc(ROUTE_LINE_LAYERS.ends, kindIs('end'), 5);
disc(ROUTE_LINE_LAYERS.stops, kindIs('stop'), 4);
line(ROUTE_LINE_LAYERS.hit, kindIs('line'), { 'line-color': ['get', 'casing'], 'line-width': 16, 'line-opacity': 0 });
}
interface AssistantGhostLinesOptions {
mapRef: { readonly current: LineMap | null };
mapReady: boolean;
lines: readonly AssistantRouteLine[] | undefined;
onHover?: (ref: string | null) => void;
onPoint?: (ref: string, lat: number, lng: number) => void;
}
/** The ref of the route line under a pointer event, if any. */
function refOf(event: LayerEvent): string | null {
const ref = event.features?.[0]?.properties?.ref;
return typeof ref === 'string' ? ref : null;
}
/**
* Whether the pointer is on something of the plan's that lies over the route
* on screen: a place pin (a DOM marker) or a cluster bubble (drawn under the
* route's layers, but the bubble is what a tap there means). The plan's own
* tracks leave such a tap alone the same way.
*/
function onPlanPin(map: LineMap, event: LayerEvent): boolean {
const target = event.originalEvent?.target;
if (target instanceof Element && target.closest('.mapboxgl-marker, .maplibregl-marker')) return true;
if (!event.point || !map.queryRenderedFeatures || !map.getLayer?.(PLACE_CLUSTER_CIRCLE_LAYER_ID)) return false;
const layers = [PLACE_CLUSTER_CIRCLE_LAYER_ID, PLACE_CLUSTER_COUNT_LAYER_ID];
return map.queryRenderedFeatures(event.point, { layers }).length > 0;
}
/**
* The routes the chat shows on the GL map, the twin of the Leaflet
* AssistantGhostLineLayer: one GeoJSON source and its layers, added the
* first time there is a route to draw and again whenever the basemap
* rebuilds the style, the data set anew when the routes change. The
* invisible line reports the pointer onto, along and off a route; a stop
* under the pointer shows its name in the map's hover label.
*/
export function useAssistantGhostLinesGL({ mapRef, mapReady, lines, onHover, onPoint }: AssistantGhostLinesOptions) {
const handlers = useRef({ onHover, onPoint });
useLayoutEffect(() => {
handlers.current = { onHover, onPoint };
}, [onHover, onPoint]);
const data = useRef<FeatureCollection>(routeLineFeatures([]));
const added = useRef(false);
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady || typeof map.getSource !== 'function') return;
data.current = routeLineFeatures(lines ?? []);
// Nothing is added to a map that never had a route to draw.
if (!added.current && data.current.features.length === 0) return;
added.current = true;
if (!map.getSource(SOURCE_ID)) {
// A basemap still loading takes no layers; its styledata puts the routes on once it is ready.
if (map.isStyleLoaded?.() === false) return;
addRouteLineLayers(map);
}
map.getSource(SOURCE_ID)?.setData?.(data.current);
}, [lines, mapReady, mapRef]);
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady || typeof map.on !== 'function' || typeof map.off !== 'function') return;
const label = mapHoverLabel(map, 'assistant-route-label');
// A new basemap takes every layer with it: put the routes back, as the plan's own tracks are.
const restore = () => {
if (!added.current || !map.getSource || map.getSource(SOURCE_ID)) return;
addRouteLineLayers(map);
map.getSource(SOURCE_ID)?.setData?.(data.current);
};
const move = (event: LayerEvent) => {
const ref = refOf(event);
if (ref === null) return;
// On a pin over the line the pin is what the pointer is on: the line goes out as if left.
if (onPlanPin(map, event)) {
handlers.current.onHover?.(null);
return;
}
handlers.current.onHover?.(ref);
handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
};
const leave = () => handlers.current.onHover?.(null);
const click = (event: LayerEvent) => {
const ref = refOf(event);
if (ref !== null && !onPlanPin(map, event)) handlers.current.onPoint?.(ref, event.lngLat.lat, event.lngLat.lng);
};
const stop = (event: LayerEvent) => {
const name = event.features?.[0]?.properties?.label;
if (typeof name === 'string' && event.point) label.show({ name, detail: null }, event.point);
};
const hideLabel = () => label.hide();
map.on('styledata', restore);
map.on('mousemove', ROUTE_LINE_LAYERS.hit, move);
map.on('mouseleave', ROUTE_LINE_LAYERS.hit, leave);
map.on('click', ROUTE_LINE_LAYERS.hit, click);
map.on('mousemove', ROUTE_LINE_LAYERS.stops, stop);
map.on('mouseleave', ROUTE_LINE_LAYERS.stops, hideLabel);
return () => {
map.off?.('styledata', restore);
map.off?.('mousemove', ROUTE_LINE_LAYERS.hit, move);
map.off?.('mouseleave', ROUTE_LINE_LAYERS.hit, leave);
map.off?.('click', ROUTE_LINE_LAYERS.hit, click);
map.off?.('mousemove', ROUTE_LINE_LAYERS.stops, stop);
map.off?.('mouseleave', ROUTE_LINE_LAYERS.stops, hideLabel);
label.remove();
};
}, [mapReady, mapRef]);
}
+11 -3
View File
@@ -5,6 +5,7 @@ import { PLACE_POP_CLASS, PLACE_PULSE_CLASS } from './assistantGhostMarker';
import { useReportViewGL } from './assistantViewport';
import type { AssistantMapProps } from './mapViewContract';
import { useAssistantCameraGL, type CameraEngine, type CameraMap } from './useAssistantCameraGL';
import { useAssistantGhostLinesGL, type LineMap } from './useAssistantGhostLinesGL';
import { useAssistantGhostMarkersGL, type GhostMap } from './useAssistantGhostMarkersGL';
interface PinGL {
@@ -14,7 +15,7 @@ interface PinGL {
interface AssistantMapGLOptions<Pin extends PinGL> {
/** The live GL map, typed by what the parts read; the engine itself stays out of this module. */
mapRef: { readonly current: (CameraMap & GhostMap) | null };
mapRef: { readonly current: (CameraMap & GhostMap & LineMap) | null };
mapReady: boolean;
gl: CameraEngine;
/** The ghosts' pins, in the ref the renderer repositions on its render clock. */
@@ -40,8 +41,8 @@ export function carryAssistantMarks(from: HTMLElement, to: HTMLElement): void {
/**
* Everything the assistant does on the GL map in one call, so MapViewGL only
* hands its props on: the ghosts, the camera, and the pop and pulse on the
* real pins. The twin of AssistantMapLeaflet.
* hands its props on: the routes its cards show, the ghosts, the camera, and
* the pop and pulse on the real pins. The twin of AssistantMapLeaflet.
*/
export function useAssistantMapGL<Pin extends PinGL>({
mapRef,
@@ -53,6 +54,13 @@ export function useAssistantMapGL<Pin extends PinGL>({
padding,
assistant,
}: AssistantMapGLOptions<Pin>): void {
useAssistantGhostLinesGL({
mapRef,
mapReady,
lines: assistant.assistantRouteLines,
onHover: assistant.onAssistantRouteHover,
onPoint: assistant.onAssistantRoutePoint,
});
useAssistantGhostMarkersGL({
mapRef,
mapReady,
@@ -12,6 +12,7 @@ import { HIKE_FACETS, type HikeFacet } from './hikeFacetSpecs';
import { facetPaint, isUnknownClass, type FacetRow } from './hikeFacets';
import { hikePaintCss } from './hikePaint';
import { sacStepOfClass } from './sacGrades';
import { GradeChip } from './SacStepChip';
import type { HikeWays } from './useHikeWays';
const percent = (share: number) => `${Math.round(share * 100)} %`;
@@ -52,20 +53,8 @@ const writtenTags = (facet: HikeFacet, row: FacetRow) => row.tags.map((tag) => `
function ClassChip({ facet, row }: { facet: HikeFacet; row: FacetRow }) {
const step = sacStepOfClass(row.key);
const unknown = isUnknownClass(row.key);
return (
<span className="inline-flex h-[18px] w-[34px] flex-none items-center justify-center gap-1 rounded-full bg-surface-card font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-inset ring-edge-faint">
<span
aria-hidden="true"
className="h-2 w-2 flex-none rounded-full"
style={{ background: paintOf(facet, row.key) }}
/>
{(step !== null || unknown) && (
<span className={unknown ? 'text-content-faint' : ''} style={fs(10.5)}>
{step === null ? '?' : stepName(step)}
</span>
)}
</span>
);
const text = step !== null ? stepName(step) : unknown ? '?' : null;
return <GradeChip color={paintOf(facet, row.key)} text={text} faint={unknown} />;
}
/** Where a row's class comes from, said in its tooltip: what OpenStreetMap wrote, or why it is unknown. */
@@ -0,0 +1,31 @@
/**
* FE-TOUR-SAC-CHIP-001 to FE-TOUR-SAC-CHIP-002
*
* A grade as the hike's chip: a dot in its map colour beside its short name,
* the highest SAC step with a question mark when too little is graded, and a
* lone question mark in the grey of the unknown when nothing is.
*/
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { GradeChip, SacStepChip } from './SacStepChip';
const dotOf = (container: HTMLElement) => container.querySelector<HTMLElement>('[aria-hidden="true"]');
describe('SacStepChip', () => {
it('FE-TOUR-SAC-CHIP-001: names the step in its colour, uncertain with a question mark', () => {
const { container } = render(<SacStepChip step={3} />);
expect(screen.getByText('T3')).toBeInTheDocument();
expect(dotOf(container)?.style.background).toBe('var(--hike-sac-t3)');
render(<SacStepChip step={4} uncertain />);
expect(screen.getByText('T4?')).toBeInTheDocument();
});
it('FE-TOUR-SAC-CHIP-002: no known step reads as a faint question mark in the grey of the unknown, never as easy', () => {
const { container } = render(<SacStepChip step={null} />);
expect(screen.getByText('?')).toHaveClass('text-content-faint');
expect(dotOf(container)?.style.background).toBe('var(--bike-surface-unknown)');
const plain = render(<GradeChip color="var(--hike-way-path)" text={null} />);
expect(plain.container.textContent).toBe('');
});
});
@@ -0,0 +1,46 @@
import { fs } from '../../shared/DialogShell';
import { bikePaintCss } from '../bike/bikePaint';
import { hikePaintCss } from './hikePaint';
/**
* A grade as the small white chip the hike's card shows it with: a dot in the
* colour the map draws it in, and its short name beside it. The classes of a
* hike's facets and the SAC step a route reaches share it, so a grade reads
* the same in the planner and in the chat.
*/
/** The SAC steps' paints, by step: T1 first. */
const SAC_PAINTS = ['t1', 't2', 't3', 't4', 't5', 't6'] as const;
/** A step's colour as a CSS variable: its own for T1 to T6, the grey of the unknown otherwise. */
function sacStepPaint(step: number | null): string {
const paint = step === null ? undefined : SAC_PAINTS[step - 1];
return paint ? hikePaintCss(paint) : bikePaintCss('unknown');
}
/** The chip: the dot, and the short name when there is one; faint for what the data does not say. */
export function GradeChip({ color, text, faint = false }: { color: string; text: string | null; faint?: boolean }) {
const width = text !== null && text.length > 2 ? 'px-1.5' : 'w-[34px]';
return (
<span
className={`inline-flex h-[18px] flex-none items-center justify-center gap-1 rounded-full bg-surface-card font-geist font-semibold tabular-nums text-content shadow-sm ring-1 ring-inset ring-edge-faint ${width}`}
>
<span aria-hidden="true" className="h-2 w-2 flex-none rounded-full" style={{ background: color }} />
{text !== null && (
<span className={faint ? 'text-content-faint' : ''} style={fs(10.5)}>
{text}
</span>
)}
</span>
);
}
/**
* The highest SAC step a route reaches ("T3"), with a question mark when too
* little of the way is graded to be sure ("T3?"), or a lone question mark
* when no step is known at all: an ungraded way never reads as an easy one.
*/
export function SacStepChip({ step, uncertain = false }: { step: number | null; uncertain?: boolean }) {
if (step === null) return <GradeChip color={sacStepPaint(null)} text="?" faint />;
return <GradeChip color={sacStepPaint(step)} text={uncertain ? `T${step}?` : `T${step}`} />;
}
@@ -8,7 +8,7 @@ import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { DialogSection, fs } from '../../shared/DialogShell';
import { CardAction, TourNotice } from '../tourParts';
import { linkName } from './packingLinkName';
import { CategoryDot, DimmedLink } from './packingLinkParts';
import { CheckCircle, DimmedLink, PackedBar, PackTile } from './packingLinkParts';
import { TourPackingLinkDialog } from './TourPackingLinkDialog';
import { useTourPackingLink, type TourPackingLinkController } from './useTourPackingLink';
@@ -30,36 +30,11 @@ function SectionPill({ onClick, icon, children }: { onClick: () => void; icon: R
);
}
/** The backpack tile with the item count on its corner, the way a waypoint's number carries its marks. */
function PackTile({ count, color }: { count: number; color: string | null }) {
return (
<span className="relative grid h-9 w-9 flex-none place-items-center rounded-full bg-surface-secondary text-content-muted">
<Backpack size={16} strokeWidth={2} aria-hidden="true" />
<span
aria-hidden="true"
className="absolute -end-1.5 -top-1.5 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-accent px-1 font-geist font-semibold tabular-nums text-accent-text ring-2 ring-surface-card"
style={fs(9.5)}
>
{count}
</span>
{color && (
<span className="absolute -bottom-0.5 -start-0.5 grid h-3.5 w-3.5 place-items-center rounded-full bg-surface-card">
<CategoryDot color={color} />
</span>
)}
</span>
);
}
/** One item as Packing keeps it, read only: ticked off or not, its name and how many. */
function ItemRow({ item }: { item: PackingItem }) {
return (
<li className="flex min-w-0 items-center gap-2 py-1">
<span
className={`grid h-4 w-4 flex-none place-items-center rounded-full ${item.checked ? 'bg-accent text-accent-text' : 'ring-1 ring-inset ring-edge'}`}
>
{!!item.checked && <Check size={10} strokeWidth={3} aria-hidden="true" />}
</span>
<CheckCircle done={!!item.checked} />
<span
className={`min-w-0 flex-1 truncate ${item.checked ? 'text-content-faint line-through' : 'text-content-secondary'}`}
style={fs(12, 'body')}
@@ -109,12 +84,7 @@ function LinkedCard({ packing, onOpenPacking }: { packing: TourPackingLinkContro
</div>
{total > 0 ? (
<>
<div className="h-1 overflow-hidden rounded-full bg-surface-secondary" aria-hidden="true">
<div
className="h-full rounded-full bg-accent transition-[width] duration-300"
style={{ width: `${(linked.packed / total) * 100}%` }}
/>
</div>
<PackedBar packed={linked.packed} total={total} />
<ul className="m-0 flex list-none flex-col p-0">
{linked.items.slice(0, SHOWN_ITEMS).map((item) => (
<ItemRow key={item.id} item={item} />
@@ -1,7 +1,8 @@
import type { TourPackingLink } from '@trek/shared';
import { Package, PowerOff } from 'lucide-react';
import { Backpack, Check, type LucideIcon, Package, PowerOff } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { tintOf, WhiteBadge } from '../../Planner/planParts';
import { fs } from '../../shared/DialogShell';
import { TourNotice } from '../tourParts';
import { linkName } from './packingLinkName';
@@ -40,3 +41,65 @@ export function CountBadge({ count }: { count: number }) {
</span>
);
}
/**
* The round tile of a list with its item count on the corner, the way a
* waypoint's number carries its marks: a backpack for packing, another glyph
* for another list (the chat's to-dos). A category colour sits on the other
* corner when the list is one category of it.
*/
export function PackTile({
count,
color,
icon: Icon = Backpack,
}: {
count: number;
color: string | null;
icon?: LucideIcon;
}) {
return (
<span className="relative grid h-9 w-9 flex-none place-items-center rounded-full bg-surface-secondary text-content-muted">
<Icon size={16} strokeWidth={2} aria-hidden="true" />
<span
aria-hidden="true"
className="absolute -end-1.5 -top-1.5 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-accent px-1 font-geist font-semibold tabular-nums text-accent-text ring-2 ring-surface-card"
style={fs(9.5)}
>
{count}
</span>
{color && (
<span className="absolute -bottom-0.5 -start-0.5 grid h-3.5 w-3.5 place-items-center rounded-full bg-surface-card">
<CategoryDot color={color} />
</span>
)}
</span>
);
}
/**
* The round check of a list row: filled once it is done, an empty ring before.
* `pending` draws a dashed accent ring around it, for a tick a change has not
* made yet.
*/
export function CheckCircle({ done, pending = false }: { done: boolean; pending?: boolean }) {
return (
<span
className={`grid h-4 w-4 flex-none place-items-center rounded-full ${done ? 'bg-accent text-accent-text' : 'ring-1 ring-inset ring-edge'} ${pending ? 'outline-dashed outline-1 outline-offset-2 outline-accent' : ''}`}
>
{done && <Check size={10} strokeWidth={3} aria-hidden="true" />}
</span>
);
}
/** How much of a list is packed, as a thin bar in the accent colour. */
export function PackedBar({ packed, total }: { packed: number; total: number }) {
const share = total > 0 ? Math.min(1, packed / total) : 0;
return (
<div className="h-1 overflow-hidden rounded-full bg-surface-secondary" aria-hidden="true">
<div
className="h-full rounded-full bg-accent transition-[width] duration-300"
style={{ width: `${share * 100}%` }}
/>
</div>
);
}
@@ -27,17 +27,23 @@ function endName(stop: number | undefined, t: Translate): string {
return stop === undefined ? t('tours.planner.shapingPoint') : t('tours.planner.waypointLabel', { n: stop });
}
/** How another way differs from the one taken now, in length and time; nothing it cannot say. */
function VariantDelta({
/**
* How another way differs from the one taken now, in length and time; nothing
* it cannot say. `hint` names what it is compared with, the way taken now
* unless said otherwise.
*/
export function VariantDelta({
variant,
current,
unit,
t,
hint,
}: {
variant: LegVariant;
current: LegVariant;
unit: DistanceUnit;
t: Translate;
hint?: string;
}) {
const delta = variantDelta(variant, current);
const parts = [
@@ -51,7 +57,7 @@ function VariantDelta({
].filter((part) => part.text !== null);
if (parts.length === 0) return null;
return (
<Tooltip label={t('tours.variants.deltaHint')}>
<Tooltip label={hint ?? t('tours.variants.deltaHint')}>
<span className="flex flex-wrap gap-1" data-testid="leg-variant-delta">
{parts.map(({ key, Icon, text }) => (
<SoftPill key={key} icon={<Icon size={10} strokeWidth={2.2} aria-hidden="true" />}>
@@ -5,7 +5,7 @@ import type { Day } from '../../../types'
import { useTranslation } from '../../../i18n'
import { useSettingsStore } from '../../../store/settingsStore'
import { useTourFerrataCrossingStore } from '../../../store/tourFerrataCrossingStore'
import { formatDistance, formatElevation } from '../../../utils/units'
import { formatDistance } from '../../../utils/units'
import ConfirmDialog from '../../shared/ConfirmDialog'
import { DialogButton, NEUTRAL_TINT, fs } from '../../shared/DialogShell'
import EmptyState from '../../shared/EmptyState'
@@ -15,7 +15,7 @@ import { BOX } from '../../Planner/bookings/bookingParts'
import { BarButton, SoftPill } from '../../Planner/planParts'
import TourListRow from '../TourListRow'
import ElevationProfile from '../../shared/ElevationProfile'
import { formatPlannedTourDuration, hikeSourceBadgeLabel, tourSource } from '../tourPresentation'
import { formatPlannedTourDuration, hikeSourceBadgeLabel, profileFocusLabel, tourSource } from '../tourPresentation'
import { CardAction, FoldButton, RailCard, RailFacts, TourDayMenu, TourListCard, TourNotice, TourSection, type TourMetricField } from '../tourParts'
import { movingTimeLabel, tourMetricFacts, withoutHeights } from '../tourFacts'
import { TourDetailsSection, TourNameField } from './TourEditorFields'
@@ -40,7 +40,7 @@ import { stageKeysOfTour } from '../stages/stageSpread'
import { TourStageOverview } from '../stages/TourStageOverview'
import { useTourStageRail } from '../stages/useTourStageRail'
import { useTripStore } from '../../../store/tripStore'
import type { DistanceIndexedProfileSample, RouteProfileFocus } from '../../../utils/routeGeometry'
import type { DistanceIndexedProfileSample } from '../../../utils/routeGeometry'
import type { TourPlannerController, TourPlannerStatus } from './useTourPlanner'
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
import TourConversionSection from '../conversion/TourConversionSection'
@@ -82,13 +82,6 @@ export const RAIL_HEAD = 'flex flex-none items-start gap-2 border-b border-edge-
type Translate = (key: string, params?: Record<string, string | number | null>) => string
function profileFocusLabel(focus: RouteProfileFocus, t: Translate, unit: 'metric' | 'imperial'): string {
return t('tours.planner.inspector.profileFocus', {
distance: formatDistance(focus.distanceMeters / 1000, unit),
elevation: focus.elevationMeters == null ? '-' : formatElevation(focus.elevationMeters, unit),
})
}
/** The title of a rail's head band, with an optional line under it. */
export function RailTitle({ title, sub, aside }: { title: string; sub?: ReactNode; aside?: ReactNode }) {
return (
@@ -1,9 +1,9 @@
// FE-TOUR-LEG-VARIANT-PAINT-001 to FE-TOUR-LEG-VARIANT-PAINT-004: the ways of a compared section
// FE-TOUR-LEG-VARIANT-PAINT-001 to FE-TOUR-LEG-VARIANT-PAINT-005: the ways of a compared section
// and the round hikes offered on the planner map, each in its colour, the one in focus on top
// (RTE-02, RTE-03).
import { renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { drawOrder, useLegVariantLines, variantPaintCss } from './legVariantPaint';
import { drawOrder, useLegVariantLines, useVariantPaint, variantPaintCss } from './legVariantPaint';
import type { LegVariant } from './legVariants';
const variant = (key: LegVariant['key'], lat: number): LegVariant => ({
@@ -87,4 +87,15 @@ describe('legVariantPaint', () => {
const empty = renderHook(() => useLegVariantLines([], null, null, rounds, null));
expect(empty.result.current.route).toEqual([WAYS[1].coordinates, WAYS[2].coordinates]);
});
it('FE-TOUR-LEG-VARIANT-PAINT-005: hands the colours of the ways and their rim resolved, for a map outside the planner', () => {
const { result } = renderHook(() => useVariantPaint());
expect(result.current).toEqual({
current: '#0a84ff',
a: '#f97316',
b: '#d946ef',
c: '#14b8a6',
casing: '#ffffff',
});
});
});
@@ -23,6 +23,11 @@ export function variantPaintCss(key: LegVariantKey): string {
return `var(${VARIANT_PAINT_TOKENS[key]})`;
}
/** The ways' colours and their rim resolved for map paint, again whenever the theme changes. */
export function useVariantPaint(): Readonly<Record<LegVariantKey | 'casing', string>> {
return useMapPaint(VARIANT_PAINT_TOKENS);
}
type Drawn = Parameters<typeof withRouteHighlight>;
/** A way as the map draws it: its line and the key its colour goes by. */
type DrawnWay = Pick<LegVariant, 'key' | 'coordinates'>;
@@ -60,7 +65,7 @@ export function useLegVariantLines(
variants: readonly DrawnWay[] | null,
focus: LegVariantKey | null
) {
const paint = useMapPaint(VARIANT_PAINT_TOKENS);
const paint = useVariantPaint();
return useMemo(() => {
const ordered = variants ? drawOrder(variants, focus) : [];
const highlight = ordered.length
@@ -48,7 +48,7 @@ export function StopDisc({ stop }: { stop: number | undefined }) {
* the route's own icon, the others by their letter. The chosen one carries a
* tick at its corner.
*/
function VariantMark({ variant, chosen }: { variant: Pick<LegVariant, 'key' | 'current'>; chosen: boolean }) {
export function VariantMark({ variant, chosen }: { variant: Pick<LegVariant, 'key' | 'current'>; chosen: boolean }) {
return (
<span
className="relative grid h-7 w-7 flex-none place-items-center rounded-full font-geist font-bold uppercase shadow-sm"
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { estimateBadgeText, estimateLabel, filterTours, formatPlannedTourDuration, hikeSourceBadgeLabel, plannedTotalText, tourDayMinutes, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation'
import { estimateBadgeText, estimateLabel, filterTours, formatPlannedTourDuration, hikeSourceBadgeLabel, plannedTotalText, profileFocusLabel, tourDayMinutes, tourPlannedTimes, tourSource, tourWebsitePresentation } from './tourPresentation'
describe('tourPresentation', () => {
it('formats planned Tour totals using the shared compact duration formatter', () => {
@@ -128,4 +128,11 @@ describe('tourPresentation', () => {
('does not classify unsafe URL %s', value => {
expect(tourWebsitePresentation(value)).toBeNull()
})
it('FE-TOUR-PRES-001: says a spot of the profile by how far along and how high, without a height where none is known', () => {
const t = vi.fn((key: string, params?: Record<string, unknown>) => `${key} ${params?.distance} ${params?.elevation}`)
const focus = { distanceMeters: 2500, elevationMeters: 1234.4, lat: 47.6, lng: 12.6, sampleIndex: 3 }
expect(profileFocusLabel(focus, t, 'metric')).toBe('tours.planner.inspector.profileFocus 2.5 km 1234 m')
expect(profileFocusLabel({ ...focus, elevationMeters: null }, t, 'metric')).toBe('tours.planner.inspector.profileFocus 2.5 km -')
})
})
@@ -1,6 +1,8 @@
import type { TourListItem, TourOrigin } from '@trek/shared'
import { tourTimeEstimate, tourWebsiteSchema, type TourTimeEstimate } from '@trek/shared'
import { formatDurationShort } from '@trek/shared/roadtrip'
import type { RouteProfileFocus } from '../../utils/routeGeometry'
import { formatDistance, formatElevation } from '../../utils/units'
import { walkTimesOf, type WalkTimes } from './planner/stationModel'
import type { TourMetricField } from './tourParts'
@@ -207,3 +209,15 @@ export function statusLabel(status: TourFilter, t: Translate): string {
if (status === 'planned') return t('places.planned')
return t('places.all')
}
/**
* A spot of a route's height profile in words, as the planner's profile and
* the chat's tour card both say it: how far along, and how high where the
* heights are known.
*/
export function profileFocusLabel(focus: RouteProfileFocus, t: Translate, unit: 'metric' | 'imperial'): string {
return t('tours.planner.inspector.profileFocus', {
distance: formatDistance(focus.distanceMeters / 1000, unit),
elevation: focus.elevationMeters == null ? '-' : formatElevation(focus.elevationMeters, unit),
})
}
@@ -164,4 +164,51 @@ describe('ElevationProfile', () => {
expect(band).toHaveAttribute('width', '220')
expect(band).toHaveAttribute('fill', 'var(--map-ferrata)')
})
it('FE-ELEV-001: a band that brings its own colour is drawn in it, a little stronger than the via ferrata tint', () => {
const { container } = render(
<ElevationProfile samples={samples} color="currentColor" gradientId="graded" bands={[{ fromM: 0, toM: 150, color: 'var(--hike-sac-t4)' }]} />,
)
const band = container.querySelector('[data-profile-band]')
expect(band).toHaveAttribute('fill', 'var(--hike-sac-t4)')
expect(band).toHaveAttribute('fill-opacity', '0.22')
})
it('FE-ELEV-002: other ways are drawn thin on axes shared with the route, so a longer one reaches the right edge', () => {
const longer = [
{ distanceMeters: 0, elevationMeters: 100, lat: 48, lng: 11 },
{ distanceMeters: 600, elevationMeters: 300, lat: 48.06, lng: 11.06 },
]
const { container } = render(
<ElevationProfile samples={samples} color="currentColor" gradientId="compared" compare={[{ key: 'b', samples: longer, color: 'var(--hike-variant-b)' }]} />,
)
const other = container.querySelector('[data-profile-compare="b"]')
expect(other).toHaveAttribute('d', 'M0.0,96.0 L440.0,4.0')
expect(other).toHaveAttribute('stroke', 'var(--hike-variant-b)')
const line = container.querySelector('path[stroke="currentColor"]')?.getAttribute('d')
expect(line).toBe('M0.0,96.0 L73.3,50.0 L220.0,73.0')
expect(container.querySelector('path[fill^="url("]')?.getAttribute('d')).toContain('L220.0,100 L0.0,100 Z')
})
it('FE-ELEV-003: with the readout left to the caller no box is drawn over the chart, the focus line stays', () => {
const focus = { distanceMeters: 100, elevationMeters: 200, lat: 48.01, lng: 11.01, sampleIndex: 1 }
const { container } = render(
<ElevationProfile samples={samples} color="currentColor" gradientId="quiet" ariaLabel="Elevation profile" focus={focus} onFocusChange={vi.fn()} readout="none" />,
)
expect(container.querySelector('[data-profile-focus-readout]')).toBeNull()
expect(container.querySelector('[data-profile-focus-line]')).not.toBeNull()
})
it('FE-ELEV-004: keeps the spot a finger lifted from, while a mouse leaving clears it as before', () => {
const onFocus = vi.fn()
render(
<ElevationProfile samples={samples} color="currentColor" gradientId="kept" ariaLabel="Elevation profile" onFocusChange={onFocus} keepFocusOnLift />,
)
const profile = screen.getByRole('group', { name: 'Elevation profile' })
fireEvent.pointerLeave(profile, { pointerType: 'touch' })
fireEvent.pointerCancel(profile, { pointerType: 'touch' })
expect(onFocus).not.toHaveBeenCalledWith(null)
fireEvent.pointerLeave(profile, { pointerType: 'mouse' })
expect(onFocus).toHaveBeenLastCalledWith(null)
})
})
@@ -11,19 +11,41 @@ interface ElevationProfileProps {
focus?: RouteProfileFocus | null
onFocusChange?: (focus: RouteProfileFocus | null) => void
formatFocus?: (focus: RouteProfileFocus) => string
/** Stretches to mark behind the line, in metres along the route (the via ferratas a tour climbs). */
bands?: ReadonlyArray<{ fromM: number; toM: number }>
/**
* Stretches to mark behind the line, in metres along the route (the via
* ferratas a tour climbs, the hard grades of a hike), in their own colour
* where they bring one.
*/
bands?: ReadonlyArray<{ fromM: number; toM: number; color?: string }>
/** Spots to mark across the profile, in metres along the route (where the stages of a multi-day tour end). */
marks?: readonly number[]
/** Other ways of the same route, drawn thin on the same axes so they compare at a glance. */
compare?: ReadonlyArray<{ key: string; samples: readonly DistanceIndexedProfileSample[]; color: string }>
/** Where the spot in focus is said: in a box over the chart, or nowhere (the caller says it itself). */
readout?: 'tooltip' | 'none'
/** Keeps the spot a finger lifted from in focus, so it can still be read; a mouse leaving clears it as ever. */
keepFocusOnLift?: boolean
}
/** A route profile whose horizontal axis represents travelled distance. */
export default function ElevationProfile({ samples, color, height = 100, gradientId, ariaLabel, focus = null, onFocusChange, formatFocus, bands, marks }: ElevationProfileProps) {
export default function ElevationProfile({ samples, color, height = 100, gradientId, ariaLabel, focus = null, onFocusChange, formatFocus, bands, marks, compare, readout = 'tooltip', keepFocusOnLift = false }: ElevationProfileProps) {
const [keyboardAnnouncement, setKeyboardAnnouncement] = useState('')
const pointerFrameRef = useRef<number | null>(null)
const pendingPointerDistanceRef = useRef<number | null>(null)
const width = 440
const chart = useMemo(() => elevationProfileModel.prepareElevationProfileChart(samples, width, height), [samples, height])
const domain = useMemo(
() => compare?.length ? elevationProfileModel.sharedProfileDomain([samples, ...compare.map(other => other.samples)]) : null,
[samples, compare],
)
const chart = useMemo(() => elevationProfileModel.prepareElevationProfileChart(samples, width, height, domain), [samples, height, domain])
const compared = useMemo(
() => (compare ?? []).map(other => ({
key: other.key,
color: other.color,
path: elevationProfileModel.prepareElevationProfileChart(other.samples, width, height, domain).linePath,
})),
[compare, height, domain],
)
const interactive = samples.length >= 2 && !!onFocusChange && chart.hasGeographicCoordinates
const focusAtDistance = (distanceMeters: number): RouteProfileFocus | null =>
focusRouteProfileAtDistance(samples, distanceMeters)
@@ -66,8 +88,9 @@ export default function ElevationProfile({ samples, color, height = 100, gradien
: `${Math.round(next.distanceMeters)} m · ${Math.round(next.elevationMeters)} m`))
else setKeyboardAnnouncement('')
}
const clearPointerFocus = (): void => {
const clearPointerFocus = (event: React.PointerEvent<SVGSVGElement>): void => {
cancelPendingPointerFocus()
if (keepFocusOnLift && event.pointerType !== 'mouse') return
onFocusChange?.(null)
setKeyboardAnnouncement('')
}
@@ -124,12 +147,16 @@ export default function ElevationProfile({ samples, color, height = 100, gradien
{bands?.map(band => (
<rect key={`${band.fromM}-${band.toM}`} data-profile-band="true" x={(band.fromM / chart.maxDistance) * width} y={0}
width={Math.max(1, ((band.toM - band.fromM) / chart.maxDistance) * width)} height={height}
fill="var(--map-ferrata)" fillOpacity={0.16} />
fill={band.color ?? 'var(--map-ferrata)'} fillOpacity={band.color ? 0.22 : 0.16} />
))}
{marks?.map(atM => (
<line key={atM} data-profile-mark="true" x1={(atM / chart.maxDistance) * width} x2={(atM / chart.maxDistance) * width}
y1={0} y2={height} stroke="var(--accent)" strokeOpacity={0.55} strokeWidth={1.5} vectorEffect="non-scaling-stroke" />
))}
{compared.map(other => (
<path key={other.key} data-profile-compare={other.key} d={other.path} fill="none" stroke={other.color} strokeOpacity={0.55}
strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
))}
<path d={chart.areaPath} fill={`url(#${gradientId})`} />
<path d={chart.linePath} fill="none" stroke={color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
{focusX != null && (
@@ -144,7 +171,7 @@ export default function ElevationProfile({ samples, color, height = 100, gradien
<circle data-profile-focus-point="true" cx={`${(focusX / width) * 100}%`} cy={focusY} r={4} fill={color} stroke="var(--bg-card)" strokeWidth={2} />
</svg>
)}
{interactive && focusX != null && (
{interactive && readout === 'tooltip' && focusX != null && (
<div
data-profile-focus-readout="true"
className="absolute top-1 z-10 overflow-hidden rounded-lg border border-edge-faint bg-surface-card px-2.5 py-1 text-content shadow-elevated"
@@ -16,10 +16,37 @@ export interface ElevationProfileChartModel {
areaPath: string
}
/** The axes a chart is drawn on: its lowest and highest heights and its length. */
interface ElevationProfileDomain {
minElevation: number
maxElevation: number
maxDistance: number
}
/**
* One set of axes for several profiles drawn over each other (the ways a
* route can take), so a way that climbs higher or runs longer looks it.
* Null when none of them has a sample.
*/
export function sharedProfileDomain(
profiles: ReadonlyArray<readonly DistanceIndexedProfileSample[]>,
): ElevationProfileDomain | null {
const samples = profiles.flat()
if (samples.length === 0) return null
const elevations = samples.map(sample => sample.elevationMeters)
return {
minElevation: Math.min(...elevations),
maxElevation: Math.max(...elevations),
maxDistance: Math.max(...profiles.map(profile => profile[profile.length - 1]?.distanceMeters ?? 0)),
}
}
/** The chart of a profile, on its own axes or on the `domain` it shares with others. */
export function prepareElevationProfileChart(
samples: readonly DistanceIndexedProfileSample[],
width: number,
height: number,
domain: ElevationProfileDomain | null = null,
): ElevationProfileChartModel {
if (samples.length === 0) return {
minElevation: 0,
@@ -32,10 +59,10 @@ export function prepareElevationProfileChart(
areaPath: '',
}
const elevations = samples.map(sample => sample.elevationMeters)
const minElevation = Math.min(...elevations)
const maxElevation = Math.max(...elevations)
const minElevation = domain?.minElevation ?? Math.min(...elevations)
const maxElevation = domain?.maxElevation ?? Math.max(...elevations)
const elevationSpan = Math.max(maxElevation - minElevation, 1)
const maxDistance = samples[samples.length - 1].distanceMeters || 1
const maxDistance = (domain?.maxDistance ?? samples[samples.length - 1].distanceMeters) || 1
const points = samples.map(sample => ({
x: (sample.distanceMeters / maxDistance) * width,
y: height - ((sample.elevationMeters - minElevation) / elevationSpan) * (height - 8) - 4,
@@ -50,6 +77,9 @@ export function prepareElevationProfileChart(
hasGeographicCoordinates: samples.every(sample => Number.isFinite(sample.lat) && Number.isFinite(sample.lng)),
points,
linePath,
areaPath: `${linePath} L${width},${height} L0,${height} Z`,
// On shared axes a shorter way ends before the right edge: its area closes under its own ends.
areaPath: domain
? `${linePath} L${points[points.length - 1].x.toFixed(1)},${height} L${points[0].x.toFixed(1)},${height} Z`
: `${linePath} L${width},${height} L0,${height} Z`,
}
}
@@ -18,6 +18,8 @@ import AssistantMemoryDialog from '../../../../components/Assistant/AssistantMem
import AssistantSurfaceLazy from '../../../../components/Assistant/AssistantSurfaceLazy';
import { AI_SETTINGS_PATH } from '../../../../components/Assistant/assistantParts';
import type { EntityTarget } from '../../../../components/Assistant/cards/cardTypes';
import RoutePeekLazy from '../../../../components/Assistant/map/RoutePeekLazy';
import { moveRoutePeek } from '../../../../components/Assistant/map/routePeekControl';
import { useAssistantHintsSetting } from '../../../../components/Assistant/useAssistantHints';
import { useAssistantLiveStep } from '../../../../components/Assistant/useAssistantLiveStep';
import { useAssistantThread } from '../../../../components/Assistant/useAssistantThread';
@@ -322,7 +324,11 @@ function BackToChat({ tripId, running }: { tripId: number; running: boolean }) {
<div className="absolute inset-x-0 bottom-[calc(env(safe-area-inset-bottom,0px)+86px)] z-[42] mx-auto flex w-fit max-w-[calc(100%-2rem)] items-center gap-2">
<button
type="button"
onClick={() => setMinimized(false)}
onClick={() => {
// The route's peek belongs to the map: back in the chat, the card shows the route.
moveRoutePeek(tripId, null);
setMinimized(false);
}}
aria-label={t('assistant.mobile.backToChat')}
className={`flex h-11 min-w-0 items-center gap-2 pe-4 ps-3 text-m-ink ${glass}`}
>
@@ -344,8 +350,9 @@ function BackToChat({ tripId, running }: { tripId: number; running: boolean }) {
* The assistant on the phone: a full-screen chat over the trip, opened from
* the Mehr sheet's tile or "Ask" on a place. It stays mounted while "Show on
* map" hands the screen to the map, so an answer being written goes on and
* the pill above the dock brings the chat back as it was. The keyboard lifts
* the composer, and the back gesture closes the chat rather than the trip.
* the pill above the dock brings the chat back as it was; a route shown on
* the map peeks over it above the pill. The keyboard lifts the composer, and
* the back gesture closes the chat rather than the trip.
*
* Nothing shows until the assistant has a connection this user can use.
*/
@@ -353,6 +360,7 @@ export default function MAssistantScreen({ planner, shell }: MTripSheetsProps) {
const available = useAssistantStore((s) => s.status?.available === true);
const open = useAssistantStore((s) => s.window.open);
const minimized = useAssistantStore((s) => s.window.minimized);
const peeking = useAssistantStore((s) => s.routePeek?.tripId === planner.tripId);
const running = useAssistantStore((s) => s.byTrip[planner.tripId]?.isRunning === true);
const loadStatus = useAssistantStore((s) => s.loadStatus);
const hops = useMapHops({ planner, shell });
@@ -366,6 +374,14 @@ export default function MAssistantScreen({ planner, shell }: MTripSheetsProps) {
return (
<>
<AssistantScreen tripId={planner.tripId} tripTitle={planner.trip.title} hidden={minimized} hops={hops} />
{minimized && peeking && (
<RoutePeekLazy
tripId={planner.tripId}
onOpen={hops.open}
onFocusDay={hops.focusDay}
onRevealMap={hops.revealMap}
/>
)}
{minimized && <BackToChat tripId={planner.tripId} running={running} />}
</>
);
@@ -427,9 +427,11 @@ export default function MMapArea({ planner, shell }: MMapAreaProps) {
fitPadding={fitPadding}
// The assistant's suggestions on the plan map. A tap marks one, as a hover does on
// the desk; the hover itself stays off, since the emulated leave after a tap would
// take the mark straight off again.
// take the mark straight off again. The same goes for the routes its cards draw: a
// tap on one lights its card, puts its spot in focus and opens the route's peek.
{...(onStage ? {} : planner.assistantMap)}
onAssistantGhostHover={undefined}
onAssistantRouteHover={undefined}
/>
{/* Floating map chrome — only while the map view is front-most. The POI bar
@@ -0,0 +1,123 @@
/**
* FE-AI-TOURLINK-001 to FE-AI-TOURLINK-005
*
* The chat's link to a saved tour (`?tour=`) on the desktop planner: the
* Tours tab opens and then the tour, a tour the loaded list does not hold
* yet is asked for once more and given up after three seconds, a tour being edited
* is never discarded for it, and with Tours off or on the phone the tour
* shows as its place. The link always leaves the URL.
*/
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { DeepLinkProps } from './deepLinkTypes';
import { useTourDeepLink } from './useTourDeepLink';
interface Setup {
query?: string;
activeTab?: string;
tours?: { place_id: number }[];
toursOn?: boolean;
isMobile?: boolean;
unsaved?: boolean;
ready?: boolean;
}
function setup(over: Setup = {}) {
const params = new URLSearchParams(over.query ?? '?tour=41');
const setSearchParams = vi.fn((update: (current: URLSearchParams) => URLSearchParams) => {
update(params);
});
const planner = {
activeTab: over.activeTab ?? 'tour-planner',
enabledAddons: { tours: over.toursOn ?? true },
handleTabChange: vi.fn(),
invalidateTourPlaceIds: vi.fn(),
isMobile: over.isMobile ?? false,
setSelectedPlaceId: vi.fn(),
t: (key: string) => key,
toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() },
tours: over.tours ?? [{ place_id: 41 }],
tourDataReady: over.ready ?? true,
};
const workspace = { openTourInPlanner: vi.fn(), tourPlanner: { hasUnsavedChanges: over.unsaved ?? false } };
const props = (): DeepLinkProps => ({
planner: planner as unknown as DeepLinkProps['planner'],
workspace: workspace as unknown as DeepLinkProps['workspace'],
searchParams: params,
setSearchParams: setSearchParams as unknown as DeepLinkProps['setSearchParams'],
});
const hook = renderHook(() => useTourDeepLink(props()));
return { params, planner, workspace, hook };
}
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe('useTourDeepLink', () => {
it('FE-AI-TOURLINK-001: on the Tours tab the tour opens and the link leaves the URL', () => {
const { params, workspace } = setup();
expect(workspace.openTourInPlanner).toHaveBeenCalledWith(41);
expect(params.get('tour')).toBeNull();
});
it("FE-AI-TOURLINK-002: on another tab the Tours tab opens first, unless the chat's tab link is switching already", () => {
const away = setup({ activeTab: 'plan' });
expect(away.planner.handleTabChange).toHaveBeenCalledWith('tour-planner');
expect(away.workspace.openTourInPlanner).not.toHaveBeenCalled();
expect(away.params.get('tour')).toBe('41');
away.planner.activeTab = 'tour-planner';
away.hook.rerender();
expect(away.workspace.openTourInPlanner).toHaveBeenCalledWith(41);
const switching = setup({ activeTab: 'plan', query: '?tab=tour-planner&tour=41' });
expect(switching.planner.handleTabChange).not.toHaveBeenCalled();
});
it('FE-AI-TOURLINK-003: a tour not in the list yet is asked for once, opens when it arrives, and is given up after three seconds', () => {
const late = setup({ tours: [] });
expect(late.planner.invalidateTourPlaceIds).toHaveBeenCalledTimes(1);
late.planner.tours = [{ place_id: 41 }];
late.hook.rerender();
expect(late.workspace.openTourInPlanner).toHaveBeenCalledWith(41);
act(() => vi.advanceTimersByTime(3000));
expect(late.planner.toast.error).not.toHaveBeenCalled();
const loading = setup({ tours: [], ready: false });
act(() => vi.advanceTimersByTime(5000));
expect(loading.planner.invalidateTourPlaceIds).not.toHaveBeenCalled();
expect(loading.planner.toast.error).not.toHaveBeenCalled();
expect(loading.params.get('tour')).toBe('41');
const missing = setup({ tours: [] });
missing.hook.rerender();
expect(missing.planner.invalidateTourPlaceIds).toHaveBeenCalledTimes(1);
act(() => vi.advanceTimersByTime(3000));
expect(missing.planner.toast.error).toHaveBeenCalledWith('tours.planner.openFailed');
expect(missing.params.get('tour')).toBeNull();
expect(missing.workspace.openTourInPlanner).not.toHaveBeenCalled();
});
it('FE-AI-TOURLINK-004: a tour being edited is kept, and the planner says to save or discard it first', () => {
const { planner, workspace, params } = setup({ unsaved: true });
expect(planner.toast.warning).toHaveBeenCalledWith('assistant.card.tour.unsaved');
expect(workspace.openTourInPlanner).not.toHaveBeenCalled();
expect(params.get('tour')).toBeNull();
});
it('FE-AI-TOURLINK-005: with Tours off or on the phone the tour shows as its place; a broken link is only dropped', () => {
const off = setup({ toursOn: false });
expect(off.planner.setSelectedPlaceId).toHaveBeenCalledWith(41);
expect(off.planner.handleTabChange).not.toHaveBeenCalled();
expect(off.params.get('tour')).toBeNull();
const phone = setup({ isMobile: true, activeTab: 'plan' });
expect(phone.planner.setSelectedPlaceId).toHaveBeenCalledWith(41);
const broken = setup({ query: '?tour=abc' });
expect(broken.params.get('tour')).toBeNull();
expect(broken.workspace.openTourInPlanner).not.toHaveBeenCalled();
expect(broken.planner.setSelectedPlaceId).not.toHaveBeenCalled();
});
});
@@ -1,9 +1,102 @@
import { useCallback, useEffect, useRef } from 'react';
import type { DeepLinkProps } from './deepLinkTypes';
/** How long a tour the chat just made may take to reach the tour list before the link gives up on it. */
const LATE_TOUR_MS = 3000;
/**
* Where the tours open a tour the chat points at (`?tour=`). Nothing yet: the tour cards
* fill it in, and until then the planner keeps its own links.
* A tour the chat points at (`?tour=<placeId>`, from "Open in Tours"), on
* the desktop planner: the Tours tab opens and the tour opens in it, in the
* editor or as it came in, as a click on it in the tour list does. A tour
* the chat has only just made can beat the broadcast that brings it into the
* list, so once the list has loaded it is asked for once more, and only a
* tour still missing three seconds later says it could not be opened. A
* tour being edited is never discarded for it: the planner says to save or
* discard first. With Tours switched off, or on the phone, the tour shows
* as its place on the plan instead. The link leaves the URL once taken,
* whichever way.
*/
export function useTourDeepLink(_links: DeepLinkProps): void {
// Filled in by the tour cards.
export function useTourDeepLink({ planner, workspace, searchParams, setSearchParams }: DeepLinkProps): void {
const raw = searchParams.get('tour');
const placeId = raw !== null && /^\d{1,12}$/.test(raw) ? Number(raw) : null;
const tabPending = searchParams.get('tab') === 'tour-planner';
const { activeTab, enabledAddons, handleTabChange, invalidateTourPlaceIds, isMobile, setSelectedPlaceId } = planner;
const { t, toast, tourDataReady, tours } = planner;
const { openTourInPlanner, tourPlanner } = workspace;
const unsaved = tourPlanner.hasUnsavedChanges;
const known = placeId !== null && tours.some((tour) => tour.place_id === placeId);
/** The tour the list was asked for again, and until when it may still arrive. */
const asked = useRef<{ placeId: number; until: number } | null>(null);
const drop = useCallback(() => {
setSearchParams(
(current) => {
current.delete('tour');
return current;
},
{ replace: true }
);
}, [setSearchParams]);
useEffect(() => {
if (raw === null) return;
if (placeId === null) {
drop();
return;
}
if (!enabledAddons.tours || isMobile) {
setSelectedPlaceId(placeId);
drop();
return;
}
if (activeTab !== 'tour-planner') {
// The chat's `?tab=` link is switching already; otherwise this link switches itself.
if (!tabPending) handleTabChange('tour-planner');
return;
}
if (unsaved) {
toast.warning(t('assistant.card.tour.unsaved'));
drop();
return;
}
if (known) {
asked.current = null;
openTourInPlanner(placeId);
drop();
return;
}
// The list itself is still on its way: wait for it before counting the tour late.
if (!tourDataReady) return;
if (asked.current?.placeId !== placeId) {
asked.current = { placeId, until: Date.now() + LATE_TOUR_MS };
invalidateTourPlaceIds();
}
const timer = window.setTimeout(
() => {
asked.current = null;
toast.error(t('tours.planner.openFailed'));
drop();
},
Math.max(0, asked.current.until - Date.now())
);
return () => window.clearTimeout(timer);
}, [
activeTab,
drop,
enabledAddons.tours,
handleTabChange,
invalidateTourPlaceIds,
isMobile,
known,
openTourInPlanner,
placeId,
raw,
setSelectedPlaceId,
t,
tabPending,
toast,
tourDataReady,
unsaved,
]);
}
@@ -1,14 +1,17 @@
/**
* FE-PLANNER-AI-MAP-001 to FE-PLANNER-AI-MAP-006
* FE-PLANNER-AI-MAP-001 to FE-PLANNER-AI-MAP-009
*
* The planner's link between the assistant and its map: which suggestions
* stand as ghosts and which mark a real pin, when the map may move on its
* own (setting, lock, reduced motion, the phone's folded chat), the "before"
* view of an applied block, and the pop of a place that arrives.
* view of an applied block, the pop of a place that arrives, and the routes
* the chat's cards draw beside them.
*/
import { act, renderHook } from '@testing-library/react';
import type { AssistantMessage, AssistantMessagePart, Trip } from '@trek/shared';
import type { AssistantMessage, AssistantMessagePart, TourRouteCard, Trip } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { HIKE } from '../../../tests/helpers/assistantTour';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
@@ -27,7 +30,7 @@ function visit(id: number, dayId: number, placeId = id * 10): Assignment {
return { id, day_id: dayId, place_id: placeId, order_index: 0, place: stop } as Assignment;
}
function held(id: string, effect: object, approval: object = {}): AssistantMessagePart[] {
function held(id: string, effect: object, approval: object = {}, card?: TourRouteCard): AssistantMessagePart[] {
return [
{ type: 'tool-call', toolCallId: id, toolName: 'x', args: {}, approval: { id, ...approval } },
{
@@ -41,6 +44,7 @@ function held(id: string, effect: object, approval: object = {}): AssistantMessa
placeIds: [],
undoable: true,
effect,
...(card ? { card } : {}),
},
} as AssistantMessagePart,
];
@@ -304,3 +308,30 @@ describe('useAssistantCompareView', () => {
expect(before.result.current.places.map((p) => p.id)).toEqual([1]);
});
});
describe("useAssistantMapLink: the chat's routes", () => {
it('FE-PLANNER-AI-MAP-008: a held tour whose card draws its way stands as that line, not as a pin at its start', () => {
setMessages([...held('t1', NEW, {}, { ...HIKE, source: 'pending' }), ...held('a', NEW)]);
const { result } = link();
expect(result.current.assistantGhosts?.map((g) => g.id)).toEqual(['a']);
expect(result.current.assistantRouteLines?.map((line) => line.id)).toEqual(['t1:pending:route']);
expect(result.current.routeProfileFocus).toBeNull();
act(() => result.current.onAssistantRouteHover?.('t1'));
expect(useAssistantStore.getState().mapLink).toEqual({ tripId: TRIP, hoveredId: 't1', source: 'map' });
expect(result.current.assistantRouteLines?.[0]?.hot).toBe(true);
});
it('FE-PLANNER-AI-MAP-009: a trip place a card points at pulses its own pin; one off the map shows as a pin of its own', () => {
const { result } = link();
const spot = { tripId: TRIP, ref: 'r:0', lat: 41.01, lng: 12, name: 'Place 1' };
act(() => useAssistantStore.getState().setPreviewPin({ ...spot, placeId: 1 }));
expect(result.current.assistantGhosts).toEqual([]);
expect(result.current.assistantPulsePlaceId).toBe(1);
expect(result.current.assistantHoveredId).toBeNull();
act(() => useAssistantStore.getState().setPreviewPin({ ...spot, placeId: 2 }));
expect(result.current.assistantGhosts).toEqual([{ id: 'preview:r:0', lat: 41.01, lng: 12, name: 'Place 1' }]);
expect(result.current.assistantPulsePlaceId).toBeNull();
});
});
@@ -18,6 +18,7 @@ import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
import type { AssignmentsMap, Category, Place } from '../../types';
import { prefersReducedMotion } from '../../utils/reducedMotion';
import { useAssistantRouteLines } from './useAssistantRouteLines';
/** Moves the map was not asked for come at most this often while the assistant works. */
const AUTO_CAMERA_GAP_MS = 2000;
@@ -73,8 +74,10 @@ interface MapLinkOptions {
/**
* Everything the assistant shows on the trip map, as props for MapViewAuto:
* its waiting suggestions as ghosts tied to their rows in the chat, the
* camera moves it asks for, and the pop of a place that arrives from an
* applied suggestion. Desktop and phone both get it through useTripPlanner.
* routes its cards show (useAssistantRouteLines), the camera moves it asks
* for, and the pop of a place that arrives from an applied suggestion. A
* spot a card points at is a pin of its own, or the pulse of a trip place's
* own pin. Desktop and phone both get it through useTripPlanner.
*/
export function useAssistantMapLink(options: MapLinkOptions): AssistantMapProps {
const { tripId, isMobile, mapLocked, mapPlaces, assignments, markersLocked, selectDay } = options;
@@ -82,28 +85,38 @@ export function useAssistantMapLink(options: MapLinkOptions): AssistantMapProps
const categories = useTripStore((s) => s.categories);
const candidates = useGhostCandidates(tripId);
const knownIds = useMemo(() => new Set(places.map((p) => p.id)), [places]);
const live = useMemo(() => liveGhosts(candidates, knownIds), [candidates, knownIds]);
const preview = useAssistantStore((s) => (s.preview?.tripId === tripId ? s.preview : null));
const linkId = useAssistantStore((s) => (s.mapLink?.tripId === tripId ? s.mapLink.hoveredId : null));
const setMapHover = useAssistantStore((s) => s.setMapHover);
const { ghosts, existing } = useMemo(
() => ghostPins(live, { places, categories, onMap: new Set(mapPlaces.map((p) => p.id)) }),
[categories, live, mapPlaces, places]
const autoAllowed = useAutoCameraAllowed(isMobile, mapLocked);
const route = useAssistantRouteLines({ tripId, isMobile, autoAllowed });
const { hiddenGhosts, cardGhosts } = route;
// A change whose card draws its own line stands on the map as that line, not as a pin at its start.
const live = useMemo(
() => liveGhosts(candidates, knownIds).filter((c) => !hiddenGhosts.has(c.toolCallId)),
[candidates, hiddenGhosts, knownIds]
);
const previewId = preview ? `preview:${preview.ref}` : null;
const onMapIds = useMemo(() => new Set(mapPlaces.map((p) => p.id)), [mapPlaces]);
const { ghosts, existing } = useMemo(
() => ghostPins(live, { places, categories, onMap: onMapIds }),
[categories, live, onMapIds, places]
);
const previewPlace = preview?.placeId !== undefined && onMapIds.has(preview.placeId) ? preview.placeId : null;
const previewId = preview && previewPlace === null ? `preview:${preview.ref}` : null;
const shownGhosts = useMemo(() => {
if (!preview || !previewId) return ghosts;
return [...ghosts, { id: previewId, lat: preview.lat, lng: preview.lng, name: preview.name }];
}, [ghosts, preview, previewId]);
const all = cardGhosts.length > 0 ? [...ghosts, ...cardGhosts] : ghosts;
if (!preview || !previewId) return all;
return [...all, { id: previewId, lat: preview.lat, lng: preview.lng, name: preview.name }];
}, [cardGhosts, ghosts, preview, previewId]);
const onGhostHover = useCallback((id: string | null) => setMapHover(tripId, id, 'map'), [setMapHover, tripId]);
const onGhostClick = useCallback((id: string) => setMapHover(tripId, id, 'map'), [setMapHover, tripId]);
const autoAllowed = useAutoCameraAllowed(isMobile, mapLocked);
useAutoCamera({ tripId, live, ghosts, assignments, autoAllowed });
const assistantCamera = useCameraProp(tripId, isMobile);
const poppedPlaceIds = useArrivalPop(tripId, knownIds, selectDay, autoAllowed);
const pulse = previewPlace ?? (linkId ? (existing.get(linkId) ?? null) : null);
return useMemo(
() => ({
@@ -111,20 +124,28 @@ export function useAssistantMapLink(options: MapLinkOptions): AssistantMapProps
assistantHoveredId: previewId ?? linkId,
onAssistantGhostHover: onGhostHover,
onAssistantGhostClick: onGhostClick,
assistantPulsePlaceId: linkId ? (existing.get(linkId) ?? null) : null,
assistantPulsePlaceId: pulse,
assistantCamera,
poppedPlaceIds,
markersLocked,
assistantRouteLines: route.assistantRouteLines,
onAssistantRouteHover: route.onAssistantRouteHover,
onAssistantRoutePoint: route.onAssistantRoutePoint,
routeProfileFocus: route.routeProfileFocus,
}),
[
assistantCamera,
existing,
linkId,
markersLocked,
onGhostClick,
onGhostHover,
poppedPlaceIds,
previewId,
pulse,
route.assistantRouteLines,
route.onAssistantRouteHover,
route.onAssistantRoutePoint,
route.routeProfileFocus,
shownGhosts,
]
);
@@ -0,0 +1,180 @@
/**
* FE-PLANNER-AI-ROUTE-001 to FE-PLANNER-AI-ROUTE-007
*
* The chat's routes on the plan's map: answers laid out as the chat lays
* them out, the ways in the theme's colours with the chosen one solid, the
* pointer lighting a route's card and moving its profile's spot (once a
* frame), a tap on the phone opening the route's peek, a saved tour and a
* pointed-at drive drawn while lit, and one move of the map per new answer
* (none for what the chat showed when the planner opened).
*/
import { act, renderHook } from '@testing-library/react';
import type { AssistantMessage, AssistantMessagePart, TourRouteCard, Trip } from '@trek/shared';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { GENTLE, HIKE, HIKE_LINE, SAVED, TOUR_LIST } from '../../../tests/helpers/assistantTour';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import type { Place } from '../../types';
import { answerLayout, routeFocusAt, useAssistantRouteLines } from './useAssistantRouteLines';
const TRIP = 4;
const ASSISTANT = useAssistantStore.getState();
const TRIPS = useTripStore.getState();
const PAINT = {
'--hike-variant-current': '#0a84ff',
'--hike-variant-a': '#f97316',
'--hike-variant-b': '#d946ef',
'--bike-highlight-casing': '#ffffff',
};
function read(toolCallId: string, card: TourRouteCard): AssistantMessagePart[] {
return [
{ type: 'tool-call', toolCallId, toolName: 'propose_hike', args: {}, result: {} },
{ type: 'data-trek-card', data: { toolCallId, card } },
];
}
function setMessages(parts: AssistantMessagePart[], over: Partial<ReturnType<typeof chatOf>> = {}) {
const message: AssistantMessage = {
id: 'msg-1',
role: 'assistant',
parts,
status: 'complete',
createdAt: '2026-10-21T08:00:00.000Z',
};
useAssistantStore.setState((s) => ({ byTrip: { [TRIP]: { ...chatOf(s, TRIP), messages: [message], ...over } } }));
}
const WAYS = [...read('call_1', HIKE), ...read('call_2', GENTLE)];
function lines(props: { isMobile?: boolean; autoAllowed?: boolean } = {}) {
return renderHook(
(p: { isMobile?: boolean; autoAllowed?: boolean }) =>
useAssistantRouteLines({ tripId: TRIP, isMobile: p.isMobile ?? false, autoAllowed: p.autoAllowed ?? true }),
{ initialProps: props }
);
}
beforeEach(() => {
for (const [token, value] of Object.entries(PAINT)) document.documentElement.style.setProperty(token, value);
useTripStore.setState({ trip: { id: TRIP } as Trip, places: [] });
// One frame at once: what the pointer asks for lands before the assertion.
vi.stubGlobal('requestAnimationFrame', (run: FrameRequestCallback) => {
run(0);
return 1;
});
});
afterEach(() => {
for (const token of Object.keys(PAINT)) document.documentElement.style.removeProperty(token);
vi.unstubAllGlobals();
useAssistantStore.setState(ASSISTANT, true);
useTripStore.setState(TRIPS, true);
});
describe('useAssistantRouteLines', () => {
it('FE-PLANNER-AI-ROUTE-001: proposals of one activity are one choice of ways, anything else a card of its own', () => {
const entries = [
{ toolCallId: 'a', card: HIKE },
{ toolCallId: 'b', card: GENTLE },
{ toolCallId: 'c', card: SAVED },
{ toolCallId: 'd', card: TOUR_LIST },
];
expect(answerLayout(entries).map((group) => group.map((entry) => entry.toolCallId))).toEqual([
['a', 'b'],
['c'],
['d'],
]);
});
it('FE-PLANNER-AI-ROUTE-002: the spot of a route nearest a point is measured as its card measures it, with the height there', () => {
const [lat, lng] = HIKE_LINE[5] as [number, number, number];
const spot = routeFocusAt(HIKE, lat, lng);
expect(spot?.elevationMeters).toBeCloseTo(1400, 0);
expect(spot?.distanceMeters).toBeGreaterThan(3700);
expect(spot?.distanceMeters).toBeLessThan(3900);
expect(routeFocusAt({ ...HIKE, routed: false }, lat, lng)).toBeNull();
expect(routeFocusAt(TOUR_LIST, lat, lng)).toBeNull();
});
it("FE-PLANNER-AI-ROUTE-003: an answer's ways are drawn in the theme's colours, the chosen one solid, the lit one on top", () => {
setMessages(WAYS, { picks: { 'tour:call_1': 'call_2' } });
const { result } = lines();
const [wayA, wayB] = result.current.assistantRouteLines;
expect(wayA).toMatchObject({ ref: 'call_1', color: '#f97316', casing: '#ffffff', dashed: true });
expect(wayB).toMatchObject({ ref: 'call_2', color: '#d946ef' });
expect(wayB?.dashed).toBeUndefined();
act(() => useAssistantStore.getState().setMapHover(TRIP, 'call_1', 'card'));
const lit = result.current.assistantRouteLines;
expect(lit[lit.length - 1]).toMatchObject({ ref: 'call_1', hot: true });
});
it("FE-PLANNER-AI-ROUTE-004: the pointer on a route lights its card and moves its profile's spot; leaving puts both out", () => {
setMessages(read('call_9', HIKE));
const { result } = lines();
const [lat, lng] = HIKE_LINE[5] as [number, number, number];
act(() => result.current.onAssistantRouteHover('call_9'));
act(() => result.current.onAssistantRoutePoint('call_9', lat, lng));
expect(useAssistantStore.getState().mapLink).toEqual({ tripId: TRIP, hoveredId: 'call_9', source: 'map' });
expect(useAssistantStore.getState().routeFocus).toMatchObject({ tripId: TRIP, ref: 'call_9', source: 'map' });
expect(result.current.routeProfileFocus?.elevationMeters).toBeCloseTo(1400, 0);
expect(useAssistantStore.getState().routePeek).toBeNull();
act(() => result.current.onAssistantRouteHover(null));
expect(useAssistantStore.getState().mapLink?.hoveredId).toBeNull();
expect(useAssistantStore.getState().routeFocus).toBeNull();
// On the phone a tap does what the pointer does, and opens the route's peek over the map.
const phone = lines({ isMobile: true });
act(() => phone.result.current.onAssistantRoutePoint('call_9', lat, lng));
expect(useAssistantStore.getState().routePeek).toEqual({ tripId: TRIP, toolCallId: 'call_9' });
expect(useAssistantStore.getState().mapLink).toEqual({ tripId: TRIP, hoveredId: 'call_9', source: 'map' });
});
it('FE-PLANNER-AI-ROUTE-005: a saved tour lit from its row and a drive a card points at are drawn while lit', () => {
const geometry = JSON.stringify(HIKE_LINE.map(([lat, lng]) => [lat, lng]));
const tour = { id: 41, trip_id: TRIP, name: 'Hochfelln', route_geometry: geometry } as Place;
useTripStore.setState({ places: [tour] });
const { result } = lines();
expect(result.current.assistantRouteLines).toEqual([]);
act(() => useAssistantStore.getState().setMapHover(TRIP, 'tour:41', 'card'));
expect(result.current.assistantRouteLines).toEqual([
expect.objectContaining({ id: 'tour:41', ref: 'tour:41', color: '#0a84ff', casing: '#ffffff', hot: true }),
]);
const driven = HIKE_LINE.map(([la, ln]): [number, number] => [la, ln]);
const drive = { tripId: TRIP, ref: 'day:3', lines: [driven], color: '#22c55e' };
act(() => useAssistantStore.getState().setRoutePreview(drive));
expect(result.current.assistantRouteLines[1]).toMatchObject({ id: 'preview:day:3', color: '#22c55e', hot: true });
});
it('FE-PLANNER-AI-ROUTE-006: a new answer of routes frames the map once it is complete; one there at mount does not', () => {
setMessages(read('old', SAVED));
const { rerender } = lines();
expect(useAssistantStore.getState().camera).toBeNull();
act(() => setMessages(WAYS, { isRunning: true }));
rerender({});
expect(useAssistantStore.getState().camera).toBeNull();
act(() => setMessages(WAYS, { isRunning: false }));
rerender({});
expect(useAssistantStore.getState().camera).toMatchObject({ tripId: TRIP, mode: 'fit', why: 'auto' });
expect(useAssistantStore.getState().camera?.points).toHaveLength(HIKE_LINE.length * 2);
const seq = useAssistantStore.getState().camera?.seq;
rerender({});
expect(useAssistantStore.getState().camera?.seq).toBe(seq);
});
it('FE-PLANNER-AI-ROUTE-007: opened while the chat writes, the map stays put when the new answer draws no routes', () => {
setMessages(read('old', SAVED), { isRunning: true });
const { rerender } = lines();
act(() => setMessages(read('old', SAVED), { isRunning: false }));
rerender({});
expect(useAssistantStore.getState().camera).toBeNull();
});
});
@@ -0,0 +1,293 @@
import { cumulativeMetres, nearestAlongM, type TrekCard } from '@trek/shared';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { CARD_MAP_LOOK, CARD_RULES } from '../../components/Assistant/cards/cardLooks';
import { alongOf, profileSamples, routeEntries, wayGroups } from '../../components/Assistant/cards/tour/tourRouteModel';
import { useMapPaint } from '../../components/Map/mapPaint';
import type { AssistantGhost } from '../../components/Map/mapViewContract';
import {
type AnswerLayout,
cardGhosts,
cardHiddenGhosts,
lookPoints,
type RouteCardEntry,
routeLines,
type RouteRef,
routeRefs,
} from '../../store/assistantRouteModel';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useTripStore } from '../../store/tripStore';
import type { Place } from '../../types';
import type { AssistantRouteLine } from '../../types/assistantMap';
import {
focusRouteProfileAtDistance,
parseRenderableRouteGeometry,
type RouteProfileFocus,
} from '../../utils/routeGeometry';
/**
* The chat's routes on the plan's map, the one channel the tour and road
* trip cards share: what the cards draw (through their map looks), lit with
* their cards both ways, the spot of a route in focus shown on the map and
* picked from it, and the drive or saved tour a card points at. Desktop and
* phone both get it through useAssistantMapLink.
*/
/** A saved tour's key on the map, as the tour list lights it. */
const SAVED_TOUR = 'tour:';
/** The colours a lit saved tour and a pointed-at drive are drawn in, the planner's own (styles/hike.css). */
const SAVED_PAINT = { line: 'var(--hike-variant-current)', casing: 'var(--bike-highlight-casing)' } as const;
const PREVIEW_CASING = 'var(--bg-card)';
/** Two tokens that change with the theme, so the resolved colours follow a light or dark switch. */
const THEME_PROBE = { accent: '--accent', card: '--bg-card' } as const;
/**
* How an answer lays out its cards, as the chat does: consecutive cards of
* one group show as one, and the tours' proposals of one activity as a
* choice of up to three ways.
*/
export const answerLayout: AnswerLayout = (entries) => {
const runs: { group: string; entries: RouteCardEntry[] }[] = [];
for (const entry of entries) {
const group = CARD_RULES.group(entry.card);
const run = runs[runs.length - 1];
if (run?.group === group) run.entries.push(entry);
else runs.push({ group, entries: [entry] });
}
return runs.flatMap((run) => {
const routes = routeEntries(run.entries);
return routes.length === run.entries.length ? wayGroups(routes) : run.entries.map((entry) => [entry]);
});
};
/**
* The spot of a route nearest to a point on the map, as the route's card
* measures it: metres along its line scaled to its own length, with the
* height there. Only a route with a height profile has one.
*/
export function routeFocusAt(card: TrekCard, lat: number, lng: number): RouteProfileFocus | null {
if (card.kind !== 'tourRoute') return null;
const samples = profileSamples(card);
if (samples.length < 2) return null;
const raw = cumulativeMetres(card.line);
const scaled = alongOf(card);
const rawTotal = raw[raw.length - 1] ?? 0;
const scale = rawTotal > 0 ? (scaled[scaled.length - 1] ?? 0) / rawTotal : 1;
return focusRouteProfileAtDistance(samples, nearestAlongM(card.line, lat, lng) * scale);
}
/** A saved tour of the trip lit from the chat (its row in a tour list), drawn over its own track. */
function savedTourLine(places: readonly Place[], hoveredId: string | null): AssistantRouteLine[] {
if (!hoveredId?.startsWith(SAVED_TOUR)) return [];
const placeId = Number(hoveredId.slice(SAVED_TOUR.length));
const points = parseRenderableRouteGeometry(places.find((place) => place.id === placeId)?.route_geometry);
if (!points) return [];
const line = points.map(([lat, lng]): [number, number] => [lat, lng]);
const { line: color, casing } = SAVED_PAINT;
return [{ id: hoveredId, ref: hoveredId, lines: [line], color, casing, hot: true }];
}
/** The theme's colour for a CSS value (`var(--token)`), read again when the theme changes; other values as they are. */
function useCssColor(): (value: string) => string {
const theme = useMapPaint(THEME_PROBE);
return useMemo(() => {
const style = typeof document === 'undefined' ? null : getComputedStyle(document.documentElement);
const resolved = new Map<string, string>();
return (value: string) => {
const token = /^var\((--[\w-]+)\)$/.exec(value.trim())?.[1];
if (!token) return value;
let color = resolved.get(token);
if (color === undefined) {
color = style?.getPropertyValue(token).trim() || theme.accent;
resolved.set(token, color);
}
return color;
};
}, [theme]);
}
/** A line with its colours resolved, as both map renderers need them. */
function painted(line: AssistantRouteLine, color: (value: string) => string): AssistantRouteLine {
const next = { ...line, color: color(line.color), casing: color(line.casing) };
if (line.tints) next.tints = line.tints.map((tint) => ({ ...tint, color: color(tint.color) }));
return next;
}
/**
* Each card object the map has seen, by a number of its own. A streamed text
* delta leaves the cards' objects alone and a card that changes (a change's
* applied card replacing its preview) is a new object, so the number tells
* whether the map must redraw without comparing the cards. A weak cache: a
* card the chat let go of leaves it.
*/
const cardSerials = new WeakMap<object, number>();
let lastSerial = 0;
function serialOf(card: object): number {
let serial = cardSerials.get(card);
if (serial === undefined) {
lastSerial += 1;
serial = lastSerial;
cardSerials.set(card, serial);
}
return serial;
}
function refsNow(state: Parameters<typeof chatOf>[0], tripId: number, knownIds: ReadonlySet<number>): RouteRef[] {
const chat = chatOf(state, tripId);
return routeRefs(chat.messages, chat.picks, knownIds, CARD_MAP_LOOK, answerLayout);
}
/**
* The cards the map draws now, re-read only when one of them changes. The
* selector hands back a key, so a streamed answer does not render the
* planner on every frame.
*/
function useRouteRefs(tripId: number, knownIds: ReadonlySet<number>): RouteRef[] {
const signature = useAssistantStore((s) =>
refsNow(s, tripId, knownIds)
.map((ref) => `${ref.toolCallId}|${ref.use}|${ref.way ?? ''}|${ref.picked ? 1 : 0}|${serialOf(ref.card)}`)
.join(';')
);
return useMemo(
() => (signature === '' ? [] : refsNow(useAssistantStore.getState(), tripId, knownIds)),
[signature, knownIds, tripId]
);
}
interface RouteLinesOptions {
tripId: number;
isMobile: boolean;
/** Whether the map may move without being asked (see useAssistantMapLink). */
autoAllowed: boolean;
}
/**
* Everything the chat's routes put on the plan's map: the lines, the spots
* the waiting changes' cards mark (and the changes whose pin their own line
* replaces), the focus pin, and the pointer's way back to the cards. A
* pointer on a line lights its card; moving along it moves the card's
* profile; leaving puts both out. On the phone a tap does what the pointer
* does, and opens the route's peek over the map.
*/
export function useAssistantRouteLines({ tripId, isMobile, autoAllowed }: RouteLinesOptions) {
const places = useTripStore((s) => s.places);
const knownIds = useMemo(() => new Set(places.map((place) => place.id)), [places]);
const refs = useRouteRefs(tripId, knownIds);
const hoveredId = useAssistantStore((s) => (s.mapLink?.tripId === tripId ? s.mapLink.hoveredId : null));
const preview = useAssistantStore((s) => (s.routePreview?.tripId === tripId ? s.routePreview : null));
const focus = useAssistantStore((s) => (s.routeFocus?.tripId === tripId ? s.routeFocus.focus : null));
const setMapHover = useAssistantStore((s) => s.setMapHover);
const setRouteFocus = useAssistantStore((s) => s.setRouteFocus);
const setRoutePeek = useAssistantStore((s) => s.setRoutePeek);
const color = useCssColor();
const assistantRouteLines = useMemo(() => {
const drawn = [...routeLines(refs, CARD_MAP_LOOK, hoveredId), ...savedTourLine(places, hoveredId)];
if (preview) {
const { ref, lines } = preview;
drawn.push({ id: `preview:${ref}`, ref, lines, color: preview.color, casing: PREVIEW_CASING, hot: true });
}
return drawn.map((line) => painted(line, color));
}, [color, hoveredId, places, preview, refs]);
const ghosts = useMemo(
() =>
cardGhosts(refs, CARD_MAP_LOOK).map(
(ghost): AssistantGhost => ({
id: ghost.id,
ref: ghost.ref,
lat: ghost.lat,
lng: ghost.lng,
name: ghost.name,
variant: ghost.variant ?? 'pin',
})
),
[refs]
);
const hiddenGhosts = useMemo(() => cardHiddenGhosts(refs, CARD_MAP_LOOK), [refs]);
const latestRefs = useRef(refs);
useLayoutEffect(() => {
latestRefs.current = refs;
});
const frame = useRef<number | null>(null);
const pointed = useRef<{ ref: string; lat: number; lng: number } | null>(null);
useEffect(
() => () => {
if (frame.current !== null) cancelAnimationFrame(frame.current);
},
[]
);
const onAssistantRouteHover = useCallback(
(ref: string | null) => {
setMapHover(tripId, ref, 'map');
if (ref !== null) return;
const current = useAssistantStore.getState().routeFocus;
if (current?.tripId === tripId && current.source === 'map') setRouteFocus(null);
},
[setMapHover, setRouteFocus, tripId]
);
// Pointer moves come many per frame; the focus follows once a frame.
const onAssistantRoutePoint = useCallback(
(ref: string, lat: number, lng: number) => {
pointed.current = { ref, lat, lng };
if (frame.current !== null) return;
frame.current = requestAnimationFrame(() => {
frame.current = null;
const point = pointed.current;
pointed.current = null;
if (!point) return;
const card = latestRefs.current.find((r) => r.toolCallId === point.ref && r.use !== 'before')?.card ?? null;
const spot = card ? routeFocusAt(card, point.lat, point.lng) : null;
if (spot) setRouteFocus({ tripId, ref: point.ref, focus: spot, source: 'map' });
// The light goes on only with the peek, whose closing puts it out again (moveRoutePeek).
if (!isMobile || card?.kind !== 'tourRoute') return;
setMapHover(tripId, point.ref, 'map');
setRoutePeek({ tripId, toolCallId: point.ref });
});
},
[isMobile, setMapHover, setRouteFocus, setRoutePeek, tripId]
);
useRouteAutoCamera(tripId, refs, autoAllowed);
return {
assistantRouteLines,
onAssistantRouteHover,
onAssistantRoutePoint,
routeProfileFocus: focus,
cardGhosts: ghosts,
hiddenGhosts,
};
}
/**
* One move of its own per new answer that draws routes: once the answer is
* complete, the map frames all its ways, where the map may move by itself.
* An answer already there when the planner opens is left where it is, even
* when the planner opens while the chat is still writing and the new answer
* draws no routes of its own.
*/
function useRouteAutoCamera(tripId: number, refs: readonly RouteRef[], autoAllowed: boolean): void {
const requestCamera = useAssistantStore((s) => s.requestCamera);
const isRunning = useAssistantStore((s) => chatOf(s, tripId).isRunning);
const firstKey = refs.find((ref) => ref.use === 'look')?.toolCallId ?? null;
const blockKey = isRunning ? null : firstKey;
const seen = useRef<string | null | undefined>(undefined);
useEffect(() => {
if (seen.current === undefined) {
// What the chat shows at mount counts as seen, finished or not.
seen.current = firstKey;
return;
}
if (blockKey === null || blockKey === seen.current) return;
seen.current = blockKey;
if (!autoAllowed) return;
const points = lookPoints(refs, CARD_MAP_LOOK);
if (points.length > 0) requestCamera(tripId, points, 'fit', 'auto');
}, [autoAllowed, blockKey, firstKey, refs, requestCamera, tripId]);
}
@@ -167,6 +167,8 @@ export function useTourWorkspace(planner: TripPlanner, canPlaceEdit: boolean, ca
return {
tourPlanner,
viewImportedTour,
// A saved tour opened in the editor, or shown as it came in; the chat's `?tour=` link opens it this way.
openTourInPlanner,
tourGpxImport,
wandererBrowser,
tourConversion,
+2 -1
View File
@@ -32,7 +32,8 @@ export function changeEffects(messages: readonly AssistantMessage[]): Map<string
return effects;
}
function receiptsOf(messages: readonly AssistantMessage[]): Map<string, AssistantApplied> {
/** The receipt of every applied change of the thread, by its call. */
export function receiptsOf(messages: readonly AssistantMessage[]): Map<string, AssistantApplied> {
const receipts = new Map<string, AssistantApplied>();
for (const m of messages) {
for (const p of m.parts) if (p.type === 'data-trek-applied') receipts.set(p.data.toolCallId, p.data);
@@ -0,0 +1,183 @@
/**
* FE-AI-ROUTE-001 to FE-AI-ROUTE-005
*
* 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
* latest answer that draws any (its ways each with a letter, the chosen one
* solid), the order lines lie in, and the spots the waiting changes mark.
* The looks are a stand-in here: the model reads cards only through them.
*/
import type { AssistantApplied, AssistantMessage, AssistantMessagePart, TourRouteCard, TrekCard } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import { GENTLE, HIKE, SAVED } from '../../tests/helpers/assistantTour';
import type { CardMapLook, RouteLineSpec } from '../types/assistantMap';
import {
type AnswerLayout,
cardGhosts,
cardHiddenGhosts,
lookPoints,
routeLines,
routeRefs,
} from './assistantRouteModel';
const flat = (card: TourRouteCard): [number, number][] => card.line.map(([lat, lng]): [number, number] => [lat, lng]);
/** A look that draws a route card's line (rimmed), the way it replaces, and one spot for a held route. */
const LOOK: CardMapLook = {
lines: (card: TrekCard, use): RouteLineSpec[] => {
if (card.kind !== 'tourRoute') return [];
if (use === 'before') {
return card.previous ? [{ id: 'previous', lines: [card.previous.line], color: 'var(--faint)', dim: true }] : [];
}
if (card.line.length < 2) return [];
const tints = [{ line: flat(card).slice(3, 6), color: 'var(--t3)' }];
const route = { id: 'route', lines: [flat(card)], color: 'var(--route)', casing: 'var(--rim)' };
return [{ ...route, dashed: !card.routed, tints }];
},
ghosts: (card) =>
card.kind === 'tourRoute' ? [{ id: 'start', lat: 47.77, lng: 12.59, name: 'Bergen', variant: 'pin' }] : [],
hidesEffectGhost: (card) => card.kind === 'tourRoute' && card.routed,
};
/** All an answer's cards as one group, as the chat lays out proposals of one activity. */
const AS_WAYS: AnswerLayout = (entries) => [entries];
function message(parts: AssistantMessagePart[], id = 'msg-1'): AssistantMessage {
return { id, role: 'assistant', parts, status: 'complete', createdAt: '2026-10-21T08:00:00.000Z' };
}
function read(toolCallId: string, card: TourRouteCard): AssistantMessagePart[] {
return [
{ type: 'tool-call', toolCallId, toolName: 'propose_hike', args: {}, result: {} },
{ type: 'data-trek-card', data: { toolCallId, card } },
];
}
type Approval = { approved?: boolean; resolution?: 'expired' };
function held(toolCallId: string, card: TourRouteCard, approval: Approval = {}, extra: object = {}) {
return [
{
type: 'tool-call',
toolCallId,
toolName: 'create_hike',
args: {},
approval: { id: toolCallId, ...approval },
...extra,
},
{
type: 'data-trek-change',
data: { toolCallId, verbKey: 'addTour', subject: 'x', rows: [], dayId: null, placeIds: [], undoable: true, card },
},
] as AssistantMessagePart[];
}
function receipt(toolCallId: string, entity: AssistantApplied['entity'], ids: number[]): AssistantMessagePart {
return { type: 'data-trek-applied', data: { toolCallId, entity, ids, dayId: null, undoUntil: null } };
}
const PENDING: TourRouteCard = { ...HIKE, source: 'pending' };
const NOBODY = new Set<number>();
describe('assistantRouteModel', () => {
it('FE-AI-ROUTE-001: a change stands on the map while undecided or running, and once applied until its place arrives', () => {
const messages = [
message([
...held('wait', PENDING),
...held('run', PENDING, { approved: true }),
...held('done', PENDING, { approved: true }, { result: {} }),
receipt('done', 'tour', [41]),
...held('note', PENDING, { approved: true }, { result: {} }),
receipt('note', 'day_note', [5]),
...held('no', PENDING, { approved: false }),
...held('late', PENDING, { resolution: 'expired' }),
...held('broke', PENDING, { approved: true }, { result: {}, isError: true }),
]),
];
const standing = routeRefs(messages, {}, NOBODY, LOOK).map((ref) => `${ref.toolCallId}:${ref.use}`);
const all = ['wait:before', 'wait:pending', 'run:before', 'run:pending', 'done:before', 'done:pending'];
expect(standing).toEqual(all);
const arrived = routeRefs(messages, {}, new Set([41]), LOOK).map((ref) => ref.toolCallId);
expect(arrived).not.toContain('done');
});
it('FE-AI-ROUTE-002: the latest answer that draws routes is drawn, its ways lettered and the chosen one picked', () => {
const older = message(read('old', SAVED), 'msg-0');
const latest = message([...read('call_1', HIKE), ...read('call_2', GENTLE)], 'msg-1');
const quiet = message([{ type: 'text', text: 'Both are fine.' }], 'msg-2');
const first = routeRefs([older, latest, quiet], {}, NOBODY, LOOK, AS_WAYS);
expect(first.map((ref) => [ref.toolCallId, ref.use, ref.way, ref.picked])).toEqual([
['call_1', 'look', 'a', true],
['call_2', 'look', 'b', false],
]);
const chosen = routeRefs([older, latest], { 'tour:call_1': 'call_2' }, NOBODY, LOOK, AS_WAYS);
expect(chosen.map((ref) => ref.picked)).toEqual([false, true]);
// Laid out one by one, the cards are routes of their own: no letters.
expect(routeRefs([latest], {}, NOBODY, LOOK).map((ref) => ref.way)).toEqual([null, null]);
// A card under a change's call is that change's, never an answer's.
const applied: AssistantMessagePart = { type: 'data-trek-card', data: { toolCallId: 'c9', card: SAVED } };
const change = message([...held('c9', PENDING), applied]);
const refs = routeRefs([change], {}, NOBODY, LOOK);
expect(refs.map((ref) => ref.use)).toEqual(['before', 'pending']);
expect(refs[1]?.card).toBe(SAVED);
});
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 = [
message([...held('upd', { ...PENDING, previous })], 'msg-0'),
message([...read('call_1', HIKE), ...read('call_2', GENTLE)], 'msg-1'),
];
const refs = routeRefs(messages, { 'tour:call_1': 'call_2' }, NOBODY, LOOK, AS_WAYS);
const calm = routeLines(refs, LOOK, null);
expect(calm.map((line) => line.id)).toEqual([
'upd:before:previous',
'upd:pending:route',
'call_1:look:route',
'call_2:look:route',
]);
const [replaced, pending, wayA, wayB] = calm;
expect(replaced).toMatchObject({ ref: 'upd', dim: true, casing: 'var(--bg-card)' });
expect(replaced?.ends).toBeUndefined();
expect(pending).toMatchObject({ color: 'var(--route)', casing: 'var(--rim)' });
// The hike is a loop: one disc at its start, no ring at its end.
expect(pending?.ends).toEqual({ start: [47.77, 12.59], end: null });
expect(wayA).toMatchObject({ color: 'var(--hike-variant-a)', dashed: true });
expect(wayA?.tints).toBeUndefined();
expect(wayB).toMatchObject({ color: 'var(--hike-variant-b)' });
expect(wayB?.dashed).toBeUndefined();
expect(wayB?.tints).toHaveLength(1);
const lit = routeLines(refs, LOOK, 'call_1');
expect(lit[lit.length - 1]).toMatchObject({ id: 'call_1:look:route', hot: true });
expect(lit[lit.length - 1]?.dashed).toBeUndefined();
});
it('FE-AI-ROUTE-004: a route joined straight stays dashed even when lit, and an open route has a ring at its end', () => {
const straight: TourRouteCard = { ...HIKE, routed: false, line: HIKE.line.slice(0, 5) };
const refs = routeRefs([message(read('s', straight))], {}, NOBODY, LOOK);
const [line] = routeLines(refs, LOOK, 's');
expect(line).toMatchObject({ dashed: true, hot: true });
expect(line?.ends).toEqual({ start: [47.77, 12.59], end: [47.79, 12.62] });
});
it('FE-AI-ROUTE-005: waiting changes mark their spots under their call, hide the pin their line replaces, and answers frame', () => {
const messages = [
message([...held('wait', PENDING), ...held('flat', { ...PENDING, routed: false })], 'msg-0'),
message(read('call_1', HIKE), 'msg-1'),
];
const refs = routeRefs(messages, {}, NOBODY, LOOK);
expect(cardGhosts(refs, LOOK)).toEqual([
{ id: 'wait:start', ref: 'wait', lat: 47.77, lng: 12.59, name: 'Bergen', variant: 'pin' },
{ id: 'flat:start', ref: 'flat', lat: 47.77, lng: 12.59, name: 'Bergen', variant: 'pin' },
]);
expect([...cardHiddenGhosts(refs, LOOK)]).toEqual(['wait']);
expect(lookPoints(refs, LOOK)).toEqual(flat(HIKE));
});
});
+259
View File
@@ -0,0 +1,259 @@
import { type AssistantApplied, type AssistantMessage, haversineMetres, type TrekCard } from '@trek/shared';
import type { AssistantRouteLine, CardMapLook, GhostPoint, RouteLineSpec, RouteUse } from '../types/assistantMap';
import { receiptsOf } from './assistantMapModel';
/**
* Which routes of the chat's cards the plan's map draws, and how. Three
* kinds of card stand on the map: a change still to be decided (or applied,
* until the trip holds what it made), with the way it replaces faint beside
* it; and the cards of the latest answer that drew any, the ways of one
* proposal each in its letter's colour, the chosen one solid. Pure and
* React-free: the store holds the chat, the planner hands in the cards'
* map looks (`CardMapLook`), and the tests need neither.
*/
/** A card of the chat under the call it came from. */
export interface RouteCardEntry {
toolCallId: string;
card: TrekCard;
}
/** The letters the ways of one answer go by, in the order the answer gave them. */
type RouteWayKey = 'a' | 'b' | 'c';
const WAY_KEYS: readonly RouteWayKey[] = ['a', 'b', 'c'];
/** The colour each way is drawn in, the planner's own for the ways of a section (styles/hike.css). */
const WAY_PAINT: Readonly<Record<RouteWayKey, string>> = {
a: 'var(--hike-variant-a)',
b: 'var(--hike-variant-b)',
c: 'var(--hike-variant-c)',
};
/** The rim a line gets when its card names none. */
const DEFAULT_CASING = 'var(--bg-card)';
/** A line whose ends lie closer than this is a loop: one disc at its start, no ring at its end. */
const LOOP_M = 60;
/**
* A card the map draws, in the part it plays: `pending` a change, `before`
* the way that change replaces, `look` a card of an answer. `way` is its
* letter among the ways of one answer, `picked` whether it is the chosen one.
*/
export interface RouteRef {
toolCallId: string;
card: TrekCard;
use: RouteUse;
way: RouteWayKey | null;
picked: boolean;
}
/**
* How an answer lays out its cards, as the chat does: the groups that show
* as one card. A group of several is a choice between ways.
*/
export type AnswerLayout = (entries: readonly RouteCardEntry[]) => readonly (readonly RouteCardEntry[])[];
/** Every card on its own, for a planner that knows no groups. */
const ALONE: AnswerLayout = (entries) => entries.map((entry) => [entry]);
type ToolCallPart = Extract<AssistantMessage['parts'][number], { type: 'tool-call' }>;
/** What an applied change made that becomes a place of the trip, and so arrives on the map by itself. */
const BECOMES_A_PLACE: ReadonlySet<AssistantApplied['entity']> = new Set(['tour', 'place']);
/** The card of each change of the thread (its applied card once there, else its preview's), and every change's call. */
function changeCards(messages: readonly AssistantMessage[]): { cards: Map<string, TrekCard>; changeIds: Set<string> } {
const previews = new Map<string, TrekCard>();
const posted = new Map<string, TrekCard>();
const changeIds = new Set<string>();
for (const message of messages) {
for (const part of message.parts) {
if (part.type === 'data-trek-change') {
changeIds.add(part.data.toolCallId);
if (part.data.card) previews.set(part.data.toolCallId, part.data.card);
} else if (part.type === 'tool-call' && part.approval) changeIds.add(part.toolCallId);
else if (part.type === 'data-trek-card' && part.data.card) posted.set(part.data.toolCallId, part.data.card);
}
}
const cards = new Map(previews);
for (const [toolCallId, card] of posted) if (changeIds.has(toolCallId)) cards.set(toolCallId, card);
return { cards, changeIds };
}
/**
* Whether a change still stands on the map: undecided, or applied and not
* answered yet, or applied with a place the trip does not hold yet (the
* place comes with the broadcast, and its real line takes over then). A
* change that was discarded, expired or failed does not.
*/
function standsOnMap(part: ToolCallPart, receipt: AssistantApplied | undefined, known: ReadonlySet<number>): boolean {
const approval = part.approval;
if (!approval || approval.resolution || approval.approved === false) return false;
if (approval.approved === undefined) return true;
if (part.isError) return false;
if (!receipt) return part.result === undefined;
const placeId = receipt.ids[0];
return BECOMES_A_PLACE.has(receipt.entity) && placeId !== undefined && !known.has(placeId);
}
/**
* 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.
*/
function lookRefs(
messages: readonly AssistantMessage[],
changeIds: ReadonlySet<string>,
picks: Readonly<Record<string, string>>,
look: CardMapLook,
layout: AnswerLayout
): RouteRef[] {
const chosenIds = new Set(Object.values(picks));
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)
? [{ toolCallId: part.data.toolCallId, card: part.data.card }]
: []
);
const lined = answers.filter((entry) => look.lines(entry.card, 'look').length > 0);
const drawn = new Set(lined.map((entry) => entry.toolCallId));
if (drawn.size === 0) continue;
return layout(answers).flatMap((group) => {
const ways = group.length >= 2;
const chosen = group.find((entry) => chosenIds.has(entry.toolCallId)) ?? group[0];
return group.flatMap((entry, position): RouteRef[] => {
if (!drawn.has(entry.toolCallId)) return [];
const way = ways ? (WAY_KEYS[position] ?? null) : null;
return [{ ...entry, use: 'look', way, picked: ways && entry === chosen }];
});
});
}
return [];
}
/**
* 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
* the cards of the latest answer that draws any.
*/
export function routeRefs(
messages: readonly AssistantMessage[],
picks: Readonly<Record<string, string>>,
knownPlaceIds: ReadonlySet<number>,
look: CardMapLook,
layout: AnswerLayout = ALONE
): RouteRef[] {
const { cards, changeIds } = changeCards(messages);
const receipts = receiptsOf(messages);
const refs: RouteRef[] = [];
for (const message of messages) {
for (const part of message.parts) {
if (part.type !== 'tool-call') continue;
const card = cards.get(part.toolCallId);
if (!card || !standsOnMap(part, receipts.get(part.toolCallId), knownPlaceIds)) continue;
refs.push({ toolCallId: part.toolCallId, card, use: 'before', way: null, picked: false });
refs.push({ toolCallId: part.toolCallId, card, use: 'pending', way: null, picked: false });
}
}
return [...refs, ...lookRefs(messages, changeIds, picks, look, layout)];
}
/** Where a route starts and ends; a loop has no end of its own. */
function endsOf(lines: readonly [number, number][][]): AssistantRouteLine['ends'] | null {
const first = lines[0]?.[0];
const lastLine = lines[lines.length - 1];
const last = lastLine?.[lastLine.length - 1];
if (!first || !last) return null;
const loop = haversineMetres(first[0], first[1], last[0], last[1]) <= LOOP_M;
return { start: first, end: loop ? null : last };
}
/**
* One spec of a card as the map draws it. A way not chosen is dashed until
* it is pointed at; a line straight between its stops stays dashed. Only a
* route the card gives a rim to has ends (a via's move is a hint, not a
* route), and only a faint line leaves them out.
*/
function lineOf(
ref: RouteRef,
spec: RouteLineSpec,
hoveredId: string | null
): { line: AssistantRouteLine; rank: number } {
const hot = ref.toolCallId === hoveredId;
const unchosen = ref.way !== null && !ref.picked;
const line: AssistantRouteLine = {
id: `${ref.toolCallId}:${ref.use}:${spec.id}`,
ref: ref.toolCallId,
lines: spec.lines,
color: ref.way ? WAY_PAINT[ref.way] : spec.color,
casing: spec.casing ?? DEFAULT_CASING,
...extrasOf(spec, unchosen),
};
if (spec.dashed || (unchosen && !hot)) line.dashed = true;
if (spec.dim) line.dim = true;
if (hot) line.hot = true;
return { line, rank: rankOf(ref, hot) };
}
/** A route's ends, named stops and hard grades, as far as its spec has them; a way not chosen shows no grades. */
function extrasOf(spec: RouteLineSpec, unchosen: boolean): Pick<AssistantRouteLine, 'ends' | 'stops' | 'tints'> {
const extras: Pick<AssistantRouteLine, 'ends' | 'stops' | 'tints'> = {};
const ends = spec.casing && !spec.dim ? endsOf(spec.lines) : null;
if (ends) extras.ends = ends;
if (spec.stops && spec.stops.length > 0) extras.stops = spec.stops;
if (spec.tints && spec.tints.length > 0 && !unchosen) extras.tints = spec.tints;
return extras;
}
/** Where a line lies among the others: the replaced way at the bottom, the chosen way high, the lit one on top. */
function rankOf(ref: RouteRef, hot: boolean): number {
if (hot) return 3;
if (ref.use === 'before') return 0;
return ref.picked ? 2 : 1;
}
/**
* The lines to draw, bottom first: the ways changes replace, then the
* others, the chosen way, and last the one under the pointer, so it lies on
* top where lines share a path.
*/
export function routeLines(
refs: readonly RouteRef[],
look: CardMapLook,
hoveredId: string | null
): AssistantRouteLine[] {
const drawn = refs.flatMap((ref) => look.lines(ref.card, ref.use).map((spec) => lineOf(ref, spec, hoveredId)));
return drawn
.map((entry, order) => ({ ...entry, order }))
.sort((a, b) => a.rank - b.rank || a.order - b.order)
.map((entry) => entry.line);
}
/** A spot a waiting change marks on the map, lit with its row by the change's call (`ref`). */
interface CardGhost extends GhostPoint {
ref: string;
}
/** The spots the waiting changes' cards mark (a road trip's new stops and vias), each under its own id. */
export function cardGhosts(refs: readonly RouteRef[], look: CardMapLook): CardGhost[] {
return refs.flatMap((ref) =>
ref.use === 'pending'
? look.ghosts(ref.card).map((ghost) => ({ ...ghost, id: `${ref.toolCallId}:${ghost.id}`, ref: ref.toolCallId }))
: []
);
}
/** The changes whose card draws them itself, so the pin of the place they add would only cover its start. */
export function cardHiddenGhosts(refs: readonly RouteRef[], look: CardMapLook): Set<string> {
const hiding = refs.filter((ref) => ref.use === 'pending' && look.hidesEffectGhost(ref.card));
return new Set(hiding.map((ref) => ref.toolCallId));
}
/** The points of every line an answer draws, to frame the map on them. */
export function lookPoints(refs: readonly RouteRef[], look: CardMapLook): [number, number][] {
return refs.flatMap((ref) =>
ref.use === 'look' ? look.lines(ref.card, 'look').flatMap((spec) => spec.lines.flat()) : []
);
}
+18 -3
View File
@@ -1,5 +1,5 @@
/**
* FE-AI-STORE-CARDS-001 to FE-AI-STORE-CARDS-004
* FE-AI-STORE-CARDS-001 to FE-AI-STORE-CARDS-005
*
* What the store keeps for the cards: the spot of a route the card and the
* map share, a line a card asks the map to draw, the route the phone peeks
@@ -91,12 +91,27 @@ describe('assistantStore: cards', () => {
{ type: 'tool-call', toolCallId: 'w1', toolName: 'create_hike', args: {}, approval: { id: 'w1' } },
]);
const take = vi.spyOn(assistantRepo, 'takeProposal').mockResolvedValueOnce({ message: held });
await store().takeProposal(TRIP, 'r1');
await expect(store().takeProposal(TRIP, 'r1')).resolves.toBe('held');
expect(take).toHaveBeenCalledWith(TRIP, 7, expect.objectContaining({ toolCallId: 'r1' }));
expect(chatOf(store(), TRIP).messages).toEqual([held]);
vi.spyOn(assistantRepo, 'takeProposal').mockRejectedValueOnce({ response: { status: 429 } });
await store().takeProposal(TRIP, 'r1');
await expect(store().takeProposal(TRIP, 'r1')).resolves.toBe('refused');
expect(chatOf(store(), TRIP).error).toBe('too_many_requests');
});
it('FE-AI-STORE-CARDS-005: "Plan this" says it held nothing while a turn runs or after the thread changed under it', async () => {
seedThread([answer([{ type: 'text', text: 'Three ways up.' }])]);
const held = answer([{ type: 'text', text: 'Three ways up.' }]);
const take = vi.spyOn(assistantRepo, 'takeProposal').mockImplementationOnce(async () => {
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chatOf(store(), TRIP), threadId: 8 } } });
return { message: held };
});
await expect(store().takeProposal(TRIP, 'r1')).resolves.toBe('skipped');
expect(take).toHaveBeenCalledTimes(1);
useAssistantStore.setState({ byTrip: { [TRIP]: { ...chatOf(store(), TRIP), isRunning: true } } });
await expect(store().takeProposal(TRIP, 'r1')).resolves.toBe('skipped');
expect(take).toHaveBeenCalledTimes(1);
});
});
+18 -8
View File
@@ -191,10 +191,17 @@ interface AssistantState {
setRoutePeek: (routePeek: AssistantState['routePeek']) => void;
/** Picks one of the ways a block of cards offers. */
pick: (tripId: number, blockKey: string, toolCallId: string) => void;
/** Takes a proposal an answer showed as a card ("Plan this"); the server holds it as a change to decide. */
takeProposal: (tripId: number, toolCallId: string) => Promise<void>;
/**
* Takes a proposal an answer showed as a card ("Plan this"); the server holds it as a change to decide.
* Says what came of it: held, refused (the chat's error says why), or skipped while a turn ran or the
* thread changed under it.
*/
takeProposal: (tripId: number, toolCallId: string) => Promise<HoldOutcome>;
}
/** What came of a change the server was asked to hold without a model turn. */
export type HoldOutcome = 'held' | 'refused' | 'skipped';
const NO_LIVE: AssistantTripChat['live'] = { activity: null };
const EMPTY_CHAT: AssistantTripChat = {
@@ -646,10 +653,11 @@ function compareActions(ctx: Ctx) {
const points = [...dayPoints(compare.snapshot, days), ...dayPoints(useTripStore.getState().assignments, days)];
requestCameraOn(ctx, tripId, points, 'fit', 'user');
},
addFoundPlace: (tripId: number, ref: string, dayId: number) =>
holdWithoutTurn(ctx, tripId, (threadId) =>
addFoundPlace: async (tripId: number, ref: string, dayId: number) => {
await holdWithoutTurn(ctx, tripId, (threadId) =>
assistantRepo.addFoundPlace(tripId, threadId, { ...clientContext(), ref, dayId })
),
);
},
takeProposal: (tripId: number, toolCallId: string) =>
holdWithoutTurn(ctx, tripId, (threadId) =>
assistantRepo.takeProposal(tripId, threadId, { ...clientContext(), toolCallId })
@@ -667,19 +675,21 @@ async function holdWithoutTurn(
ctx: Ctx,
tripId: number,
hold: (threadId: number) => Promise<{ message: AssistantMessage }>
): Promise<void> {
): Promise<HoldOutcome> {
const threadId = chat(ctx, tripId).threadId;
if (chat(ctx, tripId).isRunning || threadId === null) return;
if (chat(ctx, tripId).isRunning || threadId === null) return 'skipped';
try {
const { message } = await hold(threadId);
const c = chat(ctx, tripId);
// The thread may have changed while the server answered.
if (c.threadId !== threadId) return;
if (c.threadId !== threadId) return 'skipped';
const known = c.messages.some((m) => m.id === message.id);
const messages = known ? c.messages.map((m) => (m.id === message.id ? message : m)) : [...c.messages, message];
patch(ctx, tripId, { messages, error: null });
return 'held';
} catch (err) {
patch(ctx, tripId, { error: errorCodeOf(err) });
return 'refused';
}
}
+27
View File
@@ -18,6 +18,33 @@ export interface RouteLineSpec {
casing?: string;
dashed?: boolean;
dim?: boolean;
/** The named stops along it, dotted on the line while it is lit, each with its name. */
stops?: { lat: number; lng: number; label: string }[];
/** Stretches drawn over it in their own colour (the hard grades of a hike). */
tints?: { line: [number, number][]; color: string }[];
}
/**
* A line as the plan's map draws it beside the plan: the specs of every card
* in the chat, laid out and lit by the store's route model. `ref` is the key
* the chat and the map light it by (a change's call, `tour:<placeId>`), so
* several lines of one card light together; `id` is the line's own. Colours
* are CSS values here and resolved by the planner before the map draws them.
*/
export interface AssistantRouteLine {
id: string;
ref: string;
lines: [number, number][][];
color: string;
casing: string;
dashed?: boolean;
dim?: boolean;
/** Under the pointer, in the chat or on the map: drawn solid, on top, with its stops. */
hot?: boolean;
/** A filled disc at the start and a ring at the end; no end where the line closes on its start. */
ends?: { start: [number, number]; end: [number, number] | null };
stops?: { lat: number; lng: number; label: string }[];
tints?: { line: [number, number][]; color: string }[];
}
/** A spot a card asks the plan's map to mark: a pin for a stop, a small point for a via. */
+156
View File
@@ -0,0 +1,156 @@
import type { TourListCard, TourRouteCard, TourRouteStop } from '@trek/shared';
/**
* Tour cards as the server builds them, for the tour card tests: a hike up
* the Hochfelln from Bergen on day 2 at 8:00, graded T3 at the top, with a
* hut and a water tap on the way, thunder forecast for late morning; and the
* saved tours of a trip as the sidebar lists them.
*/
export function tourStop(extra: Partial<TourRouteStop> = {}): TourRouteStop {
return {
role: 'via',
kind: null,
name: null,
lat: 47.77,
lng: 12.59,
atM: 0,
offM: null,
minutes: null,
clock: null,
stayMinutes: null,
stageEnd: false,
flag: null,
...extra,
};
}
/** The line, about 4.1 km up and as far back down, each point with its height. */
export const HIKE_LINE: TourRouteCard['line'] = [
[47.77, 12.59, 600],
[47.775, 12.6, 760],
[47.78, 12.61, 920],
[47.785, 12.615, 1080],
[47.79, 12.62, 1240],
[47.795, 12.625, 1400],
[47.79, 12.63, 1240],
[47.78, 12.62, 920],
[47.77, 12.59, 600],
];
export const HIKE: TourRouteCard = {
kind: 'tourRoute',
source: 'proposal',
activity: 'hike',
placeId: null,
name: 'Hochfelln from Bergen',
label: null,
distanceM: 8200,
ascentM: 640,
descentM: 640,
minEleM: 600,
maxEleM: 1400,
minutes: 200,
minutesBasis: 'din33466',
limit: 3,
grades: { highest: 3, unknownShare: 0.1 },
routed: true,
problem: null,
line: HIKE_LINE,
stretches: [
{ facet: 'sac', fromM: 0, toM: 3000, step: 2 },
{ facet: 'sac', fromM: 3000, toM: 4100, step: 3 },
],
stops: [
tourStop({ role: 'start', name: 'Bergen', atM: 0, minutes: 0, clock: '08:00' }),
tourStop({ role: 'along', kind: 'drinking_water', name: null, lat: 47.776, atM: 1200, offM: 80 }),
tourStop({
role: 'via',
kind: 'shelter',
name: 'Strausshütte',
lat: 47.78,
atM: 2400,
minutes: 100,
clock: '09:40',
}),
tourStop({ role: 'via', kind: 'peak', name: 'Hochfelln', lat: 47.795, atM: 4100, minutes: 150, clock: '10:30' }),
tourStop({ role: 'end', name: 'Bergen', atM: 8200, minutes: 200, clock: '11:20' }),
],
stages: null,
skiScheme: null,
schedule: {
date: '2026-10-14',
dayId: 22,
dayNumber: 2,
start: '08:00',
arrive: '11:20',
nextDay: false,
sunrise: '07:00',
sunset: '18:00',
latestStart: null,
weather: 'forecast',
hours: [
{ hour: 8, main: 'Clear', tempC: 9, rainPct: 0, windKmh: 10 },
{ hour: 9, main: 'Clouds', tempC: 11, rainPct: 10, windKmh: 12 },
{ hour: 10, main: 'Rain', tempC: 13, rainPct: 40, windKmh: 15 },
{ hour: 11, main: 'Thunderstorm', tempC: 14, rainPct: 70, windKmh: 30 },
],
},
warnings: [{ kind: 'thunder', at: '11:00', count: null }],
previous: null,
};
/** Another way up, longer and gentler, as a second proposal of one answer. */
export const GENTLE: TourRouteCard = {
...HIKE,
label: 'Long and gentle',
distanceM: 9800,
ascentM: 520,
descentM: 520,
minutes: 225,
grades: { highest: 2, unknownShare: 0.6 },
stretches: [{ facet: 'sac', fromM: 0, toM: 9800, step: 2 }],
};
/** A saved tour as get_tour_plan reads it: no schedule, no grades, the line in brief. */
export const SAVED: TourRouteCard = {
...HIKE,
source: 'saved',
placeId: 41,
label: null,
minutesBasis: 'planned',
grades: null,
schedule: null,
warnings: [],
stops: [HIKE.stops[0] as TourRouteStop, HIKE.stops[4] as TourRouteStop],
};
export const TOUR_LIST: TourListCard = {
kind: 'tourList',
dayId: null,
more: 2,
rows: [
{
placeId: 41,
name: 'Hochfelln from Bergen',
activity: 'hike',
limit: 3,
distanceM: 8200,
ascentM: 640,
minutes: 200,
stages: null,
days: [2],
},
{
placeId: 42,
name: 'Chiemsee by kayak',
activity: 'kayak',
limit: null,
distanceM: 12000,
ascentM: null,
minutes: null,
stages: null,
days: [],
},
],
};
@@ -8,7 +8,7 @@ import { chatOf, useAssistantStore } from '../../../../src/store/assistantStore'
import { buildPlanner, buildShell } from '../../../helpers/mobileTrip';
import { act, cleanup, fireEvent, render, screen } from '../../../helpers/render';
// FE-MOB-ASSIST-001 to FE-MOB-ASSIST-014
// FE-MOB-ASSIST-001 to FE-MOB-ASSIST-015
//
// The phone's full-screen chat around the shared chat surface, which is
// replaced here: what is tested is the frame, the header and the way to the
@@ -45,6 +45,10 @@ vi.mock('../../../../src/components/Assistant/AssistantSurfaceLazy', () => ({
},
}));
vi.mock('../../../../src/components/Assistant/map/RoutePeekLazy', () => ({
default: (props: { tripId: number }) => <div data-testid="route-peek-mount" data-trip={props.tripId} />,
}));
vi.mock('../../../../src/components/Assistant/useAssistantThread', () => ({
useAssistantThread: () => thread,
}));
@@ -151,6 +155,27 @@ describe('MAssistantScreen', () => {
expect(screen.getByRole('dialog', { name: 'Assistant' })).toBeInTheDocument();
});
it("FE-MOB-ASSIST-015: the route's peek is mounted only while a route is shown, and the pill back closes it", () => {
renderScreen({}, { trTab: 'plan', view: 'map' });
expect(screen.queryByTestId('route-peek-mount')).not.toBeInTheDocument();
// Over the map with no route shown (a place, a booking), the peek's code is not loaded.
act(() => surface.onRevealMap?.());
expect(screen.queryByTestId('route-peek-mount')).not.toBeInTheDocument();
act(() => useAssistantStore.getState().setRoutePeek({ tripId: 2, toolCallId: 'c1' }));
expect(screen.queryByTestId('route-peek-mount')).not.toBeInTheDocument();
act(() => {
useAssistantStore.getState().setRoutePeek({ tripId: 1, toolCallId: 'c1' });
useAssistantStore.getState().setMapHover(1, 'c1', 'map');
});
expect(screen.getByTestId('route-peek-mount').dataset.trip).toBe('1');
// Back in the chat the line the tap lit goes out with the peek.
fireEvent.click(screen.getByRole('button', { name: 'Back to chat' }));
expect(useAssistantStore.getState().routePeek).toBeNull();
expect(useAssistantStore.getState().mapLink).toEqual({ tripId: 1, hoveredId: null, source: 'map' });
expect(screen.queryByTestId('route-peek-mount')).not.toBeInTheDocument();
});
it('FE-MOB-ASSIST-009: a day picked in the chat is selected on the map, and the pill says where a step looks', () => {
const { planner, shell } = renderScreen({}, { trTab: 'listen', view: 'plan' });
act(() => surface.onFocusDay?.(7));
@@ -17,7 +17,7 @@ import { openLeg } from '../../../helpers/legAlternatives'
import { RT_ALT_BAR_LIFT } from '../../../../src/mobile/screens/trip/roadtrip/useMRtAlternatives'
import { corePoiCategories, pluginPoiCategories } from '../../../../src/components/Map/usePoiCategories'
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-050
// FE-MOB-MAPAREA-001 to FE-MOB-MAPAREA-051
//
// The stage's pins come out of the trip store rather than the planner's map list, so the
// stage fixtures seed the store and leave `mapPlaces` to stand for what the plan tab shows.
@@ -1004,3 +1004,20 @@ describe('MMapArea — viewport', () => {
expect(useTripStore.getState().planMapViewport).toBeNull()
})
})
describe("MMapArea: the chat's routes", () => {
it("FE-MOB-MAPAREA-051: the chat's routes reach the plan map, a tap points along them, and no hover is wired", () => {
const onPoint = vi.fn()
const lines = [{ id: 'c1:look:route', ref: 'c1', lines: [[[47.7, 12.5], [47.8, 12.6]]], color: '#0a84ff', casing: '#fff' }]
const assistantMap = {
assistantRouteLines: lines,
onAssistantRouteHover: vi.fn(),
onAssistantRoutePoint: onPoint,
}
renderArea({}, { assistantMap } as unknown as Partial<TripPlanner>)
expect(mocks.props.assistantRouteLines).toBe(lines)
expect(mocks.props.onAssistantRouteHover).toBeUndefined()
;(mocks.props.onAssistantRoutePoint as (ref: string, lat: number, lng: number) => void)('c1', 47.75, 12.55)
expect(onPoint).toHaveBeenCalledWith('c1', 47.75, 12.55)
})
})
@@ -44,6 +44,8 @@ const NAMED_WITHIN_M = 2_000;
const GOOGLE_PLACE_ID = /^[\w-]{1,200}$/;
const MAX_OSM_ID_CHARS = 40;
/** A source's own id that names an OpenStreetMap element, as a tour POI carries it (`node:123`). */
const OSM_ELEMENT_ID = /^(node|way|relation):\d+$/;
const MAX_CALL_ID_CHARS = 64;
const WEATHER_KINDS: ReadonlySet<string> = new Set(['forecast', 'climate', 'current']);
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
@@ -53,6 +55,8 @@ const PLACE_LISTS: Readonly<Record<string, 'places' | 'pois'>> = {
search_place: 'places',
search_nearby_places: 'places',
search_pois: 'pois',
// Places along a saved tour: a hut, a spring, a viewpoint to add to the day.
search_tour_pois: 'pois',
};
/** Whether the trip runs today, the only time a source's "open now" means anything for it. */
@@ -72,7 +76,9 @@ function foundPlaceOf(row: Row, ref: string, card: TripCard, running: boolean):
if (!isLat(lat) || !isLng(lng) || !name) return null;
const googleId = typeof row.google_place_id === 'string' ? row.google_place_id : '';
const googlePlaceId = GOOGLE_PLACE_ID.test(googleId) ? googleId : null;
const osm = typeof row.osm_id === 'string' ? row.osm_id : '';
// A tour POI has no osm_id of its own: its id is the OpenStreetMap element when the source is OpenStreetMap.
const ownId = typeof row.id === 'string' && OSM_ELEMENT_ID.test(row.id) ? row.id : '';
const osm = typeof row.osm_id === 'string' ? row.osm_id : ownId;
const osmId = osm !== '' && osm.length <= MAX_OSM_ID_CHARS ? osm : null;
const rating = finite(row.rating);
const inTrip = [...card.places.values()].find((place) => isSamePlace(place, googlePlaceId, osmId));
@@ -11,6 +11,7 @@ import {
ASSISTANT_SERVER_TOOLS,
IMPORT_BOOKING_TOOL,
READ_BOOKING_FILE_TOOL,
TAKE_PROPOSAL_TOOL,
} from './assistant-tools.constants';
import type { AssistantMessage, AssistantMessagePart, AssistantToolApproval } from '@trek/shared';
@@ -87,6 +88,18 @@ function foundPlaceOutcome(part: ToolCallPart): string {
return `The user picked ${place} from the search results for ${day}; it waits for them to add or discard it.`;
}
/** What became of a proposal the user took from an answer to add it to the trip, as its stored part says. */
function takenProposalOutcome(part: ToolCallPart): string {
const name = quoted(part.args.name);
const what = part.args.via === 'create_city_walk' ? 'city walk' : 'hike';
if (part.approval?.approved === true) {
return part.isError ? `The ${what} ${name} could not be added.` : `The ${what} ${name} was added to the trip.`;
}
if (part.approval?.approved === false) return `The user discarded the ${what} ${name}.`;
if (part.approval?.resolution) return `The ${what} ${name} was not added; the user moved on without deciding.`;
return `The user took the proposed ${what} ${name} to add it; it waits for them to add or discard it.`;
}
/**
* The server's own steps of a message (reading an attached file, the bookings
* it found) as one note in plain words. They are no tools the provider knows,
@@ -107,6 +120,8 @@ export function serverStepsNote(calls: readonly ToolCallPart[]): string {
lines.push(bookingOutcome(part));
} else if (part.toolName === ADD_FOUND_PLACE_TOOL) {
lines.push(foundPlaceOutcome(part));
} else if (part.toolName === TAKE_PROPOSAL_TOOL) {
lines.push(takenProposalOutcome(part));
}
}
return lines.join(' ');
@@ -0,0 +1,311 @@
import { AssistantMessages } from '../../db/entities/AssistantMessages.entity';
import { AssistantThreads } from '../../db/entities/AssistantThreads.entity';
import { AssistantToolCalls } from '../../db/entities/AssistantToolCalls.entity';
import {
ASSISTANT_MESSAGE_PARTS_WRITE,
ASSISTANT_TOOL_ARGS,
ASSISTANT_TOOL_PREVIEW_WRITE,
} from '../../db/json-columns';
import type {
AssistantMessageRow,
AssistantMessagesRepository,
} from '../../db/repositories/AssistantMessages.repository';
import type { AssistantThreadsRepository } from '../../db/repositories/AssistantThreads.repository';
import type {
AssistantToolCallRow,
AssistantToolCallsRepository,
} from '../../db/repositories/AssistantToolCalls.repository';
import { dbNow } from '../../db/types';
import { encodeJson } from '../../utils/json-column';
import { aiRefusal } from '../ai-connections/ai-connections.constants';
import { RateLimitService } from '../common/rate-limit.service';
import { UnitOfWork } from '../database/unit-of-work';
import { buildTripCard, categoriesFrom, todayIn, type TripCard } from './assistant-context';
import { partsOf, toThreadMessages } from './assistant-history';
import { AssistantStreamRegistry } from './assistant-stream.registry';
import { TAKE_PROPOSAL_TOOL, TURNS_PER_MINUTE } from './assistant-tools.constants';
import { AssistantToolsService, type ToolRun } from './assistant-tools.service';
import { AssistantCardContext } from './cards/card-context';
import { recordOf, type Row } from './cards/card-values';
import { argText } from './cards/tour/tour-card-values';
import { cityWalkCard } from './cards/tour/tour-sport.cards';
import { describeToolCall } from './tool-call-preview.helpers';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import type {
AssistantAddFoundPlaceResponse,
AssistantMessage,
AssistantMessagePart,
AssistantTakeProposalRequest,
TourRouteCard,
} from '@trek/shared';
import { randomUUID } from 'node:crypto';
/**
* The proposing read a walk is asked again with: the answer stored in the
* thread is the copy the model saw (masked, its lists cut to fit), so the
* stations to create are read whole from the tool itself.
*/
const WALK_READ = 'propose_city_walk';
/** What a taken proposal reads: the trip's days and places, the category names its card writes with, a walk again. */
const TRIP_READS: ReadonlySet<string> = new Set(['get_trip_summary', 'list_places', 'list_categories', WALK_READ]);
/** Message rows searched for the proposal; the history a turn sends the provider. */
const SEARCHED_ROWS = 40;
/** Message rows the answer holding the change is read back from; a thread is shown with as many. */
const THREAD_ROWS = 50;
/** The longest name a city walk is given from its stations, well inside a tour's name. */
const WALK_NAME_CHARS = 120;
/** A proposal an answer showed: the call that proposed it, what it answered, and the card drawn from it. */
interface StoredProposal {
toolName: string;
args: Row;
result: Row;
card: TourRouteCard;
}
/** The proposing reads a card can be taken from, and the tool each one is created through. */
const CREATES: Readonly<Record<string, 'create_hike' | 'create_city_walk'>> = {
propose_hike: 'create_hike',
propose_city_walk: 'create_city_walk',
};
const PROPOSING: ReadonlySet<string> = new Set(Object.keys(CREATES));
/** The proposal under `toolCallId` in the newest rows that hold it, with its stored card; null when there is none. */
export function storedProposal(rows: readonly AssistantMessageRow[], toolCallId: string): StoredProposal | null {
for (const row of [...rows].reverse()) {
const parts = partsOf(row);
const call = parts.find(
(part) => part.type === 'tool-call' && part.toolCallId === toolCallId && PROPOSING.has(part.toolName),
);
const drawn = parts.find((part) => part.type === 'data-trek-card' && part.data.toolCallId === toolCallId);
const card = drawn?.type === 'data-trek-card' ? drawn.data.card : null;
if (call?.type === 'tool-call' && card?.kind === 'tourRoute') {
return { toolName: call.toolName, args: recordOf(call.args), result: recordOf(call.result), card };
}
}
return null;
}
/** A walk's name from its first and last named stations ("Marienplatz → Englischer Garten"), else day and time. */
function walkName(stations: readonly Row[], result: Row): string {
const names = stations
.map((station) => (typeof station.name === 'string' ? station.name.replace(/\s+/g, ' ').trim() : ''))
.filter((name) => name !== '');
const ends = [...new Set([names[0], names.at(-1)])].filter((name): name is string => !!name);
const frame = recordOf(result.frame);
const fallback = [result.date, frame.start_time].filter((part) => typeof part === 'string').join(' ');
const name = ends.length > 0 ? ends.join(' → ') : fallback;
return name.length > WALK_NAME_CHARS ? `${name.slice(0, WALK_NAME_CHARS - 1)}…` : name;
}
/**
* A walk asked again with the arguments it was proposed with: the stations to
* create from the tool's own answer, and the card drawn from that answer (the
* stored one when none can be drawn). Null when the walk cannot be read now.
*/
async function walkAgain(
read: (name: string, args: Row) => Promise<ToolRun>,
proposal: StoredProposal,
trip: TripCard,
today: string,
): Promise<StoredProposal | null> {
const run = await read(WALK_READ, proposal.args);
if (!run.ok) return null;
const drawn = cityWalkCard({ toolCallId: '', args: proposal.args, value: run.value, card: trip, today });
return { ...proposal, result: recordOf(run.value), card: drawn ?? proposal.card };
}
/**
* The card of the held change: the proposal's, waiting, under the name the
* change saves and without a variant label; a day only where the change puts
* the tour on that day (a walk is created on none, a hike asked by date alone
* on none either).
*/
export function pendingCardOf(card: TourRouteCard, args: Row): TourRouteCard {
const dayId = typeof args.day_id === 'number' ? args.day_id : null;
const keepsDay = card.schedule?.dayId === dayId;
const schedule = card.schedule && !keepsDay ? { ...card.schedule, dayId: null, dayNumber: null } : card.schedule;
return { ...card, source: 'pending', name: argText(args.name, 120) ?? card.name, label: null, schedule };
}
/** The fields of a propose_hike create_hike takes as they are. */
const HIKE_FIELDS = [
'name',
'waypoints',
'max_hiking_difficulty',
'hiking_preferences',
'round_trip',
'day_id',
'start_time',
] as const;
/**
* The change a taken proposal holds: create_hike with the proposal's own
* arguments for a hike whose way was planned, create_city_walk with the
* stations it answered for a walk that can be created. Null for a proposal
* nothing can be created from.
*/
export function takeArgsOf(proposal: StoredProposal): Row | null {
const via = CREATES[proposal.toolName];
if (via === 'create_hike') {
if (!proposal.card.routed) return null;
const args: Row = { via };
for (const key of HIKE_FIELDS) if (proposal.args[key] !== undefined) args[key] = proposal.args[key];
return args;
}
const stations = Array.isArray(proposal.result.stations_for_create) ? proposal.result.stations_for_create : [];
if (proposal.result.can_create === false || stations.length < 2) return null;
return { via, name: walkName(stations.map(recordOf), proposal.result), stations };
}
/**
* "Plan this" on a hike or a city walk an answer proposed: the change that
* creates it is held as a card, decided like one the model proposed, but
* without a model turn and without counting against the daily limit. What is
* created comes from the proposal stored in the thread, never from the
* request: a hike from its own arguments under the card the answer showed, a
* walk from the tool asked again with its own arguments, since the stored
* answer is the model's shortened copy. Nothing is written to the trip here;
* applying the card runs the tool with the user's own rights.
*/
@Injectable()
export class AssistantProposalsService {
constructor(
@InjectRepository(AssistantThreads) private readonly threads: AssistantThreadsRepository,
@InjectRepository(AssistantMessages) private readonly messages: AssistantMessagesRepository,
@InjectRepository(AssistantToolCalls) private readonly toolCalls: AssistantToolCallsRepository,
private readonly tools: AssistantToolsService,
private readonly rateLimit: RateLimitService,
private readonly streams: AssistantStreamRegistry,
private readonly uow: UnitOfWork,
private readonly cardContext: AssistantCardContext,
) {}
/**
* Holds the change for the proposal in the user's thread and answers with
* the answer that now holds it. Refused while a turn of the user runs
* (`busy`), while the thread holds changes nobody decided on
* (`decide_first`), past the per-minute limit, for a proposal the thread
* does not hold (404) and for one nothing can be created from.
*/
async take(
userId: number,
tripId: number,
threadId: number,
req: AssistantTakeProposalRequest,
): Promise<AssistantAddFoundPlaceResponse> {
if (!(await this.threads.findOwned(threadId, userId, tripId))) {
throw aiRefusal('not_found', 'Conversation not found');
}
if (!(await this.rateLimit.check('assistant', String(userId), TURNS_PER_MINUTE, 60_000, Date.now()))) {
throw aiRefusal('too_many_requests', 'Too many requests, try again in a minute');
}
const controller = new AbortController();
if (!(await this.streams.claim(userId, controller))) {
throw aiRefusal('busy', 'The assistant is already answering in another window');
}
try {
await this.hold(userId, tripId, threadId, req);
} finally {
await this.streams.release(userId, controller);
}
return { message: await this.answerOf(threadId) };
}
private async hold(userId: number, tripId: number, threadId: number, req: AssistantTakeProposalRequest) {
if ((await this.toolCalls.listOpen(threadId)).length > 0) {
throw aiRefusal('decide_first', 'Decide on the changes that are waiting first');
}
const stored = storedProposal(await this.messages.listLatest(threadId, SEARCHED_ROWS), req.toolCallId);
if (!stored) throw aiRefusal('not_found', 'This proposal is not in the conversation');
const id = randomUUID();
const today = todayIn(req.timeZone, new Date());
const { card, categories, proposal } = await this.tripOf(userId, tripId, stored, today);
const args = proposal ? takeArgsOf(proposal) : null;
if (!proposal || !args) throw aiRefusal('generic', 'Nothing can be created from this proposal: ask for it again');
const ctx = this.cardContext.contextOf(card, categories, req.locale, today);
const preview = {
...describeToolCall(TAKE_PROPOSAL_TOOL, id, args, ctx),
card: pendingCardOf(proposal.card, args),
};
const parts: AssistantMessagePart[] = [
{ type: 'tool-call', toolCallId: id, toolName: TAKE_PROPOSAL_TOOL, args, approval: { id } },
{ type: 'data-trek-change', data: preview },
];
const now = dbNow();
await this.uow.transactional(async () => {
const messageId = await this.messages.append({
threadId,
role: 'assistant',
parts: encodeJson(ASSISTANT_MESSAGE_PARTS_WRITE, parts),
providerState: null,
status: 'awaiting_approval',
model: null,
createdAt: now,
});
await this.toolCalls.insertProposals([
{
id,
threadId,
messageId,
providerCallId: `take_${id}`,
toolName: TAKE_PROPOSAL_TOOL,
args: encodeJson(ASSISTANT_TOOL_ARGS, args),
preview: encodeJson(ASSISTANT_TOOL_PREVIEW_WRITE, preview),
createdAt: now,
},
]);
await this.threads.touch(threadId, now);
});
}
/**
* The trip card, the category names and the proposal to take (a walk read
* again, null when it cannot be), read through the tools with the user's rights.
*/
private async tripOf(
userId: number,
tripId: number,
stored: StoredProposal,
today: string,
): Promise<{ card: TripCard; categories: ReadonlyMap<number, string>; proposal: StoredProposal | null }> {
const session = await this.tools.open(userId, tripId, TRIP_READS);
try {
const summary = await session.read('get_trip_summary');
const places = await session.read('list_places');
const listed = await session.read('list_categories');
const categories = categoriesFrom(listed.ok ? listed.value : null);
const card = buildTripCard(
tripId,
userId,
summary.ok ? summary.value : null,
places.ok ? places.value : null,
null,
);
const read = (name: string, args: Row) => session.read(name, args);
return {
card,
categories: new Map(categories.map((category): [number, string] => [category.id, category.name])),
proposal: stored.toolName === WALK_READ ? await walkAgain(read, stored, card, today) : stored,
};
} finally {
await session.close();
}
}
/** The newest answer of the thread as the client shows it, with what became of its changes. */
private async answerOf(threadId: number): Promise<AssistantMessage> {
const rows = await this.messages.listLatest(threadId, THREAD_ROWS);
const held = rows.filter((row) => partsOf(row).some((part) => part.type === 'tool-call' && part.approval));
const lists = await Promise.all(held.map((row) => this.toolCalls.listByMessage(row.id)));
const calls = new Map(lists.flat().map((call): [string, AssistantToolCallRow] => [call.id, call]));
const messages = toThreadMessages(rows, calls, new Date());
return messages[messages.length - 1];
}
}
@@ -119,6 +119,13 @@ export const IMPORT_BOOKING_TOOL = 'import_booking';
*/
export const ADD_FOUND_PLACE_TOOL = 'add_found_place';
/**
* A proposal an answer showed as a card, taken by the user ("Plan this" on a
* proposed hike or city walk). Held as a change card like a found place,
* through the model's tool in its `via` argument, without a model turn.
*/
export const TAKE_PROPOSAL_TOOL = 'take_proposal';
/**
* A fact or wish the user wants kept for this trip ("we are two"). It is no
* MCP tool: the server offers it to the model itself, holds it as a change
@@ -130,6 +137,9 @@ export const REMEMBER_TOOL = 'remember_for_trip';
/** The model's tools a found place is added through: a new place for the day, or one the trip has already. */
const FOUND_PLACE_VIA: ReadonlySet<string> = new Set(['create_and_assign_place', 'assign_place_to_day']);
/** The model's tools a taken proposal is created through: a hike, a city walk. */
const PROPOSAL_VIA: ReadonlySet<string> = new Set(['create_hike', 'create_city_walk']);
/**
* Changes the server applies itself: a booking from an attached file, a search
* result the user picked, a note the user wants kept. They are held, decided
@@ -143,20 +153,24 @@ export const ASSISTANT_SERVER_WRITE_TOOLS: Readonly<Record<string, AssistantWrit
[IMPORT_BOOKING_TOOL]: { verbKey: 'importBooking', entity: 'reservation' },
// The card names the verb of the `via` tool; these are its fallbacks.
[ADD_FOUND_PLACE_TOOL]: { verbKey: 'addPlaceToDay', entity: 'place', quiet: true },
[TAKE_PROPOSAL_TOOL]: { verbKey: 'addTour', entity: 'tour', quiet: true },
[REMEMBER_TOOL]: { verbKey: 'rememberNote', entity: 'memory' },
};
/**
* The tool a held call runs as: for a found place the model's tool in its
* `via` argument, for anything else the call's own. A `via` that is not one of
* the two is returned as it is, so it finds no card, run or undo.
* The tool a held call runs as: for a found place or a taken proposal the
* model's tool in its `via` argument, for anything else the call's own. A
* `via` that is not one of the tools it may name is returned as it is, so it
* finds no card, run or undo.
*/
export function writeToolOf(toolName: string, args: Record<string, unknown>): string {
if (toolName !== ADD_FOUND_PLACE_TOOL) return toolName;
return typeof args.via === 'string' && FOUND_PLACE_VIA.has(args.via) ? args.via : toolName;
const via = typeof args.via === 'string' ? args.via : '';
if (toolName === ADD_FOUND_PLACE_TOOL) return FOUND_PLACE_VIA.has(via) ? via : toolName;
if (toolName === TAKE_PROPOSAL_TOOL) return PROPOSAL_VIA.has(via) ? via : toolName;
return toolName;
}
/** The arguments a found place runs its `via` tool with: what that tool takes, nothing else. */
/** The arguments a found place or a taken proposal runs its `via` tool with: what that tool takes, nothing else. */
export function foundPlaceArgs(args: Record<string, unknown>): Record<string, unknown> {
if (args.via === 'assign_place_to_day') return { dayId: args.dayId, placeId: args.placeId };
const { via: _via, ...rest } = args;
@@ -10,6 +10,7 @@ import { ResponseContract } from '../common/response-contract';
import { onClientGone, openSseStream } from '../common/sse-stream';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { AssistantFoundPlacesService } from './assistant-found-places.service';
import { AssistantProposalsService } from './assistant-proposals.service';
import { AssistantShareService } from './assistant-share.service';
import {
AssistantAddFoundPlaceDto,
@@ -17,6 +18,7 @@ import {
AssistantDecisionsDto,
AssistantShareDto,
AssistantStopDto,
AssistantTakeProposalDto,
AssistantUndoDto,
} from './assistant.dto';
import { AssistantService, type TurnChannel } from './assistant.service';
@@ -67,6 +69,7 @@ export class AssistantController {
private readonly assistant: AssistantService,
private readonly foundPlaces: AssistantFoundPlacesService,
private readonly shares: AssistantShareService,
private readonly proposals: AssistantProposalsService,
) {}
@Get('threads')
@@ -125,6 +128,19 @@ export class AssistantController {
return this.foundPlaces.add(user.id, Number(tripId), idParam(threadId), body);
}
/** "Plan this" on a hike or a city walk an answer proposed: a change card that creates it, no model turn. */
@Post('threads/:threadId/proposals')
@HttpCode(200)
@ResponseContract(assistantAddFoundPlaceResponseSchema)
takeProposal(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('threadId') threadId: string,
@Body() body: AssistantTakeProposalDto,
) {
return this.proposals.take(user.id, Number(tripId), idParam(threadId), body);
}
/** An answer shared in the trip's chat, marked as from the assistant; the chat's own right decides. */
@Post('threads/:threadId/messages/:messageId/share')
@HttpCode(201)
@@ -10,6 +10,7 @@ import {
assistantDecisionsRequestSchema,
assistantShareRequestSchema,
assistantStopRequestSchema,
assistantTakeProposalRequestSchema,
assistantUndoRequestSchema,
} from '@trek/shared';
@@ -21,4 +22,5 @@ export class AssistantChatDto extends createZodDto(assistantChatRequestSchema) {
export class AssistantDecisionsDto extends createZodDto(assistantDecisionsRequestSchema) {}
export class AssistantShareDto extends createZodDto(assistantShareRequestSchema) {}
export class AssistantStopDto extends createZodDto(assistantStopRequestSchema) {}
export class AssistantTakeProposalDto extends createZodDto(assistantTakeProposalRequestSchema) {}
export class AssistantUndoDto extends createZodDto(assistantUndoRequestSchema) {}
@@ -16,6 +16,7 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module'
import { AssistantAttachmentsController } from './assistant-attachments.controller';
import { AssistantBookingImportService } from './assistant-booking-import.service';
import { AssistantFoundPlacesService } from './assistant-found-places.service';
import { AssistantProposalsService } from './assistant-proposals.service';
import { AssistantRetentionJob } from './assistant-retention.job';
import { AssistantShareService } from './assistant-share.service';
import { AssistantStreamRegistry, InMemoryAssistantStreamRegistry } from './assistant-stream.registry';
@@ -59,6 +60,7 @@ import { Module } from '@nestjs/common';
AssistantToolsService,
AssistantBookingImportService,
AssistantFoundPlacesService,
AssistantProposalsService,
AssistantShareService,
AssistantRetentionJob,
AssistantCardContext,
@@ -43,7 +43,6 @@ import { toolOutputForProvider } from './assistant-output';
import { AssistantStreamRegistry } from './assistant-stream.registry';
import { titleFrom } from './assistant-title';
import {
ADD_FOUND_PLACE_TOOL,
APPROVAL_TTL_MS,
ASSISTANT_SERVER_WRITE_TOOLS,
foundPlaceArgs,
@@ -349,7 +348,7 @@ export class AssistantService {
/**
* Decisions on changes the user proposed themselves (a found place for a
* day): applied or discarded like any others, but no model turn follows, so
* day, a taken proposal): applied or discarded like any others, but no model turn follows, so
* neither the connection nor the daily limit is asked.
*
* @txIndependent like `decide`: the decisions in one transaction, then each
@@ -390,7 +389,7 @@ export class AssistantService {
/**
* Runs one applied change: a booking from a file through the booking import,
* a found place through the tool it names, anything else through its own tool.
* a found place or a taken proposal through the tool it names, anything else through its own tool.
*/
private runChange(
session: AssistantToolSession,
@@ -400,7 +399,7 @@ export class AssistantService {
args: Record<string, unknown>,
): Promise<ToolRun> {
if (name === IMPORT_BOOKING_TOOL) return this.inContext(() => this.bookings.apply(userId, tripId, args));
if (name === ADD_FOUND_PLACE_TOOL) return session.call(writeToolOf(name, args), foundPlaceArgs(args));
if (writeToolOf(name, args) !== name) return session.call(writeToolOf(name, args), foundPlaceArgs(args));
if (name === REMEMBER_TOOL) return this.inContext(() => rememberForTrip(this.settings, userId, tripId, args));
return session.call(name, args);
}
+76 -10
View File
@@ -1,25 +1,91 @@
import type { DomainCards } from '../card-types';
import type { DomainCards, ToolCards } from '../card-types';
import { recordOf } from '../card-values';
import {
appliedTourCard,
createCityWalkCard,
createHikeCard,
createHikeEffect,
loadCreateHike,
loadUpdateTour,
updateTourCard,
type TourLoaded,
} from './tour-change.cards';
import { TOUR_DESCRIBERS } from './tour-describe';
import { DELETE_CREATED_TOUR } from './tour-undo';
import { tourListCard } from './tour-lists.cards';
import { proposalCard, savedTourCard } from './tour-route.cards';
import { cityWalkCard, paddleCard, skiCard } from './tour-sport.cards';
import { DELETE_CREATED_TOUR, DELETE_IMPORTED_TOUR } from './tour-undo';
/**
* The tours in the assistant chat. A tour is a place with a route: its id is
* its place's, so a created one is taken back by deleting that place.
* its place's, so a created one is taken back by deleting that place. The
* planning tools are always asked for their line for drawing, which the
* model never sees (every key holding `geometry` is dropped for it); the
* cards draw it.
*/
/** The planning tools answer their line for drawing; the flag is set by the server and hidden from the model. */
const WITH_GEOMETRY: NonNullable<ToolCards['argPolicy']> = { forced: { include_geometry: true } };
/** get_tour_plan as the model reads it: without the saved line in brief, which only the card draws. */
function withoutPreview(value: unknown): unknown {
const plan = recordOf(value);
if (!plan.line || typeof plan.line !== 'object') return value;
const { preview: _preview, ...line } = recordOf(plan.line);
return { ...plan, line };
}
const createHike: ToolCards<TourLoaded> = {
write: { verbKey: 'addTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.create_hike,
load: loadCreateHike,
change: createHikeCard,
effect: createHikeEffect,
applied: appliedTourCard,
undo: DELETE_CREATED_TOUR,
};
const updateTour: ToolCards<TourLoaded> = {
write: { verbKey: 'updateTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.update_tour,
load: loadUpdateTour,
change: updateTourCard,
// The new way stays beside the receipt: the answer is the tour as saved, naming it.
applied: appliedTourCard,
};
export const TOUR_CARDS: DomainCards = {
id: 'tour',
tools: {
create_hike: {
write: { verbKey: 'addTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.create_hike,
undo: DELETE_CREATED_TOUR,
},
propose_hike: { argPolicy: WITH_GEOMETRY, answer: proposalCard },
propose_city_walk: { argPolicy: WITH_GEOMETRY, answer: cityWalkCard },
plan_paddle_route: { argPolicy: WITH_GEOMETRY, answer: paddleCard },
plan_ski_route: { argPolicy: WITH_GEOMETRY, answer: skiCard },
get_tour_plan: { project: withoutPreview, answer: savedTourCard },
list_tours: { answer: tourListCard },
find_tours_for_day: { answer: tourListCard },
create_hike: createHike,
create_city_walk: {
write: { verbKey: 'addTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.create_city_walk,
change: createCityWalkCard,
applied: appliedTourCard,
undo: DELETE_CREATED_TOUR,
},
update_tour: updateTour,
import_tour_gpx: {
write: { verbKey: 'importTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.import_tour_gpx,
undo: DELETE_IMPORTED_TOUR,
},
take_over_tour: {
write: { verbKey: 'addTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.take_over_tour,
undo: DELETE_CREATED_TOUR,
},
update_tour: { write: { verbKey: 'updateTour', entity: 'tour' }, describe: TOUR_DESCRIBERS.update_tour },
import_tour_gpx: { write: { verbKey: 'importTour', entity: 'tour' }, describe: TOUR_DESCRIBERS.import_tour_gpx },
},
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.',
],
};
@@ -0,0 +1,246 @@
import { thinLine } from '../../../../utils/polyline';
import type { TripCard } from '../../assistant-context';
import { clipped, clockOf, finite, isLat, isLng, recordOf, type Row } from '../card-values';
import {
cumulativeMetres,
haversineMetres,
MAX_CARD_CHARS,
TOUR_POI_CATEGORIES,
TOUR_ROUTE_LIMITS,
type TourPoiCategory,
type TourRouteCard,
type TourRouteStop,
type TourRouteStretch,
} from '@trek/shared';
/**
* The readers the tour cards are built with: a tool's own answer or a call's
* arguments taken as they come, nothing trusted, every number bounded the way
* the card kind bounds it. A line is thinned for drawing and keeps either a
* height on every point or on none, so a profile is drawn only from a line
* whose heights are all known.
*/
export type RoutePoint = TourRouteCard['line'][number];
/** A coordinate as a card draws it: five decimals, about a metre. */
export function coord(value: number): number {
return Math.round(value * 1e5) / 1e5;
}
/** A whole number within `[min, max]`, or null for anything else. */
export function whole(value: unknown, min: number, max: number): number | null {
const number = finite(value);
if (number === null) return null;
const rounded = Math.round(number);
return rounded >= min && rounded <= max ? rounded : null;
}
export const metresOf = (value: unknown) => whole(value, 0, 5_000_000);
export const climbOf = (value: unknown) => whole(value, 0, 100_000);
export const heightOf = (value: unknown) => whole(value, -500, 9_000);
export const minutesOf = (value: unknown) => whole(value, 0, 86_400);
/** A SAC step, T1 to T6 as 1 to 6, or null. */
export function sacStepOf(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 6 ? value : null;
}
/** A point `{ lat, lng }` (or `[lat, lng]`), or null when it is none. */
export function latLngOf(value: unknown): { lat: number; lng: number } | null {
const [rawLat, rawLng] = Array.isArray(value) ? value : [recordOf(value).lat, recordOf(value).lng];
const lat = finite(rawLat);
const lng = finite(rawLng);
return isLat(lat) && isLng(lng) ? { lat: coord(lat), lng: coord(lng) } : null;
}
/**
* A line to draw from a tool's `[lat, lng]` or `[lat, lng, height]` points:
* bad points left out, thinned to `most`, every height null when one of the
* kept points has none.
*/
export function lineOf(raw: unknown, most: number = TOUR_ROUTE_LIMITS.line): RoutePoint[] {
if (!Array.isArray(raw)) return [];
const points = raw.flatMap((point: unknown): Array<[number, number, number | null]> => {
const at = latLngOf(point);
return at ? [[at.lat, at.lng, heightOf(Array.isArray(point) ? point[2] : null)]] : [];
});
const kept = thinLine(points, most);
const heights = kept.every(([, , height]) => height !== null);
return kept.map(([lat, lng, height]): RoutePoint => [lat, lng, heights ? height : null]);
}
/** A line of points joined straight, without heights: the stops of a way that is not planned. */
export function straightLine(points: ReadonlyArray<{ lat: number; lng: number }>): RoutePoint[] {
return thinLine(points, TOUR_ROUTE_LIMITS.line).map(({ lat, lng }): RoutePoint => [lat, lng, null]);
}
/** Metres along a straight line at each of its points. */
export function straightMetres(points: ReadonlyArray<{ lat: number; lng: number }>): number[] {
return cumulativeMetres(points.map(({ lat, lng }) => [lat, lng] as const)).map(Math.round);
}
/** The lowest and highest height of a line whose heights are known, else nulls. */
export function heightRange(line: readonly RoutePoint[]): { min: number | null; max: number | null } {
const heights = line.map(([, , height]) => height).filter((height): height is number => height !== null);
if (heights.length === 0 || heights.length !== line.length) return { min: null, max: null };
return { min: Math.min(...heights), max: Math.max(...heights) };
}
const POI_CATEGORIES: ReadonlySet<string> = new Set(TOUR_POI_CATEGORIES);
/** A kind of place along a tour, or null. */
export function poiCategoryOf(value: unknown): TourPoiCategory | null {
return typeof value === 'string' && POI_CATEGORIES.has(value) ? (value as TourPoiCategory) : null;
}
/** A stop with every field it does not name empty. */
export function stopOf(
fields: Pick<TourRouteStop, 'role' | 'lat' | 'lng' | 'atM'> & Partial<TourRouteStop>,
): TourRouteStop {
return {
kind: null,
name: null,
offM: null,
minutes: null,
clock: null,
stayMinutes: null,
stageEnd: false,
flag: null,
...fields,
};
}
/** The role of the point at `index` of `count` a way runs through. */
export function roleAt(index: number, count: number): TourRouteStop['role'] {
if (index === 0) return 'start';
return index === count - 1 ? 'end' : 'via';
}
/** A stop's name from a tool's answer: masked and cut, as a card shows it. */
export function stopName(value: unknown): string | null {
return clipped(value, 80);
}
/**
* A text from a call's arguments (a name the change will store): on one
* line and cut to `max`, never masked, so the card shows what is saved.
*/
export function argText(value: unknown, max: number): string | null {
if (typeof value !== 'string') return null;
const line = value.replace(/\s+/g, ' ').trim();
if (line === '') return null;
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
}
/**
* The stops in the order they lie along the way, at most as many as a card
* holds: the ends and the named points first kept, the places along it after.
*/
export function sortedStops(stops: readonly TourRouteStop[]): TourRouteStop[] {
const most = TOUR_ROUTE_LIMITS.stops + TOUR_ROUTE_LIMITS.along;
const own = stops.filter((stop) => stop.role !== 'along');
const kept = [...own.slice(0, TOUR_ROUTE_LIMITS.stops - 1), ...own.slice(-1)].slice(0, own.length);
const along = stops.filter((stop) => stop.role === 'along').slice(0, most - kept.length);
return [...kept, ...along].sort((a, b) => a.atM - b.atM);
}
/** A clock `minutes` after a start, `HH:MM`, wrapped past midnight; null without a start. */
export function clockAfter(start: string | null, minutes: number | null): string | null {
const begin = clockOf(start);
if (!begin || minutes === null) return null;
const total = Number(begin.slice(0, 2)) * 60 + Number(begin.slice(3, 5)) + minutes;
const within = ((total % 1440) + 1440) % 1440;
return `${String(Math.floor(within / 60)).padStart(2, '0')}:${String(within % 60).padStart(2, '0')}`;
}
/** The number of a trip day, from the trip card; null for a day it does not hold. */
export function dayNumberOf(card: TripCard, dayId: number | null): number | null {
if (dayId === null) return null;
const number = card.days.find((day) => day.id === dayId)?.number ?? null;
return number !== null && number >= 1 && number <= 999 ? number : null;
}
/** Whether two points lie within `metres` of each other. */
export function near(a: { lat: number; lng: number }, b: { lat: number; lng: number }, metres: number): boolean {
return haversineMetres(a.lat, a.lng, b.lat, b.lng) <= metres;
}
/** The rows of an array, each as a record. */
export function rowsIn(value: unknown): Row[] {
return Array.isArray(value) ? value.map(recordOf) : [];
}
/** A tour card with every field it does not name empty: no figures, no stretches, no schedule. */
export function routeCard(
fields: Pick<TourRouteCard, 'source' | 'activity' | 'line' | 'routed'> & Partial<TourRouteCard>,
): TourRouteCard {
return {
kind: 'tourRoute',
placeId: null,
name: null,
label: null,
distanceM: null,
ascentM: null,
descentM: null,
minEleM: null,
maxEleM: null,
minutes: null,
minutesBasis: null,
limit: null,
grades: null,
problem: null,
stretches: [],
stops: [],
stages: null,
skiScheme: null,
schedule: null,
warnings: [],
previous: null,
...fields,
};
}
/** How much a stretch tells: a SAC grade by its step, any other stretch the least. */
function weightOf(stretch: TourRouteStretch): number {
return stretch.facet === 'sac' ? stretch.step : 0;
}
/**
* At most `most` stretches, the hardest kept wherever they lie (among equals
* the first), in their order along the way: the end of a long route keeps its
* grades rather than reading as ungraded.
*/
export function hardestStretches(stretches: readonly TourRouteStretch[], most: number): TourRouteStretch[] {
if (stretches.length <= most) return [...stretches];
return [...stretches]
.sort((a, b) => weightOf(b) - weightOf(a))
.slice(0, most)
.sort((a, b) => a.fromM - b.fromM);
}
/** How a card too heavy to send is lightened, one step after the other, the least telling first. */
const LIGHTER: ReadonlyArray<(card: TourRouteCard) => TourRouteCard> = [
(card) => ({
...card,
line: thinLine(card.line, 200),
previous: card.previous ? { ...card.previous, line: thinLine(card.previous.line, 100) } : null,
}),
(card) => ({ ...card, stops: card.stops.filter((stop) => stop.role !== 'along' || stop.kind === 'highest') }),
(card) => ({ ...card, line: thinLine(card.line, 120), stretches: hardestStretches(card.stretches, 40) }),
];
/**
* The card as light as it must be to be sent (MAX_CARD_CHARS as JSON): a
* card of long names, a full line and every stretch is thinned first, then
* loses the places along the way, then more of its line and all but its
* hardest stretches. One that is still too heavy is refused by the registry.
*/
export function fitted(card: TourRouteCard): TourRouteCard {
let shown = card;
for (const lighter of LIGHTER) {
if (JSON.stringify(shown).length <= MAX_CARD_CHARS) return shown;
shown = lighter(shown);
}
return shown;
}
@@ -0,0 +1,234 @@
import type { CardReader, PreviewContext, ToolArgs } from '../card-types';
import { canonicalJson, clipped, finite, recordOf, type Row } from '../card-values';
import { hikeCard } from './tour-route.cards';
import {
argText,
climbOf,
fitted,
latLngOf,
lineOf,
metresOf,
minutesOf,
roleAt,
routeCard,
rowsIn,
sacStepOf,
sortedStops,
stopOf,
straightLine,
straightMetres,
whole,
} from './tour-card-values';
import {
hikingPreferencesOf,
TOUR_ROUTE_LIMITS,
TOURS_MAX_HIKING_DIFFICULTY,
type AssistantChangeEffect,
type AssistantChangePreview,
type HikingPreferences,
type TourRouteCard,
} from '@trek/shared';
/**
* The cards inside the change cards of the tours: a held create_hike shows
* the way the proposal of the same points showed (the planner keeps the
* plan for a quarter of an hour, so the saved line is that line), a held
* create_city_walk its stations joined straight (its way is planned on
* save), a held update_tour the new way beside the one it replaces. A way
* that could not be planned now is drawn straight with the reason.
*/
/** What a held hike change looked up: the plan of its way, and for a change the saved tour it changes. */
export interface TourLoaded {
plan: unknown;
saved: unknown;
}
/** The fields propose_hike takes of a create_hike, without the places along the way the change card does not show. */
const PLAN_FIELDS = ['name', 'waypoints', 'max_hiking_difficulty', 'hiking_preferences', 'round_trip'] as const;
/** The proposal a held create_hike stands for: the same points, limit, preferences and day. */
export function planArgsOf(args: ToolArgs): ToolArgs {
const plan: ToolArgs = { along: false };
for (const key of [...PLAN_FIELDS, 'day_id', 'start_time'] as const) {
if (args[key] !== undefined) plan[key] = args[key];
}
return plan;
}
/** create_hike: plans the way through the points (a proposal of a moment ago is answered from the planner's memo). */
export async function loadCreateHike(args: ToolArgs, read: CardReader): Promise<TourLoaded> {
return { plan: await read('propose_hike', planArgsOf(args)), saved: null };
}
/** A saved hike's limit and preferences, as update_tour keeps them when the change does not name them. */
function keptSettings(tour: Row): { limit: number; preferences: HikingPreferences | null } {
const limit = sacStepOf(tour.max_hiking_difficulty) ?? TOURS_MAX_HIKING_DIFFICULTY;
return { limit, preferences: hikingPreferencesOf(recordOf(tour.hiking_preferences) as HikingPreferences) };
}
/**
* Whether update_tour plans the hike's way anew, as it decides: new points,
* a hike without a line yet, or a limit or preferences other than the saved.
*/
export function replans(args: ToolArgs, saved: unknown): boolean {
const plan = recordOf(saved);
const kept = keptSettings(recordOf(plan.tour));
const limit = sacStepOf(args.max_hiking_difficulty) ?? kept.limit;
const preferences =
args.hiking_preferences === undefined
? kept.preferences
: hikingPreferencesOf(recordOf(args.hiking_preferences) as HikingPreferences);
return (
args.waypoints !== undefined ||
plan.line === null ||
limit !== kept.limit ||
canonicalJson(preferences) !== canonicalJson(kept.preferences)
);
}
/**
* update_tour: the saved tour, and where the change plans the way anew the
* way it will plan, through the new points or the saved ones under the saved
* limit and preferences where the change leaves them, so the planner's memo
* answers it and the card shows the line the change will save.
*/
export async function loadUpdateTour(args: ToolArgs, read: CardReader): Promise<TourLoaded | null> {
const placeId = whole(args.placeId, 1, Number.MAX_SAFE_INTEGER);
if (placeId === null) return null;
const saved = await read('get_tour_plan', { placeId });
if (!saved || !replans(args, saved)) return { plan: null, saved };
const tour = recordOf(recordOf(saved).tour);
const kept = keptSettings(tour);
const points = args.waypoints ?? rowsIn(recordOf(saved).waypoints).map(({ lat, lng }) => ({ lat, lng }));
const plan = await read('propose_hike', {
name: args.name ?? tour.name,
waypoints: points,
max_hiking_difficulty: args.max_hiking_difficulty ?? kept.limit,
hiking_preferences: args.hiking_preferences === undefined ? kept.preferences : args.hiking_preferences,
along: false,
});
return { plan, saved };
}
/** Why a held create is drawn straight: the planner's own reason where it refuses the hike, else "planned on save". */
function heldProblem(card: TourRouteCard | null): TourRouteCard['problem'] {
const why = card?.problem?.why;
const refuses = why !== undefined && why !== 'router_unavailable' && why !== 'not_planned';
return refuses && card?.problem ? card.problem : { why: 'not_planned', position: null, snappedM: null };
}
/** The points of a held hike or walk joined straight, as stops along the straight line. */
function straightCard(points: readonly Row[], activity: 'hike' | 'walk', fields: Partial<TourRouteCard>) {
const stops = points.flatMap((point) => {
const at = latLngOf(point);
return at ? [{ ...at, point }] : [];
});
if (stops.length < 2) return null;
const along = straightMetres(stops);
return routeCard({
source: 'pending',
activity,
routed: false,
problem: { why: 'not_planned', position: null, snappedM: null },
line: straightLine(stops),
stops: sortedStops(
stops.map(({ lat, lng, point }, index) =>
stopOf({
role: roleAt(index, stops.length),
kind: activity === 'walk' ? 'station' : null,
name: argText(point.name, 80),
lat,
lng,
atM: Math.min(along[index] ?? 0, 5_000_000),
stayMinutes: minutesOf(point.stay_minutes),
}),
),
),
...fields,
});
}
/** The points a hike is asked through, a round trip's way back included. */
function hikePoints(args: ToolArgs): Row[] {
const points = rowsIn(args.waypoints);
return args.round_trip === true && points[0] ? [...points, points[0]] : points;
}
/** create_hike: the planned way as the proposal drew it, else the points straight. */
export function createHikeCard(ctx: PreviewContext, args: ToolArgs, loaded: TourLoaded | null): TourRouteCard | null {
const name = argText(args.name, 120);
const planned = loaded ? hikeCard(loaded.plan, args, ctx.card, 'pending') : null;
if (planned?.routed) return fitted({ ...planned, name: name ?? planned.name, label: null });
const straight = straightCard(hikePoints(args), 'hike', {
name,
limit: sacStepOf(args.max_hiking_difficulty) ?? TOURS_MAX_HIKING_DIFFICULTY,
problem: heldProblem(planned),
});
return straight ? fitted(straight) : null;
}
/** create_city_walk: the stations joined straight; the way on foot is planned when the walk is saved. */
export function createCityWalkCard(_ctx: PreviewContext, args: ToolArgs): TourRouteCard | null {
const straight = straightCard(rowsIn(args.stations), 'walk', { name: argText(args.name, 120) });
return straight ? fitted(straight) : null;
}
/** The saved way a change replaces, in brief: its line flat, its length, climb and time. */
function previousOf(saved: unknown): TourRouteCard['previous'] {
const tour = recordOf(recordOf(saved).tour);
const line = lineOf(recordOf(recordOf(saved).line).preview, TOUR_ROUTE_LIMITS.previous);
const distance = finite(tour.distance);
const distanceM = distance === null ? null : metresOf(distance * 1000);
if (line.length < 2 || distanceM === null) return null;
return {
line: line.map(([lat, lng]): [number, number] => [lat, lng]),
distanceM,
ascentM: climbOf(tour.elevation_gain),
minutes: minutesOf(tour.planned_duration_minutes) ?? minutesOf(tour.duration),
};
}
/** update_tour: the new way beside the saved one; a change of name or words alone draws nothing. */
export function updateTourCard(ctx: PreviewContext, args: ToolArgs, loaded: TourLoaded | null): TourRouteCard | null {
if (!loaded?.saved || !replans(args, loaded.saved)) return null;
const tour = recordOf(recordOf(loaded.saved).tour);
const placeId = whole(args.placeId, 1, Number.MAX_SAFE_INTEGER);
const name = argText(args.name, 120) ?? clipped(tour.name, 120);
const previous = previousOf(loaded.saved);
const planned = hikeCard(loaded.plan, args, ctx.card, 'pending');
if (planned?.routed) return fitted({ ...planned, placeId, name, label: null, previous });
const points = args.waypoints === undefined ? rowsIn(recordOf(loaded.saved).waypoints) : rowsIn(args.waypoints);
const straight = straightCard(points, 'hike', { placeId, name, previous, problem: heldProblem(planned) });
return straight ? fitted(straight) : null;
}
/** create_hike on a day: the hike lands on it at its first point, for the day's preview on the map. */
export function createHikeEffect(ctx: PreviewContext, args: ToolArgs): AssistantChangeEffect | null {
const dayId = whole(args.day_id, 1, Number.MAX_SAFE_INTEGER);
const start = latLngOf(rowsIn(args.waypoints)[0]);
if (dayId === null || !ctx.card.days.some((day) => day.id === dayId)) return null;
const name = argText(args.name, 200);
return {
kind: 'add',
dayId,
fromDayId: null,
placeId: null,
assignmentId: null,
place: start && name ? { name, lat: start.lat, lng: start.lng, categoryId: null } : null,
};
}
/**
* The card of an applied create: the one the change showed, now naming the
* tour it created, so the receipt opens it. Null when the change showed none
* or the answer names no tour.
*/
export function appliedTourCard(input: {
result: unknown;
preview: AssistantChangePreview | null;
}): TourRouteCard | null {
const shown = input.preview?.card;
const placeId = whole(recordOf(recordOf(input.result).tour).place_id, 1, Number.MAX_SAFE_INTEGER);
return shown?.kind === 'tourRoute' && placeId !== null ? { ...shown, placeId } : null;
}
@@ -1,6 +1,6 @@
import type { Described, PreviewContext, ToolArgs } from '../card-types';
import { recordOf } from '../card-values';
import { clip, id, Rows, text } from '../preview-format';
import { clip, dayLabel, id, Rows, text } from '../preview-format';
import { TOURS_MAX_HIKING_DIFFICULTY } from '@trek/shared';
/**
@@ -43,6 +43,13 @@ function hours(value: unknown): string {
return `${Math.floor(value / 60)}:${String(value % 60).padStart(2, '0')} h`;
}
/** The day a hike is put on, with the start of its visit when one is given; nothing for a day the trip lacks. */
function onDay(ctx: PreviewContext, dayId: number | null, start: unknown): string {
if (dayId === null || !ctx.card.days.some((day) => day.id === dayId)) return '';
const at = typeof start === 'string' && /^\d{2}:\d{2}$/.test(start) ? `, ${start}` : '';
return `${dayLabel(ctx, dayId)}${at}`;
}
/** The first name a GPX text gives, a track's or the file's own. */
function gpxName(gpx: unknown): string {
if (typeof gpx !== 'string') return '';
@@ -53,12 +60,14 @@ function gpxName(gpx: unknown): string {
export const TOUR_DESCRIBERS = {
create_hike: (ctx, args) => {
const day = onDay(ctx, id(args, 'day_id'), args.start_time);
const rows = new Rows()
.add('route', routeOf(ctx, args.waypoints, args.round_trip))
.add('day', day)
.add('difficulty', grade(args.max_hiking_difficulty ?? TOURS_MAX_HIKING_DIFFICULTY))
.add('time', hours(args.planned_duration_minutes))
.add('description', text(args.description));
return { subject: text(args.name), rows, dayId: null, placeIds: [] };
return { subject: text(args.name), rows, dayId: day ? id(args, 'day_id') : null, placeIds: [] };
},
create_city_walk: (ctx, args) => {
const rows = new Rows().add('route', routeOf(ctx, args.stations, false)).add('description', text(args.description));
@@ -82,6 +91,8 @@ export const TOUR_DESCRIBERS = {
placeIds: placeId === null ? [] : [placeId],
};
},
// A tour of another trip copied in; its own name stays unless the copy is given one.
take_over_tour: (_ctx, args) => ({ subject: text(args.name), rows: new Rows(), dayId: null, placeIds: [] }),
import_tour_gpx: (_ctx, args) => {
const rows = new Rows().add('type', text(args.tour_type));
return { subject: text(args.file_name) || gpxName(args.gpx) || 'GPX', rows, dayId: null, placeIds: [] };
@@ -0,0 +1,49 @@
import type { AnswerInput } from '../card-types';
import { clipped, finite, recordOf } from '../card-values';
import { climbOf, metresOf, minutesOf, rowsIn, sacStepOf, whole } from './tour-card-values';
import { TOUR_LIST_ROWS, tourTypeKeySchema, type TourListCard, type TourListRow } from '@trek/shared';
/**
* The tours of a trip (list_tours) or of one of its days (find_tours_for_day)
* as rows: what each tour is, how long and how high, its stages, and the
* days it is on. A tour without a name or a type this build knows is left
* out; an empty list has no card.
*/
/** A saved tour as a row, or null when it cannot be one. */
function rowOf(raw: unknown): TourListRow | null {
const tour = recordOf(raw);
const placeId = whole(tour.place_id, 1, Number.MAX_SAFE_INTEGER);
const name = clipped(tour.name, 120);
const activity = tourTypeKeySchema.safeParse(tour.tour_type);
if (placeId === null || !name || !activity.success) return null;
const distance = finite(tour.distance);
const estimate = recordOf(tour.time_estimate);
const days = rowsIn(tour.days)
.map((day) => whole(day.day_number, 1, 999))
.filter((number): number is number => number !== null);
return {
placeId,
name,
activity: activity.data,
limit: sacStepOf(tour.max_hiking_difficulty),
distanceM: distance === null ? null : metresOf(distance * 1000),
ascentM: climbOf(tour.elevation_gain),
minutes: minutesOf(tour.planned_duration_minutes) ?? minutesOf(tour.duration) ?? minutesOf(estimate.minutes),
stages: whole(tour.stage_count, 2, 60),
days: [...new Set(days)].sort((a, b) => a - b).slice(0, 10),
};
}
/** list_tours, find_tours_for_day: the first rows and how many more there are. */
export function tourListCard({ value, args }: AnswerInput): TourListCard | null {
const tours = recordOf(value).tours;
const rows = (Array.isArray(tours) ? tours : []).map(rowOf).filter((row): row is TourListRow => row !== null);
if (rows.length === 0) return null;
return {
kind: 'tourList',
dayId: whole(args.dayId, 1, Number.MAX_SAFE_INTEGER),
more: Math.min(rows.length - Math.min(rows.length, TOUR_LIST_ROWS), 10_000),
rows: rows.slice(0, TOUR_LIST_ROWS),
};
}
@@ -0,0 +1,344 @@
import type { TripCard } from '../../assistant-context';
import type { AnswerInput } from '../card-types';
import { clipped, clockOf, finite, recordOf, type Row } from '../card-values';
import {
climbOf,
clockAfter,
dayNumberOf,
fitted,
hardestStretches,
heightOf,
heightRange,
latLngOf,
lineOf,
metresOf,
minutesOf,
near,
poiCategoryOf,
roleAt,
routeCard,
rowsIn,
sacStepOf,
sortedStops,
stopName,
stopOf,
straightLine,
straightMetres,
whole,
type RoutePoint,
} from './tour-card-values';
import {
cumulativeMetres,
HIKE_UNROUTED_REASONS,
legMinutes,
nearestAlongM,
TOUR_ROUTE_LIMITS,
TOUR_WARNING_KINDS,
tourTypeKeySchema,
type TourRouteCard,
type TourRouteSchedule,
type TourRouteStop,
type TourRouteStretch,
} from '@trek/shared';
/**
* The cards of a hike the assistant proposed (propose_hike) and of a saved
* tour it read (get_tour_plan), built from the tool's raw answer. A proposal
* draws its line only when the tool added it for drawing (the assistant
* always asks), so without `line_geometry` there is no card. A way that was
* not planned is drawn straight between the points it was asked through,
* with the reason.
*/
/** How close a summit along the way must lie to the highest point to stand for it. */
const SAME_SUMMIT_M = 150;
/** How close a saved tour's last point must lie to the end of its line to be that end (a loop's included). */
const LOOP_END_M = 60;
const UNROUTED: ReadonlySet<string> = new Set(HIKE_UNROUTED_REASONS);
const WARNINGS: ReadonlySet<string> = new Set(TOUR_WARNING_KINDS);
/** Graded stretches merged where the step repeats, at most the card's share, the hardest kept. */
export function sacStretches(segments: unknown): TourRouteStretch[] {
const merged: Array<Extract<TourRouteStretch, { facet: 'sac' }>> = [];
for (const segment of rowsIn(segments)) {
const step = sacStepOf(segment.sac);
const fromM = metresOf(segment.from_m);
const toM = metresOf(segment.to_m);
if (step === null || fromM === null || toM === null || toM < fromM) continue;
const last = merged.at(-1);
if (last && last.step === step && fromM <= last.toM) last.toM = Math.max(last.toM, toM);
else merged.push({ facet: 'sac', fromM, toM, step });
}
return hardestStretches(merged, TOUR_ROUTE_LIMITS.stretches);
}
/** The walking minutes to a spot along the way, read between the points whose minutes are known. */
function minutesAt(stops: readonly TourRouteStop[], atM: number): number | null {
const timed = stops.filter((stop) => stop.minutes !== null);
const after = timed.findIndex((stop) => stop.atM >= atM);
if (after < 0) return null;
const b = timed[after];
const a = after > 0 ? timed[after - 1] : null;
if (!b || b.minutes === null) return null;
if (!a || a.minutes === null || b.atM === a.atM) return b.atM === atM ? b.minutes : null;
return Math.round(a.minutes + ((b.minutes - a.minutes) * (atM - a.atM)) / (b.atM - a.atM));
}
/** The day of a proposal: its date, sun, weather by the hour; null without a date. */
function scheduleOf(value: unknown, card: TripCard): TourRouteSchedule | null {
const schedule = recordOf(value);
const date = typeof schedule.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(schedule.date) ? schedule.date : null;
if (!date) return null;
const dayId = whole(schedule.day_id, 1, Number.MAX_SAFE_INTEGER);
const hours = rowsIn(schedule.hours).flatMap((hour) => {
const at = whole(hour.hour, 0, 23);
const main = clipped(hour.main, 20);
const tempC = whole(hour.temp_c, -80, 60);
if (at === null || !main || tempC === null) return [];
return [{ hour: at, main, tempC, rainPct: whole(hour.rain_pct, 0, 100), windKmh: whole(hour.wind_kmh, 0, 400) }];
});
return {
date,
dayId,
dayNumber: dayNumberOf(card, dayId),
start: clockOf(schedule.start),
arrive: clockOf(schedule.arrive),
nextDay: schedule.next_day === true,
sunrise: clockOf(schedule.sunrise),
sunset: clockOf(schedule.sunset),
latestStart: clockOf(schedule.latest_start),
weather: schedule.weather === 'forecast' || schedule.weather === 'climate' ? schedule.weather : null,
hours: hours.slice(0, TOUR_ROUTE_LIMITS.hours),
};
}
/** What to look out for, as the proposal names it. */
function warningsOf(value: unknown): TourRouteCard['warnings'] {
return rowsIn(value)
.flatMap((warning): TourRouteCard['warnings'] => {
const kind = typeof warning.kind === 'string' && WARNINGS.has(warning.kind) ? warning.kind : null;
if (!kind) return [];
return [{ kind: kind as TourRouteCard['warnings'][number]['kind'], at: clockOf(warning.at), count: null }];
})
.slice(0, TOUR_ROUTE_LIMITS.warnings);
}
/** The points a proposal was asked through, as stops. */
function waypointStops(waypoints: readonly Row[], start: string | null): TourRouteStop[] {
return waypoints.flatMap((point, index) => {
const at = latLngOf(point);
if (!at) return [];
const minutes = minutesOf(point.minutes);
return [
stopOf({
role: roleAt(index, waypoints.length),
kind: poiCategoryOf(point.poi_category),
name: stopName(point.name),
...at,
atM: metresOf(point.at_m) ?? 0,
minutes,
clock: clockOf(point.arrival) ?? clockAfter(start, minutes),
}),
];
});
}
/** 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)
.slice(0, TOUR_ROUTE_LIMITS.along)
.flatMap((place) => {
const at = latLngOf(place);
const atM = metresOf(place.at_m);
if (!at || atM === null) return [];
const offM = whole(place.off_m, 0, Number.MAX_SAFE_INTEGER);
return [
stopOf({
role: 'along',
kind: poiCategoryOf(place.category),
name: stopName(place.name),
...at,
atM,
offM: offM === null ? null : Math.min(offM, 2_000),
}),
];
});
const high = recordOf(value.high_point);
const top = latLngOf(high);
const topAt = metresOf(high.at_m);
const summit = [...own, ...along].some(
(stop) => stop.kind === 'peak' && top !== null && near(stop, top, SAME_SUMMIT_M),
);
const highest =
top && topAt !== null && !summit ? [stopOf({ role: 'along', kind: 'highest', ...top, atM: topAt })] : [];
return [...along, ...highest].map((stop) => {
const minutes = minutesAt(own, stop.atM);
return { ...stop, minutes, clock: clockAfter(start, minutes) };
});
}
/** A proposal whose way was not planned: its points joined straight, with why. */
function unroutedHike(value: Row, args: Row, source: TourRouteCard['source']): TourRouteCard | null {
const asked = rowsIn(value.waypoints).length > 0 ? rowsIn(value.waypoints) : rowsIn(args.waypoints);
const points = asked.flatMap((point) => {
const at = latLngOf(point);
return at ? [{ ...at, name: stopName(point.name), kind: poiCategoryOf(point.poi_category) }] : [];
});
if (points.length < 2) return null;
const route = recordOf(value.route);
const unreachable = recordOf(value.unreachable);
const why = typeof route.why === 'string' && UNROUTED.has(route.why) ? route.why : 'not_planned';
const along = straightMetres(points);
return routeCard({
source,
activity: 'hike',
name: clipped(value.name ?? args.name, 120),
label: clipped(value.variant_label ?? args.variant_label, 40),
limit: sacStepOf(value.max_hiking_difficulty ?? args.max_hiking_difficulty),
routed: false,
problem: {
why: why as NonNullable<TourRouteCard['problem']>['why'],
position: whole(route.position, 1, 1_000),
snappedM: whole(unreachable.snapped_m, 0, 1_000_000),
},
line: straightLine(points),
stops: sortedStops(
points.map((point, index) =>
stopOf({ role: roleAt(index, points.length), ...point, atM: Math.min(along[index] ?? 0, 5_000_000) }),
),
),
});
}
/**
* The card of a hike proposal: the way with its figures, grades, stops and
* day. `source` is `pending` when it shows a held create of the same hike.
*/
export function hikeCard(
value: unknown,
args: Row,
card: TripCard,
source: TourRouteCard['source'],
): TourRouteCard | null {
const plan = recordOf(value);
if (recordOf(plan.route).planned !== true) return unroutedHike(plan, args, source);
const line = lineOf(plan.line_geometry);
if (line.length < 2) return null;
const sac = plan.sac === null || plan.sac === undefined ? null : recordOf(plan.sac);
const start = clockOf(plan.start_time);
const own = waypointStops(rowsIn(plan.waypoints), start);
const basis = plan.duration_basis === 'din33466' || plan.duration_basis === 'router' ? plan.duration_basis : null;
const unknown = finite(sac?.unknown_share);
return routeCard({
source,
activity: 'hike',
name: clipped(plan.name, 120),
label: clipped(plan.variant_label, 40),
distanceM: metresOf(plan.distance_m),
ascentM: climbOf(plan.ascent_m),
descentM: climbOf(plan.descent_m),
minEleM: heightOf(plan.min_ele_m),
maxEleM: heightOf(plan.max_ele_m),
minutes: minutesOf(plan.duration_minutes),
minutesBasis: basis,
limit: sacStepOf(plan.max_hiking_difficulty),
grades: sac ? { highest: sacStepOf(sac.highest), unknownShare: Math.min(1, Math.max(0, unknown ?? 0)) } : null,
routed: true,
line,
stretches: sacStretches(sac?.segments),
stops: sortedStops([...own, ...alongStops(plan, own, start)]),
schedule: scheduleOf(plan.schedule, card),
warnings: warningsOf(plan.warnings),
});
}
/** propose_hike: the proposal as a card. */
export function proposalCard({ value, args, card }: AnswerInput): TourRouteCard | null {
const proposal = hikeCard(value, args, card, 'proposal');
return proposal ? fitted(proposal) : null;
}
/**
* The control points of a saved tour (a city walk's stations), placed along
* its line and timed by the signposts' rule; shaping points are left out.
*/
function savedStops(
waypoints: readonly Row[],
line: readonly RoutePoint[],
total: number | null,
walk: boolean,
): TourRouteStop[] {
const points = waypoints.filter((point) => point.shaping !== true && latLngOf(point) !== null);
const length = line.length >= 2 ? (cumulativeMetres(line).at(-1) ?? 0) : 0;
const end = line.at(-1);
// A tour still waiting for its way: its points lie along the straight line between them.
const straight = line.length < 2 ? straightMetres(points.flatMap((point) => latLngOf(point) ?? [])) : null;
const atMs = points.map((point, index) => {
const at = latLngOf(point);
if (straight) return straight[index] ?? 0;
if (!at) return 0;
// A loop ends where it started: its end lies at the end of the line, not at its first pass.
if (index === points.length - 1 && end && near(at, { lat: end[0], lng: end[1] }, LOOP_END_M)) {
return Math.round(length);
}
return Math.round(nearestAlongM(line, at.lat, at.lng));
});
const legs = total !== null && line.length >= 2 && points.length >= 2 ? legMinutes(line, atMs, total) : null;
let walked = 0;
return points.map((point, index) => {
const at = latLngOf(point) ?? { lat: 0, lng: 0 };
if (index > 0 && legs) walked += legs[index - 1] ?? 0;
return stopOf({
role: roleAt(index, points.length),
kind: walk ? 'station' : poiCategoryOf(point.poi_category),
name: stopName(point.name ?? point.linked_place_name),
...at,
atM: Math.min(atMs[index] ?? 0, 5_000_000),
minutes: legs ? walked : null,
stayMinutes: minutesOf(point.stay_minutes),
stageEnd: point.stage_end === true,
});
});
}
/** get_tour_plan: a saved tour of the trip as a card, its line the saved one in brief. */
export function savedTourCard({ value }: AnswerInput): TourRouteCard | null {
const plan = recordOf(value);
const tour = recordOf(plan.tour);
const activity = tourTypeKeySchema.safeParse(tour.tour_type);
const placeId = whole(tour.place_id, 1, Number.MAX_SAFE_INTEGER);
if (!activity.success || placeId === null) return null;
const saved = lineOf(recordOf(plan.line).preview);
const waypoints = rowsIn(plan.waypoints);
const planned = minutesOf(tour.planned_duration_minutes);
const minutes = planned ?? minutesOf(tour.duration);
const distance = finite(tour.distance);
const routed = saved.length >= 2;
const line = routed ? saved : straightLine(waypoints.flatMap((point) => latLngOf(point) ?? []));
if (line.length < 2) return null;
const heights = heightRange(line);
const walked = routed ? (minutesOf(tour.duration) ?? minutes) : null;
const stops = savedStops(waypoints, routed ? line : [], walked, activity.data === 'walk');
return fitted(
routeCard({
source: 'saved',
activity: activity.data,
placeId,
name: clipped(tour.name, 120),
distanceM: distance === null ? null : metresOf(distance * 1000),
ascentM: climbOf(tour.elevation_gain),
descentM: climbOf(tour.elevation_loss),
minEleM: heights.min,
maxEleM: heights.max,
minutes,
minutesBasis: minutes === null ? null : 'planned',
limit: sacStepOf(tour.max_hiking_difficulty),
routed,
problem: routed ? null : { why: 'not_planned', position: null, snappedM: null },
line,
stops: sortedStops(stops),
stages: whole(tour.stage_count, 2, 60),
}),
);
}
@@ -0,0 +1,277 @@
import type { AnswerInput } from '../card-types';
import { clockOf, recordOf, type Row } from '../card-values';
import {
climbOf,
coord,
dayNumberOf,
fitted,
latLngOf,
lineOf,
metresOf,
minutesOf,
roleAt,
routeCard,
rowsIn,
sortedStops,
stopName,
stopOf,
straightLine,
whole,
type RoutePoint,
} from './tour-card-values';
import {
haversineMetres,
skiColourSchemeSchema,
skiRunDifficultySchema,
skiSegmentKindSchema,
TOUR_ROUTE_LIMITS,
type TourRouteCard,
type TourRouteStop,
type TourRouteStretch,
} from '@trek/shared';
/**
* The cards of the other ways an assistant plans: a city walk
* (propose_city_walk), a paddle tour's legs (plan_paddle_route) and a ski
* tour's legs (plan_ski_route), each drawn as one route. The legs of a plan
* are joined into one line, each leg's stretches placed along the whole; a
* leg that could not be planned is counted in a warning and leaves a gap.
*/
/** A station flagged by what goes wrong at it, the first conflict the proposal names. */
function flagOf(conflicts: unknown): TourRouteStop['flag'] {
const first = Array.isArray(conflicts) ? conflicts[0] : null;
if (first === 'late' || first === 'after_frame') return first;
return typeof first === 'string' && first.startsWith('close') ? 'closed' : null;
}
/** Why a walk's way was estimated instead of planned, as the card names it; a router that did not answer else. */
const WALK_PROBLEMS: Readonly<Record<string, 'point_off_the_way' | 'ferry_only'>> = {
station_off_the_way: 'point_off_the_way',
ferry_only: 'ferry_only',
};
function walkProblem(estimate: Row): TourRouteCard['problem'] {
const why = (typeof estimate.why === 'string' ? WALK_PROBLEMS[estimate.why] : undefined) ?? 'router_unavailable';
return { why, position: whole(estimate.position, 1, 1_000), snappedM: null };
}
/** propose_city_walk: the stations in order with their times, the routed way when there is one. */
export function cityWalkCard({ value, args, card }: AnswerInput): TourRouteCard | null {
const walk = recordOf(value);
const stations = rowsIn(walk.stations).filter((station) => latLngOf(station) !== null);
if (stations.length < 2) return null;
const totals = recordOf(walk.totals);
const frame = recordOf(walk.frame);
let metres = 0;
let minutes = 0;
const stops = stations.map((station, index) => {
const leg = recordOf(station.walk_from_previous);
metres += metresOf(leg.metres) ?? 0;
minutes += minutesOf(leg.minutes) ?? 0;
return stopOf({
role: roleAt(index, stations.length),
kind: 'station',
name: stopName(station.name),
...(latLngOf(station) ?? { lat: 0, lng: 0 }),
atM: Math.min(metres, 5_000_000),
minutes: Math.min(minutes, 86_400),
clock: clockOf(station.arrival),
stayMinutes: minutesOf(station.stay_minutes),
flag: flagOf(station.conflicts),
});
});
const routedLine = lineOf(walk.line_geometry);
const routed = routedLine.length >= 2;
const date = typeof walk.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(walk.date) ? walk.date : null;
const dayId = whole(args.day_id, 1, Number.MAX_SAFE_INTEGER);
const proposal = routeCard({
source: 'proposal',
activity: 'walk',
distanceM: metresOf(totals.walk_m),
minutes: minutesOf(totals.total_minutes),
minutesBasis: 'stations',
routed,
problem: routed ? null : walkProblem(recordOf(walk.estimate)),
line: routed ? routedLine : straightLine(stops),
stops: sortedStops(stops),
schedule: date
? {
date,
dayId,
dayNumber: dayNumberOf(card, dayId),
start: clockOf(frame.start_time),
arrive: clockOf(totals.ends_at),
nextDay: false,
sunrise: null,
sunset: null,
latestStart: null,
weather: null,
hours: [],
}
: null,
});
return fitted(proposal);
}
/** A planned leg of a plan: its line for drawing, its length, its stretches along it. */
interface PlannedLeg {
line: RoutePoint[];
distanceM: number;
segments: Row[];
leg: Row;
}
/** The legs of a plan in order: the planned ones with their lines, null for each that failed. */
function legsOf(value: unknown): Array<PlannedLeg | null> {
return rowsIn(recordOf(value).legs).map((leg) => {
const line = lineOf(leg.line_geometry);
if (leg.status !== 'ok' || line.length < 2) return null;
return { line, distanceM: metresOf(leg.distance_m) ?? 0, segments: rowsIn(leg.segments), leg };
});
}
/** One leg's stretches placed along the whole route. */
type StretchOf = (segment: Row, fromM: number, toM: number) => TourRouteStretch | null;
/** The metres straight between two points of a line. */
function straightBetween(a: RoutePoint, b: RoutePoint): number {
return Math.round(haversineMetres(a[0], a[1], b[0], b[1]));
}
/**
* The legs joined into one route: one line (heights kept only when every leg
* has them), the stretches each leg names placed along the whole, the ends of
* the legs as stops, and a warning counting the legs that failed. Where legs
* failed between planned ones the line jumps straight from the last planned
* end to the next planned start: that jump is marked as a gap and counted in
* the length, so what follows sits where it lies along the line.
*/
function joined(args: Row, legs: Array<PlannedLeg | null>, stretchOf: StretchOf) {
const asked = rowsIn(args.legs);
const stretches: TourRouteStretch[] = [];
const points: RoutePoint[] = [];
const stops: TourRouteStop[] = [];
let offset = 0;
let failedSince = false;
const lastPlanned = legs.reduce((last, leg, index) => (leg ? index : last), -1);
const first = latLngOf(asked[0]?.from);
if (first) stops.push(stopOf({ role: 'start', ...first, atM: 0 }));
legs.forEach((planned, index) => {
const lastEnd = points.at(-1);
if (planned && lastEnd && failedSince) {
const jump = straightBetween(lastEnd, planned.line[0] ?? lastEnd);
const toM = Math.min(offset + jump, 5_000_000);
stretches.push({ facet: 'mark', fromM: offset, toM, kind: 'gap' });
offset = toM;
}
if (planned) {
failedSince = false;
points.push(...planned.line);
for (const segment of planned.segments) {
const fromM = metresOf(segment.from_m);
const toM = metresOf(segment.to_m);
if (fromM === null || toM === null) continue;
const stretch = stretchOf(segment, Math.min(offset + fromM, 5_000_000), Math.min(offset + toM, 5_000_000));
if (stretch) stretches.push(stretch);
}
offset = Math.min(offset + planned.distanceM, 5_000_000);
} else {
failedSince = true;
}
const end = latLngOf(asked[index]?.to);
// The end of a failed leg between planned ones lies on the straight jump, as far along it as from where it starts.
const pastEnd =
!planned && index < lastPlanned && end && lastEnd ? straightBetween(lastEnd, [end.lat, end.lng, null]) : 0;
const atM = Math.min(offset + pastEnd, 5_000_000);
if (end) stops.push(stopOf({ role: index === legs.length - 1 ? 'end' : 'via', ...end, atM }));
});
const heights = points.every(([, , height]) => height !== null);
const line = lineOf(
points.map(([lat, lng, height]) => [coord(lat), coord(lng), heights ? height : null]),
TOUR_ROUTE_LIMITS.line,
);
const failed = legs.filter((leg) => leg === null).length;
return {
line,
distanceM: offset,
stretches: mergedStretches(stretches),
stops: sortedStops(stops),
warnings: failed > 0 ? [{ kind: 'legs_failed' as const, at: null, count: Math.min(failed, 50) }] : [],
};
}
/** Stretches of one kind that run on into each other joined, at most the card's share. */
function mergedStretches(stretches: readonly TourRouteStretch[]): TourRouteStretch[] {
const merged: TourRouteStretch[] = [];
for (const stretch of stretches) {
const last = merged.at(-1);
if (last && sameKind(last, stretch) && stretch.fromM <= last.toM) last.toM = Math.max(last.toM, stretch.toM);
else merged.push({ ...stretch });
}
return merged.slice(0, TOUR_ROUTE_LIMITS.stretches);
}
function sameKind(a: TourRouteStretch, b: TourRouteStretch): boolean {
if (a.facet === 'ski' && b.facet === 'ski') return a.kind === b.kind && a.difficulty === b.difficulty;
if (a.facet === 'mark' && b.facet === 'mark') return a.kind === b.kind;
return false;
}
const PADDLE_MARKS: Readonly<Record<string, 'portage' | 'rapids' | 'gap'>> = {
portage: 'portage',
rapids: 'rapids',
gap: 'gap',
};
/** plan_paddle_route: the legs on the water as one route, carried and rough stretches marked. */
export function paddleCard({ value, args }: AnswerInput): TourRouteCard | null {
const legs = legsOf(value);
if (!legs.some((leg) => leg !== null)) return null;
const route = joined(args, legs, (segment, fromM, toM) => {
const kind = typeof segment.kind === 'string' ? PADDLE_MARKS[segment.kind] : undefined;
return kind ? { facet: 'mark', fromM, toM, kind } : null;
});
if (route.line.length < 2) return null;
return fitted(routeCard({ source: 'plan', activity: 'kayak', routed: true, ...route }));
}
/** The ways up and down a ski area that are no run: drawn dotted as lifts. */
const LIFTS: ReadonlySet<string> = new Set(['lift', 'lift_down', 'rail']);
/** plan_ski_route: the legs on runs, trails and lifts as one route, with their figures and the area's colours. */
export function skiCard({ value, args }: AnswerInput): TourRouteCard | null {
const plan = recordOf(value);
const legs = legsOf(plan);
const planned = legs.filter((leg): leg is PlannedLeg => leg !== null);
if (planned.length === 0) return null;
const route = joined(args, legs, (segment, fromM, toM) => {
if (typeof segment.kind === 'string' && LIFTS.has(segment.kind)) return { facet: 'mark', fromM, toM, kind: 'lift' };
const kind = skiSegmentKindSchema.safeParse(segment.kind);
if (!kind.success) return null;
const difficulty = skiRunDifficultySchema.safeParse(segment.difficulty);
return { facet: 'ski', fromM, toM, kind: kind.data, difficulty: difficulty.success ? difficulty.data : null };
});
if (route.line.length < 2) return null;
const sum = (read: (leg: Row) => unknown) => planned.reduce((total, { leg }) => total + (Number(read(leg)) || 0), 0);
const seconds = sum((leg) => {
const parts = recordOf(leg.seconds);
return (Number(parts.ski) || 0) + (Number(parts.lift) || 0) + (Number(parts.wait) || 0) + (Number(parts.walk) || 0);
});
const heights = route.line.map(([, , height]) => height).filter((height): height is number => height !== null);
const scheme = skiColourSchemeSchema.safeParse(plan.colour_scheme);
const ski = routeCard({
source: 'plan',
activity: 'ski',
ascentM: climbOf(sum((leg) => leg.climb_m)),
descentM: climbOf(sum((leg) => leg.descent_m)),
minEleM: heights.length === route.line.length ? Math.min(...heights) : null,
maxEleM: heights.length === route.line.length ? Math.max(...heights) : null,
minutes: minutesOf(seconds / 60),
minutesBasis: 'piste',
routed: true,
skiScheme: scheme.success ? scheme.data : null,
...route,
});
return fitted(ski);
}
@@ -1,37 +1,58 @@
import type { ToolCards, UndoSnapshot } from '../card-types';
import { recordOf } from '../card-values';
import { recordOf, type Row } from '../card-values';
import { byId, placeUnchanged, snapshotPlaces } from '../undo-snapshot';
/** The place id of a tour a create answered with (`{ tour: { place_id } }`). */
function tourIdIn(value: unknown): number | null {
const id = recordOf(recordOf(value).tour).place_id;
/** The place id of a tour, or null. */
function placeIdOf(tour: Row): number | null {
const id = tour.place_id;
return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null;
}
/**
* A created tour, unchanged and on no day: its place still has the name and
* description it was created with, has not been written since it was made,
* and nothing plans, books or pays for it. Every write of a tour's route or
* name (a change over MCP, the planner's save, a rename) stamps its place's
* `updated_at`, which the create left equal to `created_at`; the revision is
* not in the snapshot. A tour planned on a day since is kept too.
* A created tour, unchanged and on no other day: its place still has the
* name and description it was created with, has not been written since it
* was made, and nothing plans, books or pays for it. Every write of a tour's
* route or name (a change over MCP, the planner's save, a rename) stamps its
* place's `updated_at`, which the create left equal to `created_at`; the
* revision is not in the snapshot. The visit a hike created on a day comes
* with it (`assignment`); planned on another day since, the tour is kept.
*/
function tourUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
const tour = recordOf(recordOf(result).tour);
function tourUnchanged(tour: Row, assignment: unknown, snapshot: UndoSnapshot): boolean {
const current = byId(snapshotPlaces(snapshot), tour.place_id);
const untouched = typeof current?.created_at === 'string' && current.updated_at === current.created_at;
return (
untouched &&
placeUnchanged({ place: { id: tour.place_id, name: tour.name, description: tour.description ?? null } }, snapshot)
);
const created = { id: tour.place_id, name: tour.name, description: tour.description ?? null };
return untouched && placeUnchanged({ place: created, assignment: recordOf(assignment) }, snapshot);
}
/** A created tour taken back with its place, which takes its route and points along. */
/** A created tour taken back with its place, which takes its route, its points and its visit along. */
export const DELETE_CREATED_TOUR: NonNullable<ToolCards['undo']> = {
tool: 'delete_place',
args: (_args, result) => {
const placeId = tourIdIn(result);
const placeId = placeIdOf(recordOf(recordOf(result).tour));
return placeId ? { placeId } : null;
},
unchanged: tourUnchanged,
unchanged: (result, snapshot) => {
const answer = recordOf(result);
return tourUnchanged(recordOf(answer.tour), answer.assignment, snapshot);
},
};
/** The one tour a GPX file brought in, when it brought in exactly one: a file of several has no single way back. */
function onlyImported(result: unknown): Row | null {
const tours = recordOf(result).tours;
return Array.isArray(tours) && tours.length === 1 ? recordOf(tours[0]) : null;
}
/** A tour taken in from a GPX file of one track, taken back with its place while it is as it came in. */
export const DELETE_IMPORTED_TOUR: NonNullable<ToolCards['undo']> = {
tool: 'delete_place',
args: (_args, result) => {
const tour = onlyImported(result);
const placeId = tour ? placeIdOf(tour) : null;
return placeId ? { placeId } : null;
},
unchanged: (result, snapshot) => {
const tour = onlyImported(result);
return tour !== null && tourUnchanged(tour, null, snapshot);
},
};
@@ -0,0 +1,322 @@
/**
* Trip assistant e2e for the tours: the whole app (buildApp, every global
* guard, the Zod pipe, the response contracts, the real MCP registry with
* its addon gates) with an instance connection to a fake OpenAI-compatible
* server on loopback, scripted per case. The hike planner itself is stubbed
* on the container's HikePlanService (its routing has suites of its own,
* tours-hike.e2e): the proposal it answers, and the hike it creates on a
* real place of the trip.
*
* Covered: a proposed hike streams and stores its route card, its line for
* drawing asked for by the server and kept from the model; "Plan this" holds
* the hike as a change card from the stored proposal, refused for an unknown
* proposal and while it waits; applying it runs create_hike without a model
* turn and names the tour in the receipt and on its card.
*/
import { buildApp } from '../../src/bootstrap';
import { HikePlanService } from '../../src/nest/tours/hike-plan.service';
import { makePlace } from '../helpers/factories/places';
import { setAddonEnabled } from '../helpers/factories/settings';
import { makeTrip } from '../helpers/factories/trips';
import { makeUser } from '../helpers/factories/users';
import { resetRateLimits } from '../helpers/test-db';
import { sessionCookie } from './harness';
import { MikroORM } from '@mikro-orm/core';
import type { INestApplication } from '@nestjs/common';
import type { AssistantMessagePart, HikeProposal, HikeProposeRequest, UiStreamChunk } from '@trek/shared';
import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
// ── The fake provider ─────────────────────────────────────────────────────────
interface ChatBody {
messages: Array<{ role: string; content: string | null }>;
tools?: Array<{ function: { name: string; parameters: { properties?: Record<string, unknown> } } }>;
}
type Step = () => { chunks: object[] };
const provider = { requests: [] as ChatBody[], script: [] as Step[] };
const say =
(text: string): Step =>
() => ({
chunks: [
{ choices: [{ index: 0, delta: { content: text } }] },
{ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] },
{ choices: [], usage: { prompt_tokens: 12, completion_tokens: 4 } },
],
});
const callTool =
(id: string, name: string, args: Record<string, unknown>): Step =>
() => ({
chunks: [
{
choices: [
{
index: 0,
delta: {
tool_calls: [{ index: 0, id, type: 'function', function: { name, arguments: JSON.stringify(args) } }],
},
},
],
},
{ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] },
],
});
async function readBody(req: IncomingMessage): Promise<string> {
let text = '';
for await (const chunk of req) text += String(chunk);
return text;
}
async function answer(req: IncomingMessage, res: ServerResponse): Promise<void> {
if (req.method === 'GET' && req.url?.startsWith('/api/tags')) {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ models: [{ name: 'fake-model', size: 1 }] }));
return;
}
if (req.method !== 'POST' || req.url !== '/v1/chat/completions') {
res.writeHead(404).end();
return;
}
provider.requests.push(JSON.parse(await readBody(req)) as ChatBody);
const reply = (provider.script.shift() ?? say('Nothing more.'))();
res.writeHead(200, { 'content-type': 'text/event-stream' });
for (const chunk of reply.chunks) res.write(`data: ${JSON.stringify(chunk)}\n\n`);
res.end('data: [DONE]\n\n');
}
// ── The app ──────────────────────────────────────────────────────────────────
let nestApp: INestApplication;
let orm: MikroORM;
let fake: HttpServer;
beforeAll(async () => {
fake = createServer((req, res) => {
answer(req, res).catch(() => res.destroy());
});
await new Promise<void>((resolve) => fake.listen(0, '127.0.0.1', resolve));
nestApp = await buildApp();
orm = nestApp.get(MikroORM);
await setAddonEnabled(orm, 'ai_assistant', true);
await setAddonEnabled(orm, 'tours', true);
const admin = (await makeUser(orm, { role: 'admin' })).user.id;
const { port } = fake.address() as AddressInfo;
const res = await request(nestApp.getHttpServer())
.put('/api/admin/ai/connection')
.set('Cookie', sessionCookie(admin))
.send({ kind: 'custom', baseUrl: `http://127.0.0.1:${port}/v1`, model: 'fake-model' });
expect(res.status).toBe(200);
});
beforeEach(async () => {
await resetRateLimits(nestApp);
provider.requests = [];
provider.script = [];
vi.restoreAllMocks();
});
afterAll(async () => {
await nestApp.close();
await new Promise<void>((resolve) => fake.close(() => resolve()));
});
// ── Helpers ──────────────────────────────────────────────────────────────────
const CTX = { locale: 'en', timeZone: 'Europe/Berlin' };
interface Answer {
status: number;
chunks: UiStreamChunk[];
json: Record<string, unknown> | null;
}
async function post(path: string, userId: number, body: object): Promise<Answer> {
const res = await request(nestApp.getHttpServer())
.post(path)
.set('Cookie', sessionCookie(userId))
.send(body)
.buffer(true)
.parse((stream, done) => {
let text = '';
stream.setEncoding('utf8');
stream.on('data', (chunk: string) => (text += chunk));
stream.on('end', () => done(null, text));
});
const text = res.body as string;
if (!String(res.headers['content-type']).includes('text/event-stream')) {
return { status: res.status, chunks: [], json: text ? (JSON.parse(text) as Record<string, unknown>) : null };
}
const events = text
.split('\n\n')
.filter((event) => event.startsWith('data: '))
.map((event) => event.slice('data: '.length));
expect(events.at(-1)).toBe('[DONE]');
const chunks = events.slice(0, -1).map((event) => JSON.parse(event) as UiStreamChunk);
return { status: res.status, chunks, json: null };
}
const ofType = <T extends UiStreamChunk['type']>(chunks: UiStreamChunk[], type: T) =>
chunks.filter((chunk): chunk is Extract<UiStreamChunk, { type: T }> => chunk.type === type);
const WAYPOINTS = [
{ lat: 47.6, lng: 11.3, name: 'Car park' },
{ lat: 47.61, lng: 11.3, name: 'Summit', poi_category: 'peak' as const },
];
const LINE: HikeProposal['line_geometry'] = [
[47.6, 11.3, 800],
[47.61, 11.3, 880],
];
const PROFILE: HikeProposal['profile_geometry'] = [
[0, 800],
[1112, 880],
];
/** What the planner answers for the hike: a routed way of 1.1 km, its line only when asked for. */
function proposalFor(asked: HikeProposeRequest): HikeProposal {
return {
name: asked.name,
variant_label: null,
route: { planned: true },
unreachable: null,
distance_m: 1112,
ascent_m: 80,
descent_m: 0,
duration_minutes: 22,
duration_basis: 'din33466',
heights_known: true,
min_ele_m: 800,
max_ele_m: 880,
...(asked.include_geometry ? { line_geometry: LINE, profile_geometry: PROFILE } : {}),
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 },
],
along: null,
max_hiking_difficulty: 2,
hiking_preferences: null,
round_trip: false,
day_id: null,
date: null,
start_time: null,
schedule: null,
warnings: [],
};
}
/** A traveller with a trip, the tour place create_hike will answer with, and the planner stubbed. */
async function travellerWithPlanner() {
const { user } = await makeUser(orm);
const trip = await makeTrip(orm, user.id, { title: 'Bavaria' });
const place = await makePlace(orm, trip.id, { name: 'Herzogstand', category: null });
const hikes = nestApp.get(HikePlanService);
const propose = vi.spyOn(hikes, 'propose').mockImplementation(async (_u, _t, req) => proposalFor(req));
const create = vi.spyOn(hikes, 'create').mockResolvedValue({
tour: { place_id: place.id, name: 'Herzogstand' },
waypoints: [],
route: { planned: true },
assignment: null,
} as unknown as Awaited<ReturnType<HikePlanService['create']>>);
const base = `/api/trips/${trip.id}/assistant`;
return { userId: user.id, tripId: trip.id, placeId: place.id, base, propose, create };
}
/** A turn that loads the tours and proposes the hike. */
async function proposeTurn(t: { userId: number; base: string }) {
provider.script = [
callTool('call_tours', 'use_addon_tools', { groups: ['tours'] }),
callTool('call_hike', 'propose_hike', { name: 'Herzogstand', waypoints: WAYPOINTS }),
say('A short hike up the Herzogstand.'),
];
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'A hike up the Herzogstand?' });
return { turn, threadId: ofType(turn.chunks, 'data-trek-thread')[0].data.threadId };
}
// ── Cases ────────────────────────────────────────────────────────────────────
describe('a proposed hike in the chat', () => {
it('ASTE2E-TOUR-001: streams and stores its route card; the line is asked for by the server and kept from the model', async () => {
const t = await travellerWithPlanner();
const { turn, threadId } = await proposeTurn(t);
expect(t.propose).toHaveBeenCalledWith(
t.userId,
t.tripId,
expect.objectContaining({ name: 'Herzogstand', include_geometry: true }),
);
const [card] = ofType(turn.chunks, 'data-trek-card');
expect(card?.data).toMatchObject({
toolCallId: 'call_hike',
card: { kind: 'tourRoute', source: 'proposal', name: 'Herzogstand', routed: true, distanceM: 1112 },
});
// The model never sees the flag nor the line: neither in the tool it is offered nor in the answer it reads.
const last = provider.requests.at(-1);
const offered = last?.tools?.find((tool) => tool.function.name === 'propose_hike');
expect(offered).toBeDefined();
expect(Object.keys(offered?.function.parameters.properties ?? {})).not.toContain('include_geometry');
expect(JSON.stringify(last?.messages)).not.toContain('line_geometry');
const shown = await request(nestApp.getHttpServer())
.get(`${t.base}/threads/${threadId}`)
.set('Cookie', sessionCookie(t.userId));
expect(shown.status).toBe(200);
const parts = (shown.body.messages as Array<{ parts: AssistantMessagePart[] }>).flatMap((m) => m.parts);
expect(parts).toContainEqual(
expect.objectContaining({
type: 'data-trek-card',
data: expect.objectContaining({ toolCallId: 'call_hike' }),
}),
);
});
it('ASTE2E-TOUR-002: "Plan this" holds the hike from the stored proposal; applying it creates the tour without a model turn', async () => {
const t = await travellerWithPlanner();
const { threadId } = await proposeTurn(t);
const path = `${t.base}/threads/${threadId}/proposals`;
const unknown = await post(path, t.userId, { ...CTX, toolCallId: 'call_nope' });
expect(unknown).toMatchObject({ status: 404, json: { code: 'not_found' } });
const taken = await post(path, t.userId, { ...CTX, toolCallId: 'call_hike' });
expect(taken.status).toBe(200);
const parts = (taken.json?.message as { parts: AssistantMessagePart[] }).parts;
const change = parts.find((part) => part.type === 'data-trek-change');
expect(change?.type === 'data-trek-change' && change.data).toMatchObject({
verbKey: 'addTour',
subject: 'Herzogstand',
card: { kind: 'tourRoute', source: 'pending', routed: true },
});
expect(parts).toContainEqual(expect.objectContaining({ type: 'tool-call', toolName: 'take_proposal' }));
const twice = await post(path, t.userId, { ...CTX, toolCallId: 'call_hike' });
expect(twice).toMatchObject({ status: 409, json: { code: 'decide_first' } });
const toolCallId = change?.type === 'data-trek-change' ? change.data.toolCallId : '';
const asked = provider.requests.length;
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
...CTX,
decisions: [{ toolCallId, approved: true }],
});
expect(decided.status).toBe(200);
expect(provider.requests).toHaveLength(asked);
expect(t.create).toHaveBeenCalledWith(
t.userId,
t.tripId,
expect.objectContaining({ name: 'Herzogstand', waypoints: WAYPOINTS }),
);
expect(t.create.mock.calls[0]?.[2]).not.toHaveProperty('via');
const [applied] = ofType(decided.chunks, 'data-trek-applied');
expect(applied?.data).toMatchObject({ toolCallId, entity: 'tour', ids: [t.placeId] });
// The card the change showed stays beside the receipt, now naming the tour it created.
const [receipt] = ofType(decided.chunks, 'data-trek-card');
expect(receipt?.data).toMatchObject({ toolCallId, card: { kind: 'tourRoute', placeId: t.placeId } });
});
});
@@ -0,0 +1,95 @@
import type { TourListCard, TourRouteCard } from '@trek/shared';
/**
* Tour cards as the tours package builds them, valid against the card union,
* for suites that hand a card through the stream, a stored part or a change
* preview and need one the strict write accepts.
*/
/** A short routed hike with every field the kind declares. */
export function tourRouteCard(extra: Partial<TourRouteCard> = {}): TourRouteCard {
return {
kind: 'tourRoute',
source: 'proposal',
activity: 'hike',
placeId: null,
name: 'Ridge walk',
label: null,
distanceM: 1112,
ascentM: 80,
descentM: 0,
minEleM: 800,
maxEleM: 880,
minutes: 22,
minutesBasis: 'din33466',
limit: 2,
grades: null,
routed: true,
problem: null,
line: [
[47.6, 11.3, 800],
[47.605, 11.3, 840],
[47.61, 11.3, 880],
],
stretches: [],
stops: [
{
role: 'start',
kind: null,
name: 'Car park',
lat: 47.6,
lng: 11.3,
atM: 0,
offM: null,
minutes: 0,
clock: null,
stayMinutes: null,
stageEnd: false,
flag: null,
},
{
role: 'end',
kind: 'peak',
name: 'Summit',
lat: 47.61,
lng: 11.3,
atM: 1112,
offM: null,
minutes: 22,
clock: null,
stayMinutes: null,
stageEnd: false,
flag: null,
},
],
stages: null,
skiScheme: null,
schedule: null,
warnings: [],
previous: null,
...extra,
};
}
/** One saved tour of a trip as a list. */
export function tourListCard(extra: Partial<TourListCard> = {}): TourListCard {
return {
kind: 'tourList',
dayId: null,
more: 0,
rows: [
{
placeId: 77,
name: 'Ridge walk',
activity: 'hike',
limit: 2,
distanceM: 1112,
ascentM: 80,
minutes: 22,
stages: null,
days: [2],
},
],
...extra,
};
}
@@ -125,6 +125,32 @@ describe('foundPlacesOf', () => {
expect(JSON.stringify(poi)).not.toMatch(/ramen\.example|opening/);
});
it('ASTCRD-008: the places along a saved tour show as cards; an unnamed tap is left out', () => {
const along = {
pois: [
{ id: 'node:7', name: 'Herzogstandhaus', lat: 47.6, lng: 11.3, category: 'shelter', kind: 'alpine_hut' },
{ id: 'node:8', name: '', lat: 47.61, lng: 11.3, category: 'drinking_water', kind: 'amenity=drinking_water' },
],
truncated: false,
partial: false,
capped: false,
unavailable: [],
};
const found = foundPlacesOf('search_tour_pois', 't', along, card, '2026-06-12');
expect(found?.places.map((place) => place.name)).toEqual(['Herzogstandhaus']);
expect(found?.places[0]).toMatchObject({ ref: 't:0', lat: 47.6, lng: 11.3, googlePlaceId: null });
// The POI's own id is its OpenStreetMap element: a place already in the trip is named, not added twice.
expect(found?.places[0]).toMatchObject({ osmId: 'node:7', inTripPlaceId: null });
const known = { pois: [{ id: 'way:77', name: 'Nishiki', lat: 35.005, lng: 135.764, category: 'shop' }] };
expect(foundPlacesOf('search_tour_pois', 'k', known, card, 'x')?.places[0]).toMatchObject({
osmId: 'way:77',
inTripPlaceId: 14,
});
// An id of another index is no OpenStreetMap id.
const indexed = { pois: [{ id: 'trek:55', name: 'Hut', lat: 47.6, lng: 11.3, category: 'shelter' }] };
expect(foundPlacesOf('search_tour_pois', 'i', indexed, card, 'x')?.places[0]?.osmId).toBeNull();
});
it('ASTCRD-004: at most twenty cards, the rest counted; places without a position or name are left out', () => {
const many = Array.from({ length: 23 }, (_, i) => ({ name: `Place ${i}`, lat: 35 + i / 100, lng: 135 }));
const found = foundPlacesOf('search_place', 'm', { places: [{ name: 'No position' }, ...many] }, card, 'x');
@@ -360,6 +386,15 @@ describe('found place arguments', () => {
expect(writeToolOf('add_found_place', { via: 'create_and_assign_place' })).toBe('create_and_assign_place');
expect(writeToolOf('add_found_place', { via: 'delete_trip' })).toBe('add_found_place');
expect(writeToolOf('create_place', { via: 'assign_place_to_day' })).toBe('create_place');
// A proposal taken from an answer runs as the create its `via` names, and as nothing else.
expect(writeToolOf('take_proposal', { via: 'create_hike' })).toBe('create_hike');
expect(writeToolOf('take_proposal', { via: 'create_city_walk' })).toBe('create_city_walk');
expect(writeToolOf('take_proposal', { via: 'create_and_assign_place' })).toBe('take_proposal');
expect(writeToolOf('add_found_place', { via: 'create_hike' })).toBe('add_found_place');
expect(foundPlaceArgs({ via: 'create_hike', name: 'Herzogstand', waypoints: [] })).toEqual({
name: 'Herzogstand',
waypoints: [],
});
expect(foundPlaceArgs({ via: 'assign_place_to_day', dayId: 3, placeId: 4, name: 'X' })).toEqual({
dayId: 3,
placeId: 4,
@@ -250,6 +250,31 @@ describe('toChatHistory', () => {
},
]);
});
it('ASTHIS-013: a proposal the user took to add is told as a note, a hike or a city walk, whatever became of it', () => {
const taken = (approval: AssistantToolApproval, via = 'create_hike', isError = false) =>
serverStepsNote([
{
type: 'tool-call',
toolCallId: 'p',
toolName: 'take_proposal',
args: { via, name: 'Herzogstand' },
approval,
...(isError ? { isError: true } : {}),
},
]);
expect(taken({ id: 'p' })).toBe(
'The user took the proposed hike "Herzogstand" to add it; it waits for them to add or discard it.',
);
expect(taken({ id: 'p', approved: true })).toBe('The hike "Herzogstand" was added to the trip.');
expect(taken({ id: 'p', approved: true }, 'create_hike', true)).toBe('The hike "Herzogstand" could not be added.');
expect(taken({ id: 'p', approved: false }, 'create_city_walk')).toBe(
'The user discarded the city walk "Herzogstand".',
);
expect(taken({ id: 'p', resolution: 'expired' })).toBe(
'The hike "Herzogstand" was not added; the user moved on without deciding.',
);
});
});
describe('toThreadMessages', () => {

Some files were not shown because too many files have changed in this diff Show More