mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
feat(assistant): hike, walk and route cards with their lines on the map, and "Plan this"
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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()) : []
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user