feat(tours): assistant takes found places into saved tours and route files from the chat

P6: insert_tour_stop and remove_tour_stop take a place into a saved hike or
city walk as one stop and out again, each the other's undo, with change
cards in the chat. The planner's insertion rule (poiInsertion) moved to
shared so the planner and the server place a stop the same way. "+ Tour" on
a found place holds it as a stop of the saved tour open in the planner.

P7: GPX, KML and KMZ files attached in the chat are read into their tracks
and each is held as a change that takes it in as a hike through
import_tour_gpx, undoable per track; the GPX text stays on the held call.
This commit is contained in:
Maurice
2026-10-23 21:18:19 +02:00
parent 454fc1a5f4
commit cd60d4d859
92 changed files with 3257 additions and 111 deletions
+8
View File
@@ -18,6 +18,7 @@ import {
type AssistantChatRequest,
type AssistantDecisionsRequest,
type AssistantErrorCode,
type AssistantInsertFoundPlaceRequest,
type AssistantRenderUploadResponse,
type AssistantScope,
type AssistantShareRequest,
@@ -143,6 +144,13 @@ export const assistantApi = {
body: AssistantAddFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places`, body).then((r) => r.data),
// "+ Tour": a found place taken into the saved tour open in the planner, held as a change like "+ Day 3".
insertFoundPlace: (
tripId: number,
threadId: number,
body: AssistantInsertFoundPlaceRequest
): Promise<AssistantAddFoundPlaceResponse> =>
apiClient.post(`${tripBase(tripId)}/threads/${threadId}/found-places/tour`, body).then((r) => r.data),
// A proposal an answer showed as a card ("Plan this"): answers like a found place, with the message holding it.
takeProposal: (
tripId: number,
@@ -1,5 +1,5 @@
import type { AssistantFoundPlace } from '@trek/shared';
import { Check, MapPin, Plus, Star } from 'lucide-react';
import { Check, MapPin, Plus, Route, Star } from 'lucide-react';
import { createElement, type ReactNode, useRef } from 'react';
import { useTranslation } from '../../i18n';
@@ -25,6 +25,24 @@ function AddButton({ place, strip, tall }: { place: AssistantFoundPlace; strip:
);
}
/** "+ Tour": takes the place into the saved tour open in the planner as a stop, held as a change in the chat. */
function TourButton({ place, strip, tall }: { place: AssistantFoundPlace; strip: Strip; tall: boolean }) {
const { t } = useTranslation();
if (!strip.tour) return null;
return (
<Tooltip label={t('assistant.place.addToTourHint', { name: strip.tour.name })} placement="top">
<button
type="button"
onClick={() => strip.addToTour(place)}
className={`${ACTION} ${tall ? 'h-10' : 'h-7'} min-w-0 px-2`}
>
<Route size={12} aria-hidden="true" className="flex-shrink-0" />
<span className="truncate">{t('assistant.place.addToTour')}</span>
</button>
</Tooltip>
);
}
type Category = ReturnType<Strip['categoryOf']>;
/** The picture, or the category's icon on its colour, with the category and whether it is open on it. */
@@ -185,8 +203,9 @@ export default function AssistantPlaceCard({
)}
</span>
</button>
<div className="mt-auto flex min-w-0 px-3 pb-3 pt-2.5">
<div className="mt-auto flex min-w-0 gap-1.5 px-3 pb-3 pt-2.5">
<PlaceAction place={place} strip={strip} suggested={suggested} tall={tall} />
<TourButton place={place} strip={strip} tall={tall} />
</div>
</li>
);
@@ -21,6 +21,7 @@ import { fetchPhoto } from '../../services/photoService';
import { assistantChatKey } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useAuthStore } from '../../store/authStore';
import { setOpenTour } from '../../store/openTourStore';
import { useTripStore } from '../../store/tripStore';
import { setForcedOffline } from '../../sync/networkMode';
import AssistantPlaceStrip from './AssistantPlaceStrip';
@@ -105,6 +106,7 @@ afterEach(() => {
useAuthStore.setState(AUTH, true);
globalThis.IntersectionObserver = RealObserver;
setForcedOffline(false);
setOpenTour(null);
vi.clearAllMocks();
});
@@ -238,3 +240,22 @@ describe('AssistantPlaceStrip', () => {
expect(screen.queryByRole('button', { name: 'Add to trip' })).toBeNull();
});
});
describe('"+ Tour" on a found place (P6)', () => {
it('FE-AI-PLACES-011: shows only while a saved tour of this trip is open, and holds the place as its stop', () => {
seed();
const insertFoundPlace = vi.fn(async () => 'held' as const);
useAssistantStore.setState({ insertFoundPlace });
setOpenTour({ tripId: TRIP, placeId: 64, name: 'Herzogstand' });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
fireEvent.click(screen.getByRole('button', { name: 'Add to tour' }));
expect(insertFoundPlace).toHaveBeenCalledWith(TRIP, 's1:0', 64);
});
it('FE-AI-PLACES-012: a tour open on another trip, or a traveller who may not edit, gets no "+ Tour"', () => {
seed();
setOpenTour({ tripId: TRIP + 1, placeId: 64, name: 'Elsewhere' });
renderStrip({ toolCallId: 's1', places: [place('s1:0')], more: 0 });
expect(screen.queryByRole('button', { name: 'Add to tour' })).toBeNull();
});
});
@@ -107,6 +107,7 @@ const ERRORS: Record<AssistantErrorCode, AssistantErrorView> = {
decide_first: { messageKey: 'assistant.error.decideFirst', action: null },
too_many_requests: { messageKey: 'assistant.error.rateLimited', action: null },
no_bookings_found: { messageKey: 'assistant.error.noBookingsFound', action: null },
no_tracks_found: { messageKey: 'assistant.error.noTracksFound', action: null },
import_unavailable: { messageKey: 'assistant.error.importUnavailable', action: null },
generic: {
messageKey: 'assistant.error.generic',
@@ -9,6 +9,8 @@ import {
Footprints,
History,
Luggage,
MapPinMinus,
MapPinPlus,
Palette,
Repeat,
Route,
@@ -119,6 +121,8 @@ export const TOUR_LOOK = {
setPaddlePlan: { Icon: Waves, tone: 'info', labelKey: 'assistant.change.verb.setPaddlePlan' },
decideObstacles: { Icon: TriangleAlert, tone: 'info', labelKey: 'assistant.change.verb.decideObstacles' },
restoreTrack: { Icon: History, tone: 'info', labelKey: 'assistant.change.verb.restoreTrack' },
addTourStop: { Icon: MapPinPlus, tone: 'success', labelKey: 'assistant.change.verb.addTourStop' },
removeTourStop: { Icon: MapPinMinus, tone: 'danger', labelKey: 'assistant.change.verb.removeTourStop' },
},
tools: {},
steps: {
@@ -137,6 +141,8 @@ export const TOUR_LOOK = {
search_wanderer_trails: { stepKey: 'assistant.step.searchingWanderer' },
preview_wanderer_trail: { stepKey: 'assistant.step.readingTrail' },
check_tour_source: { stepKey: 'assistant.step.checkingSource' },
// P7: a route file attached in the chat, read by the server into its tracks.
read_tour_file: { stepKey: 'assistant.step.readingTourFile' },
},
entities: {
tour: {
@@ -1,4 +1,4 @@
import { LLM_PHOTO_EXTENSIONS } from '@trek/shared';
import { ASSISTANT_TOUR_FILE_EXTENSIONS, LLM_PHOTO_EXTENSIONS } from '@trek/shared';
import { type DragEvent, useState } from 'react';
import { useTranslation } from '../../i18n';
@@ -6,7 +6,7 @@ import { assistantScopeOf } from '../../store/assistantChatKey';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useToast } from '../shared/Toast';
/** What the booking import reads (the server checks again), and how big one file may be. */
/** What the booking import reads and the route files taken in as tours (the server checks again), and how big one file may be. */
const ATTACH_EXTENSIONS: readonly string[] = [
'.pdf',
'.eml',
@@ -16,6 +16,7 @@ const ATTACH_EXTENSIONS: readonly string[] = [
'.htm',
'.txt',
...LLM_PHOTO_EXTENSIONS,
...ASSISTANT_TOUR_FILE_EXTENSIONS,
];
const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
@@ -29,7 +30,7 @@ function hasFiles(e: DragEvent): boolean {
}
/**
* Hands a booking file to the chat, from the paperclip, a paste or a drop on
* Hands a booking file or a route file (GPX, KML, KMZ) to the chat, from the paperclip, a paste or a drop on
* the window. One file at a time, refused here with a toast when it is not
* one the import reads or too big, so a wrong file never costs a round trip.
* The drop handlers go on the whole chat; `dragging` lights its drop zone.
@@ -3,6 +3,7 @@ import { useCallback } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { chatOf, useAssistantStore } from '../../store/assistantStore';
import { useOpenTourStore } from '../../store/openTourStore';
import { useCanDo } from '../../store/permissionsStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useTripStore } from '../../store/tripStore';
@@ -41,6 +42,10 @@ function lastSpot(stops: readonly Assignment[] | undefined): { lat: number; lng:
* Adding opens the planner's own place form, filled in with the place and on
* the day in focus: the traveller sees everything about it and saves it as
* any other place, so nothing waits for the chat's open decisions.
*
* P6: while a saved hike or city walk is open in the planner, "+ Tour" takes
* a place into it as a stop: held as a change in the chat like any other,
* so the stop is only saved once the traveller applies it.
*/
export function useAssistantPlaceStrip() {
const { tripId, onRevealMap } = useAssistantView();
@@ -60,6 +65,8 @@ export function useAssistantPlaceStrip() {
);
const focusDayId = useAssistantStore((s) => chatOf(s, tripId).focus?.dayId ?? null);
const openPlaceDraft = useAssistantStore((s) => s.openPlaceDraft);
const insertFoundPlace = useAssistantStore((s) => s.insertFoundPlace);
const openTour = useOpenTourStore((s) => (s.open?.tripId === tripId ? s.open : null));
const point = useCardPoint();
const focusDay = (days ?? []).find((d) => d.id === focusDayId) ?? null;
@@ -85,6 +92,14 @@ export function useAssistantPlaceStrip() {
openPlaceDraft(trip.id, place, focusDay?.id ?? null);
};
/** The saved tour a place can be taken into as a stop, null while none is open or nothing can be added. */
const tour = canAdd ? openTour : null;
/** Holds the place as a stop of the open tour, to decide in the chat. */
const addToTour = (place: AssistantFoundPlace) => {
if (tour) void insertFoundPlace(tripId, place.ref, tour.placeId);
};
/** Shows the place on the map; on the phone the chat steps aside first. */
const show = point.show;
@@ -96,5 +111,5 @@ export function useAssistantPlaceStrip() {
[hoverPoint]
);
return { canAdd, categoryOf, distanceOf, open, show, hover };
return { canAdd, categoryOf, distanceOf, open, show, hover, tour, addToTour };
}
@@ -41,18 +41,6 @@ function stationSplit<T extends RoutePoint>(point: T & StationParts): { station:
return { station, rest: rest as T };
}
/**
* A new point at the end of the tour; a loop keeps returning to its start,
* so there the point goes in before the way back.
*/
export function withPointAppended<T extends { lat: number; lng: number; role: string }>(
points: readonly T[],
point: T
): T[] {
if (!isClosedLoop(points)) return [...points, { ...point, role: 'end' }];
return [...points.slice(0, -1), { ...point, role: 'via' }, points[points.length - 1]];
}
/**
* The tour taken back from its last point to its start: one end point on the
* start's spot, not a second copy of the start among the stops. `newId` names
@@ -1,4 +1,5 @@
import { act, fireEvent, render, renderHook, screen } from '@testing-library/react';
import { withPointAppended } from '@trek/shared';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildCategory, buildPlace } from '../../../../tests/helpers/factories';
import { resetAllStores, seedStore } from '../../../../tests/helpers/store';
@@ -10,7 +11,6 @@ import {
unlinkedPoint,
type TourPlannerWaypoint,
} from './tourPlannerWaypoints';
import { withPointAppended } from './tourRouteActions';
import { TripPlacePicker } from './TripPlacePicker';
import type { TourPlannerController } from './useTourPlanner';
import { useTripPlaceWaypoints } from './useTripPlaceWaypoints';
@@ -1,11 +1,10 @@
import { nearnessToLine } from '@trek/shared';
import { nearnessToLine, withPointAppended } from '@trek/shared';
import { useCallback, useMemo } from 'react';
import { useTripStore } from '../../../store/tripStore';
import type { Category, Place } from '../../../types';
import { randomId } from '../../../utils/randomId';
import { useLinkedPlaceOf } from '../walk/useWalkSchedule';
import { unlinkedPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints';
import { withPointAppended } from './tourRouteActions';
import type { TourPlannerController } from './useTourPlanner';
/** A place of the trip as the picker offers it. */
@@ -1,9 +1,9 @@
import { isClosedLoop, type PlannableTourType, type TourFixedLeg } from '@trek/shared';
import { isClosedLoop, withPointAppended, type PlannableTourType, type TourFixedLeg } from '@trek/shared';
import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react';
import { randomId } from '../../../utils/randomId';
import { holdsHorseSection } from '../horse/horseLegs';
import { unlinkedPoint, type TourPlannerWaypoint } from './tourPlannerWaypoints';
import { closeLoop, reverseRoute, toggleFreeLeg, withPointAppended } from './tourRouteActions';
import { closeLoop, reverseRoute, toggleFreeLeg } from './tourRouteActions';
type WaypointChange = TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[]);
@@ -1,5 +1,5 @@
import type { TourPoi, WalkTheme } from '@trek/shared';
import { TOUR_POI_CORRIDORS_M } from '@trek/shared';
import { placesInTour, TOUR_POI_CORRIDORS_M } from '@trek/shared';
import {
Check,
ChevronDown,
@@ -30,7 +30,6 @@ import { usePopoverList } from '../../shared/usePopoverList';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { RailCard, TourNotice } from '../tourParts';
import { formatPlannedTourDuration } from '../tourPresentation';
import { placesInTour } from './poiInsertion';
import { SearchNotices } from './TourPoiSearchNotices';
import { usePoiInsertion, type InsertionOutcome } from './usePoiInsertion';
import { useSelectedPoi, useTourPoiSearch, type TourPoiSearch } from './useTourPoiSearch';
@@ -1,5 +1,5 @@
import type { TourPoi } from '@trek/shared';
import { isClosedLoop } from '@trek/shared';
import { insertionPreview, isClosedLoop, straightDetourM, type InsertionSlot } from '@trek/shared';
import { ChevronDown, CornerDownRight, Flag, Info, Plus } from 'lucide-react';
import { useTranslation } from '../../../i18n';
import { formatDistance } from '../../../utils/units';
@@ -12,7 +12,6 @@ import { usePopoverList } from '../../shared/usePopoverList';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { useWaypointPlaceNames } from '../planner/useWaypointPlaceNames';
import { insertionPreview, straightDetourM, type InsertionSlot } from './poiInsertion';
import type { usePoiInsertion } from './usePoiInsertion';
type Unit = Parameters<typeof formatDistance>[1];
@@ -1,169 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import {
chosenAfterId,
defaultAfterId,
insertionBlock,
insertionPreview,
insertionSlots,
placesInTour,
straightDetourM,
withPointAfter,
} from './poiInsertion';
// FE-TOUR-POI-INSERT-001 to FE-TOUR-POI-INSERT-009
const point = (id: string, lat: number, lng: number, over: Partial<TourPlannerWaypoint> = {}): TourPlannerWaypoint => ({
id,
lat,
lng,
role: 'via',
...over,
});
const A = point('a', 47.0, 11.0, { role: 'start' });
const B = point('b', 47.01, 11.0);
const C = point('c', 47.02, 11.0, { role: 'end' });
const NEW = point('new', 47.005, 11.001);
describe('placing a found place in the tour (PLN-03)', () => {
it('FE-TOUR-POI-INSERT-001: goes in right after the point chosen, as a stop the router plans to', () => {
expect(withPointAfter([A, B, C], 'a', NEW)?.map((p) => p.id)).toEqual(['a', 'new', 'b', 'c']);
expect(withPointAfter([A, B, C], 'a', NEW)?.[1]).toMatchObject({ role: 'via' });
// After the last point the tour goes on to it.
expect(withPointAfter([A, B, C], 'c', NEW)?.map((p) => p.id)).toEqual(['a', 'b', 'c', 'new']);
});
it('FE-TOUR-POI-INSERT-002: never cuts a fixed line or a stretch drawn by hand', () => {
const ferrata = point('b', 47.01, 11.0, {
fixedLeg: {
kind: 'ferrata',
ref: 'way/1',
name: null,
geometry: [
[47.01, 11.0],
[47.02, 11.0],
],
},
});
const free = point('b', 47.01, 11.0, {
fixedLeg: {
kind: 'free',
geometry: [
[47.01, 11.0],
[47.02, 11.0],
],
},
});
expect(insertionBlock([A, ferrata, C], 'b')).toBe('fixed');
expect(insertionBlock([A, free, C], 'b')).toBe('free');
expect(withPointAfter([A, free, C], 'b', NEW)).toBeNull();
expect(insertionBlock([A, free, C], 'a')).toBeNull();
});
it('FE-TOUR-POI-INSERT-003: nothing goes after the way back of a loop, or after a point that is gone', () => {
const back = point('back', 47.0, 11.0, { role: 'end' });
expect(insertionBlock([A, B, back], 'back')).toBe('none');
expect(insertionBlock([A, B, C], 'gone')).toBe('none');
expect(withPointAfter([A, B, back], 'back', NEW)).toBeNull();
});
it('FE-TOUR-POI-INSERT-004: suggests the last point before the place along the route', () => {
const route: Array<[number, number]> = [
[47.0, 11.0],
[47.01, 11.0],
[47.02, 11.0],
];
// About 1.6 km along: after B (1.1 km), before C (2.2 km).
expect(defaultAfterId([A, B, C], route, 1600)).toBe('b');
expect(defaultAfterId([A, B, C], route, 100)).toBe('a');
// Without a route the points themselves stand in for the line.
expect(defaultAfterId([A, B, C], null, 1600)).toBe('b');
});
it('FE-TOUR-POI-INSERT-005: skips a point that cannot take a stop, and has nothing to suggest without one', () => {
const free = point('b', 47.01, 11.0, {
fixedLeg: {
kind: 'free',
geometry: [
[47.01, 11.0],
[47.02, 11.0],
],
},
});
expect(defaultAfterId([A, free, C], null, 1600)).toBe('a');
expect(defaultAfterId([], null, 0)).toBeNull();
});
});
describe('the slots a found place can go into (PLN-04)', () => {
const back = point('back', 47.0, 11.0, { role: 'end' });
it('FE-TOUR-POI-INSERT-006: one slot per point, between it and the next, the last one as the new end', () => {
expect(insertionSlots([A, B, C])).toEqual([
{ afterId: 'a', beforeId: 'b', block: null },
{ afterId: 'b', beforeId: 'c', block: null },
{ afterId: 'c', beforeId: null, block: null },
]);
// A loop has no new end: the slot after its way back is listed as one that cannot take the place.
expect(insertionSlots([A, B, back])[2]).toEqual({ afterId: 'back', beforeId: null, block: 'none' });
});
it('FE-TOUR-POI-INSERT-007: the preview runs through the place to the next point, or ends on it as the new end', () => {
const place = { lat: 47.03, lng: 11.0 };
expect(insertionPreview([A, B, C], 'b', place)).toEqual([
[47.01, 11.0],
[47.03, 11.0],
[47.02, 11.0],
]);
expect(insertionPreview([A, B, C], 'c', place)).toEqual([
[47.02, 11.0],
[47.03, 11.0],
]);
expect(insertionPreview([A, B, back], 'back', place)).toBeNull();
});
it('FE-TOUR-POI-INSERT-008: the detour as the crow flies: the way round less the stretch it replaces', () => {
// 1.11 km on to the place and 1.11 km back, instead of the 1.11 km between the points.
const between = straightDetourM([
[47.01, 11.0],
[47.02, 11.0],
[47.01, 11.0],
]);
expect(between).toBeGreaterThan(2200);
expect(between).toBeLessThan(2250);
// A place on the way adds nothing.
expect(
straightDetourM([
[47.0, 11.0],
[47.01, 11.0],
[47.02, 11.0],
])
).toBeLessThan(1);
// As the new end, the whole way to it.
expect(
straightDetourM([
[47.02, 11.0],
[47.03, 11.0],
])
).toBeGreaterThan(1100);
});
it('FE-TOUR-POI-INSERT-009: keeps the point picked for the place while it is there, the suggestion otherwise', () => {
const place = { id: 'node:1', along_m: 1600 };
expect(chosenAfterId([A, B, C], null, place, { poiId: 'node:1', afterId: 'c' })).toBe('c');
// Picked for another place, or a point undone since.
expect(chosenAfterId([A, B, C], null, place, { poiId: 'node:2', afterId: 'c' })).toBe('b');
expect(chosenAfterId([A, B, C], null, place, { poiId: 'node:1', afterId: 'gone' })).toBe('b');
expect(chosenAfterId([A, B, C], null, place, null)).toBe('b');
});
it('FE-TOUR-POI-INSERT-010: tells the places that are a point of the tour already by their spot', () => {
const places = [
{ id: 'node:1', lat: B.lat, lng: B.lng },
{ id: 'node:2', lat: B.lat + 0.001, lng: B.lng },
];
expect([...placesInTour(places, [A, B, C])]).toEqual(['node:1']);
expect(placesInTour(places, [A, C]).size).toBe(0);
});
});
@@ -1,138 +0,0 @@
import { haversineMetres, isClosedLoop, nearnessToLine } from '@trek/shared';
import { withPointAppended, type RoutePoint } from '../planner/tourRouteActions';
type Point = RoutePoint & { role: string; shaping?: boolean };
/**
* Why a place cannot go in after a point (PLN-03): the leg after it is a
* fixed line (a via ferrata, a portage) or drawn by hand, which a new point in
* between would cut or redraw without being asked; or there is no leg after it
* to go into (the way back of a loop, a point that is gone).
*/
export type InsertionBlock = 'fixed' | 'free' | 'none';
/** What would stop a place going in after `afterId`, or null when it can. */
export function insertionBlock(points: readonly Point[], afterId: string): InsertionBlock | null {
const index = points.findIndex((point) => point.id === afterId);
if (index < 0) return 'none';
const last = index === points.length - 1;
// After the last point the tour goes on to the place; after the way back of a loop there is nothing.
if (last) return isClosedLoop(points) ? 'none' : null;
const leg = points[index]?.fixedLeg;
if (leg?.kind === 'free') return 'free';
if (leg) return 'fixed';
return null;
}
/**
* The points with a new stop right after `afterId`: in a leg the router plans,
* between that point and the next, or as the new end after the last. Null
* where {@link insertionBlock} says it cannot go; nothing else is changed.
*/
export function withPointAfter<T extends Point>(points: readonly T[], afterId: string, point: T): T[] | null {
if (insertionBlock(points, afterId) !== null) return null;
const index = points.findIndex((candidate) => candidate.id === afterId);
if (index === points.length - 1) return withPointAppended(points, point);
return [...points.slice(0, index + 1), { ...point, role: 'via' }, ...points.slice(index + 1)];
}
/**
* The point a place found at `alongM` metres along the route most likely
* follows: the last point before it along the line, where a stop can go in.
* The first point that can take one when none lies before it.
*/
export function defaultAfterId(
points: readonly Point[],
route: Array<[number, number]> | null,
alongM: number
): string | null {
const open = points.filter((point) => insertionBlock(points, point.id) === null);
if (!open.length) return null;
const line = route && route.length > 1 ? route : points.map((point): [number, number] => [point.lat, point.lng]);
let chosen = open[0]?.id ?? null;
for (const point of open) {
const along = nearnessToLine(point, line)?.alongM ?? 0;
if (along <= alongM) chosen = point.id;
}
return chosen;
}
/**
* The point a place goes in after: the one picked for it while that point is
* still there (not undone, not removed), else the suggestion.
*/
export function chosenAfterId(
points: readonly Point[],
route: Array<[number, number]> | null,
place: { id: string; along_m: number },
picked: { poiId: string; afterId: string } | null
): string | null {
if (picked?.poiId === place.id && points.some((point) => point.id === picked.afterId)) return picked.afterId;
return defaultAfterId(points, route, place.along_m);
}
/**
* PLN-04: where a place can go, one slot per point: between it and the next
* point, or after the last as the new end, which makes the tour longer. A
* slot that cannot take it says why, so the list can show it dimmed.
*/
export interface InsertionSlot {
afterId: string;
/** The point it would go in before; null where it would become the new end. */
beforeId: string | null;
block: InsertionBlock | null;
}
export function insertionSlots(points: readonly Point[]): InsertionSlot[] {
return points.map((point, index) => ({
afterId: point.id,
beforeId: points[index + 1]?.id ?? null,
block: insertionBlock(points, point.id),
}));
}
/**
* The straight lines a place would add in the slot after `afterId`: from that
* point to the place and on to the next, or only to the place as the new
* end. Null where it cannot go there.
*/
export function insertionPreview(
points: readonly Point[],
afterId: string,
place: { lat: number; lng: number }
): Array<[number, number]> | null {
if (insertionBlock(points, afterId) !== null) return null;
const index = points.findIndex((point) => point.id === afterId);
const from = points[index];
if (!from) return null;
const to = points[index + 1];
const line: Array<[number, number]> = [
[from.lat, from.lng],
[place.lat, place.lng],
];
return to ? [...line, [to.lat, to.lng]] : line;
}
/** How much longer the preview's lines make the tour, as the crow flies; the route planned after says it exactly. */
export function straightDetourM(line: ReadonlyArray<[number, number]>): number {
const legs = (path: ReadonlyArray<[number, number]>) =>
path.slice(1).reduce((sum, [lat, lng], index) => {
const [fromLat, fromLng] = path[index] ?? [lat, lng];
return sum + haversineMetres(fromLat, fromLng, lat, lng);
}, 0);
const first = line[0];
const last = line[line.length - 1];
const replaced = line.length > 2 && first && last ? legs([first, last]) : 0;
return Math.max(0, legs(line) - replaced);
}
/** The places of a search that are a point of the tour already, told by where they lie: a taken place keeps its spot. */
export function placesInTour(
places: ReadonlyArray<{ id: string; lat: number; lng: number }>,
points: readonly RoutePoint[]
): Set<string> {
const spots = new Set(points.map((point) => `${point.lat.toFixed(6)},${point.lng.toFixed(6)}`));
return new Set(
places.filter((place) => spots.has(`${place.lat.toFixed(6)},${place.lng.toFixed(6)}`)).map((place) => place.id)
);
}
@@ -1,4 +1,4 @@
import type { TourPoi } from '@trek/shared';
import { chosenAfterId, insertionBlock, insertionSlots, withPointAfter, type TourPoi } from '@trek/shared';
import { useCallback } from 'react';
import { useTranslation } from '../../../i18n';
import { useTourPoiStore, type InsertedTourPoi } from '../../../store/tourPoiStore';
@@ -6,7 +6,6 @@ import { randomId } from '../../../utils/randomId';
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
import type { TourPlannerController, TourPlannerStatus } from '../planner/useTourPlanner';
import { chosenAfterId, insertionBlock, insertionSlots, withPointAfter } from './poiInsertion';
/** The route is planned through and its measures can be read. */
const SETTLED: readonly TourPlannerStatus[] = ['ready', 'saved', 'elevation-failed'];
@@ -1,7 +1,7 @@
import { chosenAfterId, insertionPreview } from '@trek/shared';
import { useMemo } from 'react';
import { useTourPoiStore } from '../../../store/tourPoiStore';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { chosenAfterId, insertionPreview } from './poiInsertion';
import { useSelectedPoi } from './useTourPoiSearch';
/**
@@ -0,0 +1,63 @@
/**
* FE-TOUR-OPEN-001 to FE-TOUR-OPEN-004
*
* P6: the saved tour the editor has open, as the chat's "+ Tour" reads it:
* only a saved hike or city walk the user may edit, on screen, with nothing
* unsaved, and nothing once the editor is gone.
*/
import { renderHook } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { setOpenTour, useOpenTourStore } from '../../../store/openTourStore';
import type { TourPlannerController } from '../planner/useTourPlanner';
import { usePublishOpenTour } from './usePublishOpenTour';
type Planner = Parameters<typeof usePublishOpenTour>[0];
const planner = (over: Partial<Planner> = {}): Planner => ({
tripId: 4,
mode: { type: 'edit-saved', placeId: 64 } as TourPlannerController['mode'],
name: 'Herzogstand',
tourType: 'hike',
hasUnsavedChanges: false,
canEdit: true,
...over,
});
afterEach(() => setOpenTour(null));
describe('usePublishOpenTour', () => {
it('FE-TOUR-OPEN-001: a saved hike open on screen is the tour a found place can go into', () => {
renderHook(() => usePublishOpenTour(planner(), true));
expect(useOpenTourStore.getState().open).toEqual({ tripId: 4, placeId: 64, name: 'Herzogstand' });
});
it('FE-TOUR-OPEN-002: none while the editor is off screen, holds unsaved changes, or the user may not edit', () => {
const { rerender } = renderHook(({ p, shown }) => usePublishOpenTour(p, shown), {
initialProps: { p: planner(), shown: false },
});
expect(useOpenTourStore.getState().open).toBeNull();
rerender({ p: planner({ hasUnsavedChanges: true }), shown: true });
expect(useOpenTourStore.getState().open).toBeNull();
rerender({ p: planner({ canEdit: false }), shown: true });
expect(useOpenTourStore.getState().open).toBeNull();
rerender({ p: planner({ tourType: 'walk' }), shown: true });
expect(useOpenTourStore.getState().open).toMatchObject({ placeId: 64 });
});
it('FE-TOUR-OPEN-003: a new draft, or a tour type that takes no stop from the chat, is none', () => {
const { rerender } = renderHook(({ p }) => usePublishOpenTour(p, true), {
initialProps: { p: planner({ mode: { type: 'new-draft' } as TourPlannerController['mode'] }) },
});
expect(useOpenTourStore.getState().open).toBeNull();
rerender({ p: planner({ tourType: 'bike' }) });
expect(useOpenTourStore.getState().open).toBeNull();
});
it('FE-TOUR-OPEN-004: the editor gone, nothing is open any more', () => {
const { unmount } = renderHook(() => usePublishOpenTour(planner(), true));
expect(useOpenTourStore.getState().open).not.toBeNull();
unmount();
expect(useOpenTourStore.getState().open).toBeNull();
});
});
@@ -0,0 +1,30 @@
import { TOUR_STOP_TYPES } from '@trek/shared';
import { useEffect } from 'react';
import { setOpenTour } from '../../../store/openTourStore';
import type { TourPlannerController } from '../planner/useTourPlanner';
type PublishedPlanner = Pick<
TourPlannerController,
'tripId' | 'mode' | 'name' | 'tourType' | 'hasUnsavedChanges' | 'canEdit'
>;
const STOP_TYPES: readonly string[] = TOUR_STOP_TYPES;
/**
* P6: tells the chat which saved tour the editor has open, so a place the
* assistant found can be taken into it with "+ Tour". Only a saved hike or
* city walk the user may edit counts, and only while the editor holds nothing
* unsaved: the stop is saved on the server, and a draft would then stand
* against a newer tour. `shown` is whether the editor is on screen at all.
*/
export function usePublishOpenTour(planner: PublishedPlanner, shown: boolean): void {
const { tripId, mode, name, tourType, hasUnsavedChanges, canEdit } = planner;
const placeId = mode.type === 'edit-saved' ? mode.placeId : null;
const trip = Number(tripId);
const takesStop = shown && placeId !== null && canEdit && !hasUnsavedChanges && STOP_TYPES.includes(tourType);
useEffect(() => {
setOpenTour(takesStop && placeId !== null ? { tripId: trip, placeId, name } : null);
}, [name, placeId, takesStop, trip]);
// The editor gone, the chat no longer offers to take anything into it.
useEffect(() => () => setOpenTour(null), []);
}
@@ -5,6 +5,7 @@ import { usePaddleChecklist } from '../../components/Tours/paddle/usePaddleCheck
import { useTrackPaddlePlan } from '../../components/Tours/paddle/useTrackPaddlePlan';
import { useTourPlanner } from '../../components/Tours/planner/useTourPlanner';
import { useTourRevisionWatch } from '../../components/Tours/planner/useTourRevisionWatch';
import { usePublishOpenTour } from '../../components/Tours/poi/usePublishOpenTour';
import { useSkiClassification } from '../../components/Tours/ski/useSkiClassification';
import { useRecordedTrack } from '../../components/Tours/useRecordedTrack';
import { useTourGpxImport } from '../../components/Tours/useTourGpxImport';
@@ -85,6 +86,7 @@ export function useTourWorkspace(planner: TripPlanner, canPlaceEdit: boolean, ca
});
useTourRevisionWatch(tourPlanner, tours); // P2C-03: a save made elsewhere reaches the open editor
usePublishOpenTour(tourPlanner, activeTab === 'tour-planner' && !isMobile); // P6: the chat's "+ Tour" takes a found place into the tour open here
const { viewImportedTour, openTourInPlanner } = useTourOpeners(tourPlanner, places, tours);
// The tours workspace's GPX import, shared by its button and its file drop. One new
+7
View File
@@ -7,6 +7,7 @@ import {
type AssistantAddFoundPlaceRequest,
type AssistantChatRequest,
type AssistantDecisionsRequest,
type AssistantInsertFoundPlaceRequest,
type AssistantShareRequest,
type AssistantTakeProposalRequest,
type BookDocument,
@@ -86,6 +87,12 @@ export const assistantRepo = {
return assistantAddFoundPlaceResponseSchema.parse(await assistantApi.addFoundPlace(tripId, threadId, body));
},
/** A found place taken into a saved tour as a stop ("+ Tour"), held as a change; no model runs for it. */
async insertFoundPlace(tripId: number, threadId: number, body: AssistantInsertFoundPlaceRequest) {
requireOnline();
return assistantAddFoundPlaceResponseSchema.parse(await assistantApi.insertFoundPlace(tripId, threadId, body));
},
/** A proposal an answer showed as a card ("Plan this"), held as a change; no model runs for it. */
async takeProposal(tripId: number, threadId: number, body: AssistantTakeProposalRequest) {
requireOnline();
+6
View File
@@ -228,6 +228,8 @@ interface AssistantState {
* thread changed under it, and always skipped under a journey key.
*/
takeProposal: (key: AssistantChatKey, toolCallId: string) => Promise<HoldOutcome>;
/** Takes a found place into the saved tour open in the planner as a stop ("+ Tour"), held as a change to decide. */
insertFoundPlace: (key: AssistantChatKey, ref: string, tourId: number) => Promise<HoldOutcome>;
}
/** What came of a change the server was asked to hold without a model turn. */
@@ -772,6 +774,10 @@ function compareActions(ctx: Ctx) {
holdWithoutTurn(ctx, tripId, (threadId) =>
assistantRepo.takeProposal(tripId, threadId, { ...clientContext(), toolCallId })
),
insertFoundPlace: (tripId: number, ref: string, tourId: number) =>
holdWithoutTurn(ctx, tripId, (threadId) =>
assistantRepo.insertFoundPlace(tripId, threadId, { ...clientContext(), ref, tourId })
),
};
}
+32
View File
@@ -0,0 +1,32 @@
import { create } from 'zustand';
/** The saved tour open in the planner's editor, as the chat may take a found place into it. */
interface OpenTour {
tripId: number;
placeId: number;
name: string;
}
interface OpenTourState {
/** Null while no saved hike or city walk is open, or while its editor holds changes not yet saved. */
open: OpenTour | null;
}
/**
* P6: which saved tour the planner has open, for "+ Tour" on a place the
* assistant found. The planner writes it (usePublishOpenTour), the chat's
* place cards read it; nothing else is kept here.
*/
export const useOpenTourStore = create<OpenTourState>(() => ({ open: null }));
export function setOpenTour(open: OpenTour | null): void {
const current = useOpenTourStore.getState().open;
const same =
current === open ||
(!!current &&
!!open &&
current.tripId === open.tripId &&
current.placeId === open.placeId &&
current.name === open.name);
if (!same) useOpenTourStore.setState({ open });
}