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

This commit is contained in:
Maurice
2026-10-22 15:50:47 +02:00
parent a43049eb5d
commit 085c5b4263
135 changed files with 12185 additions and 234 deletions
@@ -44,6 +44,8 @@ const NAMED_WITHIN_M = 2_000;
const GOOGLE_PLACE_ID = /^[\w-]{1,200}$/;
const MAX_OSM_ID_CHARS = 40;
/** A source's own id that names an OpenStreetMap element, as a tour POI carries it (`node:123`). */
const OSM_ELEMENT_ID = /^(node|way|relation):\d+$/;
const MAX_CALL_ID_CHARS = 64;
const WEATHER_KINDS: ReadonlySet<string> = new Set(['forecast', 'climate', 'current']);
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
@@ -53,6 +55,8 @@ const PLACE_LISTS: Readonly<Record<string, 'places' | 'pois'>> = {
search_place: 'places',
search_nearby_places: 'places',
search_pois: 'pois',
// Places along a saved tour: a hut, a spring, a viewpoint to add to the day.
search_tour_pois: 'pois',
};
/** Whether the trip runs today, the only time a source's "open now" means anything for it. */
@@ -72,7 +76,9 @@ function foundPlaceOf(row: Row, ref: string, card: TripCard, running: boolean):
if (!isLat(lat) || !isLng(lng) || !name) return null;
const googleId = typeof row.google_place_id === 'string' ? row.google_place_id : '';
const googlePlaceId = GOOGLE_PLACE_ID.test(googleId) ? googleId : null;
const osm = typeof row.osm_id === 'string' ? row.osm_id : '';
// A tour POI has no osm_id of its own: its id is the OpenStreetMap element when the source is OpenStreetMap.
const ownId = typeof row.id === 'string' && OSM_ELEMENT_ID.test(row.id) ? row.id : '';
const osm = typeof row.osm_id === 'string' ? row.osm_id : ownId;
const osmId = osm !== '' && osm.length <= MAX_OSM_ID_CHARS ? osm : null;
const rating = finite(row.rating);
const inTrip = [...card.places.values()].find((place) => isSamePlace(place, googlePlaceId, osmId));
@@ -11,6 +11,7 @@ import {
ASSISTANT_SERVER_TOOLS,
IMPORT_BOOKING_TOOL,
READ_BOOKING_FILE_TOOL,
TAKE_PROPOSAL_TOOL,
} from './assistant-tools.constants';
import type { AssistantMessage, AssistantMessagePart, AssistantToolApproval } from '@trek/shared';
@@ -87,6 +88,18 @@ function foundPlaceOutcome(part: ToolCallPart): string {
return `The user picked ${place} from the search results for ${day}; it waits for them to add or discard it.`;
}
/** What became of a proposal the user took from an answer to add it to the trip, as its stored part says. */
function takenProposalOutcome(part: ToolCallPart): string {
const name = quoted(part.args.name);
const what = part.args.via === 'create_city_walk' ? 'city walk' : 'hike';
if (part.approval?.approved === true) {
return part.isError ? `The ${what} ${name} could not be added.` : `The ${what} ${name} was added to the trip.`;
}
if (part.approval?.approved === false) return `The user discarded the ${what} ${name}.`;
if (part.approval?.resolution) return `The ${what} ${name} was not added; the user moved on without deciding.`;
return `The user took the proposed ${what} ${name} to add it; it waits for them to add or discard it.`;
}
/**
* The server's own steps of a message (reading an attached file, the bookings
* it found) as one note in plain words. They are no tools the provider knows,
@@ -107,6 +120,8 @@ export function serverStepsNote(calls: readonly ToolCallPart[]): string {
lines.push(bookingOutcome(part));
} else if (part.toolName === ADD_FOUND_PLACE_TOOL) {
lines.push(foundPlaceOutcome(part));
} else if (part.toolName === TAKE_PROPOSAL_TOOL) {
lines.push(takenProposalOutcome(part));
}
}
return lines.join(' ');
@@ -0,0 +1,311 @@
import { AssistantMessages } from '../../db/entities/AssistantMessages.entity';
import { AssistantThreads } from '../../db/entities/AssistantThreads.entity';
import { AssistantToolCalls } from '../../db/entities/AssistantToolCalls.entity';
import {
ASSISTANT_MESSAGE_PARTS_WRITE,
ASSISTANT_TOOL_ARGS,
ASSISTANT_TOOL_PREVIEW_WRITE,
} from '../../db/json-columns';
import type {
AssistantMessageRow,
AssistantMessagesRepository,
} from '../../db/repositories/AssistantMessages.repository';
import type { AssistantThreadsRepository } from '../../db/repositories/AssistantThreads.repository';
import type {
AssistantToolCallRow,
AssistantToolCallsRepository,
} from '../../db/repositories/AssistantToolCalls.repository';
import { dbNow } from '../../db/types';
import { encodeJson } from '../../utils/json-column';
import { aiRefusal } from '../ai-connections/ai-connections.constants';
import { RateLimitService } from '../common/rate-limit.service';
import { UnitOfWork } from '../database/unit-of-work';
import { buildTripCard, categoriesFrom, todayIn, type TripCard } from './assistant-context';
import { partsOf, toThreadMessages } from './assistant-history';
import { AssistantStreamRegistry } from './assistant-stream.registry';
import { TAKE_PROPOSAL_TOOL, TURNS_PER_MINUTE } from './assistant-tools.constants';
import { AssistantToolsService, type ToolRun } from './assistant-tools.service';
import { AssistantCardContext } from './cards/card-context';
import { recordOf, type Row } from './cards/card-values';
import { argText } from './cards/tour/tour-card-values';
import { cityWalkCard } from './cards/tour/tour-sport.cards';
import { describeToolCall } from './tool-call-preview.helpers';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import type {
AssistantAddFoundPlaceResponse,
AssistantMessage,
AssistantMessagePart,
AssistantTakeProposalRequest,
TourRouteCard,
} from '@trek/shared';
import { randomUUID } from 'node:crypto';
/**
* The proposing read a walk is asked again with: the answer stored in the
* thread is the copy the model saw (masked, its lists cut to fit), so the
* stations to create are read whole from the tool itself.
*/
const WALK_READ = 'propose_city_walk';
/** What a taken proposal reads: the trip's days and places, the category names its card writes with, a walk again. */
const TRIP_READS: ReadonlySet<string> = new Set(['get_trip_summary', 'list_places', 'list_categories', WALK_READ]);
/** Message rows searched for the proposal; the history a turn sends the provider. */
const SEARCHED_ROWS = 40;
/** Message rows the answer holding the change is read back from; a thread is shown with as many. */
const THREAD_ROWS = 50;
/** The longest name a city walk is given from its stations, well inside a tour's name. */
const WALK_NAME_CHARS = 120;
/** A proposal an answer showed: the call that proposed it, what it answered, and the card drawn from it. */
interface StoredProposal {
toolName: string;
args: Row;
result: Row;
card: TourRouteCard;
}
/** The proposing reads a card can be taken from, and the tool each one is created through. */
const CREATES: Readonly<Record<string, 'create_hike' | 'create_city_walk'>> = {
propose_hike: 'create_hike',
propose_city_walk: 'create_city_walk',
};
const PROPOSING: ReadonlySet<string> = new Set(Object.keys(CREATES));
/** The proposal under `toolCallId` in the newest rows that hold it, with its stored card; null when there is none. */
export function storedProposal(rows: readonly AssistantMessageRow[], toolCallId: string): StoredProposal | null {
for (const row of [...rows].reverse()) {
const parts = partsOf(row);
const call = parts.find(
(part) => part.type === 'tool-call' && part.toolCallId === toolCallId && PROPOSING.has(part.toolName),
);
const drawn = parts.find((part) => part.type === 'data-trek-card' && part.data.toolCallId === toolCallId);
const card = drawn?.type === 'data-trek-card' ? drawn.data.card : null;
if (call?.type === 'tool-call' && card?.kind === 'tourRoute') {
return { toolName: call.toolName, args: recordOf(call.args), result: recordOf(call.result), card };
}
}
return null;
}
/** A walk's name from its first and last named stations ("Marienplatz → Englischer Garten"), else day and time. */
function walkName(stations: readonly Row[], result: Row): string {
const names = stations
.map((station) => (typeof station.name === 'string' ? station.name.replace(/\s+/g, ' ').trim() : ''))
.filter((name) => name !== '');
const ends = [...new Set([names[0], names.at(-1)])].filter((name): name is string => !!name);
const frame = recordOf(result.frame);
const fallback = [result.date, frame.start_time].filter((part) => typeof part === 'string').join(' ');
const name = ends.length > 0 ? ends.join(' → ') : fallback;
return name.length > WALK_NAME_CHARS ? `${name.slice(0, WALK_NAME_CHARS - 1)}…` : name;
}
/**
* A walk asked again with the arguments it was proposed with: the stations to
* create from the tool's own answer, and the card drawn from that answer (the
* stored one when none can be drawn). Null when the walk cannot be read now.
*/
async function walkAgain(
read: (name: string, args: Row) => Promise<ToolRun>,
proposal: StoredProposal,
trip: TripCard,
today: string,
): Promise<StoredProposal | null> {
const run = await read(WALK_READ, proposal.args);
if (!run.ok) return null;
const drawn = cityWalkCard({ toolCallId: '', args: proposal.args, value: run.value, card: trip, today });
return { ...proposal, result: recordOf(run.value), card: drawn ?? proposal.card };
}
/**
* The card of the held change: the proposal's, waiting, under the name the
* change saves and without a variant label; a day only where the change puts
* the tour on that day (a walk is created on none, a hike asked by date alone
* on none either).
*/
export function pendingCardOf(card: TourRouteCard, args: Row): TourRouteCard {
const dayId = typeof args.day_id === 'number' ? args.day_id : null;
const keepsDay = card.schedule?.dayId === dayId;
const schedule = card.schedule && !keepsDay ? { ...card.schedule, dayId: null, dayNumber: null } : card.schedule;
return { ...card, source: 'pending', name: argText(args.name, 120) ?? card.name, label: null, schedule };
}
/** The fields of a propose_hike create_hike takes as they are. */
const HIKE_FIELDS = [
'name',
'waypoints',
'max_hiking_difficulty',
'hiking_preferences',
'round_trip',
'day_id',
'start_time',
] as const;
/**
* The change a taken proposal holds: create_hike with the proposal's own
* arguments for a hike whose way was planned, create_city_walk with the
* stations it answered for a walk that can be created. Null for a proposal
* nothing can be created from.
*/
export function takeArgsOf(proposal: StoredProposal): Row | null {
const via = CREATES[proposal.toolName];
if (via === 'create_hike') {
if (!proposal.card.routed) return null;
const args: Row = { via };
for (const key of HIKE_FIELDS) if (proposal.args[key] !== undefined) args[key] = proposal.args[key];
return args;
}
const stations = Array.isArray(proposal.result.stations_for_create) ? proposal.result.stations_for_create : [];
if (proposal.result.can_create === false || stations.length < 2) return null;
return { via, name: walkName(stations.map(recordOf), proposal.result), stations };
}
/**
* "Plan this" on a hike or a city walk an answer proposed: the change that
* creates it is held as a card, decided like one the model proposed, but
* without a model turn and without counting against the daily limit. What is
* created comes from the proposal stored in the thread, never from the
* request: a hike from its own arguments under the card the answer showed, a
* walk from the tool asked again with its own arguments, since the stored
* answer is the model's shortened copy. Nothing is written to the trip here;
* applying the card runs the tool with the user's own rights.
*/
@Injectable()
export class AssistantProposalsService {
constructor(
@InjectRepository(AssistantThreads) private readonly threads: AssistantThreadsRepository,
@InjectRepository(AssistantMessages) private readonly messages: AssistantMessagesRepository,
@InjectRepository(AssistantToolCalls) private readonly toolCalls: AssistantToolCallsRepository,
private readonly tools: AssistantToolsService,
private readonly rateLimit: RateLimitService,
private readonly streams: AssistantStreamRegistry,
private readonly uow: UnitOfWork,
private readonly cardContext: AssistantCardContext,
) {}
/**
* Holds the change for the proposal in the user's thread and answers with
* the answer that now holds it. Refused while a turn of the user runs
* (`busy`), while the thread holds changes nobody decided on
* (`decide_first`), past the per-minute limit, for a proposal the thread
* does not hold (404) and for one nothing can be created from.
*/
async take(
userId: number,
tripId: number,
threadId: number,
req: AssistantTakeProposalRequest,
): Promise<AssistantAddFoundPlaceResponse> {
if (!(await this.threads.findOwned(threadId, userId, tripId))) {
throw aiRefusal('not_found', 'Conversation not found');
}
if (!(await this.rateLimit.check('assistant', String(userId), TURNS_PER_MINUTE, 60_000, Date.now()))) {
throw aiRefusal('too_many_requests', 'Too many requests, try again in a minute');
}
const controller = new AbortController();
if (!(await this.streams.claim(userId, controller))) {
throw aiRefusal('busy', 'The assistant is already answering in another window');
}
try {
await this.hold(userId, tripId, threadId, req);
} finally {
await this.streams.release(userId, controller);
}
return { message: await this.answerOf(threadId) };
}
private async hold(userId: number, tripId: number, threadId: number, req: AssistantTakeProposalRequest) {
if ((await this.toolCalls.listOpen(threadId)).length > 0) {
throw aiRefusal('decide_first', 'Decide on the changes that are waiting first');
}
const stored = storedProposal(await this.messages.listLatest(threadId, SEARCHED_ROWS), req.toolCallId);
if (!stored) throw aiRefusal('not_found', 'This proposal is not in the conversation');
const id = randomUUID();
const today = todayIn(req.timeZone, new Date());
const { card, categories, proposal } = await this.tripOf(userId, tripId, stored, today);
const args = proposal ? takeArgsOf(proposal) : null;
if (!proposal || !args) throw aiRefusal('generic', 'Nothing can be created from this proposal: ask for it again');
const ctx = this.cardContext.contextOf(card, categories, req.locale, today);
const preview = {
...describeToolCall(TAKE_PROPOSAL_TOOL, id, args, ctx),
card: pendingCardOf(proposal.card, args),
};
const parts: AssistantMessagePart[] = [
{ type: 'tool-call', toolCallId: id, toolName: TAKE_PROPOSAL_TOOL, args, approval: { id } },
{ type: 'data-trek-change', data: preview },
];
const now = dbNow();
await this.uow.transactional(async () => {
const messageId = await this.messages.append({
threadId,
role: 'assistant',
parts: encodeJson(ASSISTANT_MESSAGE_PARTS_WRITE, parts),
providerState: null,
status: 'awaiting_approval',
model: null,
createdAt: now,
});
await this.toolCalls.insertProposals([
{
id,
threadId,
messageId,
providerCallId: `take_${id}`,
toolName: TAKE_PROPOSAL_TOOL,
args: encodeJson(ASSISTANT_TOOL_ARGS, args),
preview: encodeJson(ASSISTANT_TOOL_PREVIEW_WRITE, preview),
createdAt: now,
},
]);
await this.threads.touch(threadId, now);
});
}
/**
* The trip card, the category names and the proposal to take (a walk read
* again, null when it cannot be), read through the tools with the user's rights.
*/
private async tripOf(
userId: number,
tripId: number,
stored: StoredProposal,
today: string,
): Promise<{ card: TripCard; categories: ReadonlyMap<number, string>; proposal: StoredProposal | null }> {
const session = await this.tools.open(userId, tripId, TRIP_READS);
try {
const summary = await session.read('get_trip_summary');
const places = await session.read('list_places');
const listed = await session.read('list_categories');
const categories = categoriesFrom(listed.ok ? listed.value : null);
const card = buildTripCard(
tripId,
userId,
summary.ok ? summary.value : null,
places.ok ? places.value : null,
null,
);
const read = (name: string, args: Row) => session.read(name, args);
return {
card,
categories: new Map(categories.map((category): [number, string] => [category.id, category.name])),
proposal: stored.toolName === WALK_READ ? await walkAgain(read, stored, card, today) : stored,
};
} finally {
await session.close();
}
}
/** The newest answer of the thread as the client shows it, with what became of its changes. */
private async answerOf(threadId: number): Promise<AssistantMessage> {
const rows = await this.messages.listLatest(threadId, THREAD_ROWS);
const held = rows.filter((row) => partsOf(row).some((part) => part.type === 'tool-call' && part.approval));
const lists = await Promise.all(held.map((row) => this.toolCalls.listByMessage(row.id)));
const calls = new Map(lists.flat().map((call): [string, AssistantToolCallRow] => [call.id, call]));
const messages = toThreadMessages(rows, calls, new Date());
return messages[messages.length - 1];
}
}
@@ -119,6 +119,13 @@ export const IMPORT_BOOKING_TOOL = 'import_booking';
*/
export const ADD_FOUND_PLACE_TOOL = 'add_found_place';
/**
* A proposal an answer showed as a card, taken by the user ("Plan this" on a
* proposed hike or city walk). Held as a change card like a found place,
* through the model's tool in its `via` argument, without a model turn.
*/
export const TAKE_PROPOSAL_TOOL = 'take_proposal';
/**
* A fact or wish the user wants kept for this trip ("we are two"). It is no
* MCP tool: the server offers it to the model itself, holds it as a change
@@ -130,6 +137,9 @@ export const REMEMBER_TOOL = 'remember_for_trip';
/** The model's tools a found place is added through: a new place for the day, or one the trip has already. */
const FOUND_PLACE_VIA: ReadonlySet<string> = new Set(['create_and_assign_place', 'assign_place_to_day']);
/** The model's tools a taken proposal is created through: a hike, a city walk. */
const PROPOSAL_VIA: ReadonlySet<string> = new Set(['create_hike', 'create_city_walk']);
/**
* Changes the server applies itself: a booking from an attached file, a search
* result the user picked, a note the user wants kept. They are held, decided
@@ -143,20 +153,24 @@ export const ASSISTANT_SERVER_WRITE_TOOLS: Readonly<Record<string, AssistantWrit
[IMPORT_BOOKING_TOOL]: { verbKey: 'importBooking', entity: 'reservation' },
// The card names the verb of the `via` tool; these are its fallbacks.
[ADD_FOUND_PLACE_TOOL]: { verbKey: 'addPlaceToDay', entity: 'place', quiet: true },
[TAKE_PROPOSAL_TOOL]: { verbKey: 'addTour', entity: 'tour', quiet: true },
[REMEMBER_TOOL]: { verbKey: 'rememberNote', entity: 'memory' },
};
/**
* The tool a held call runs as: for a found place the model's tool in its
* `via` argument, for anything else the call's own. A `via` that is not one of
* the two is returned as it is, so it finds no card, run or undo.
* The tool a held call runs as: for a found place or a taken proposal the
* model's tool in its `via` argument, for anything else the call's own. A
* `via` that is not one of the tools it may name is returned as it is, so it
* finds no card, run or undo.
*/
export function writeToolOf(toolName: string, args: Record<string, unknown>): string {
if (toolName !== ADD_FOUND_PLACE_TOOL) return toolName;
return typeof args.via === 'string' && FOUND_PLACE_VIA.has(args.via) ? args.via : toolName;
const via = typeof args.via === 'string' ? args.via : '';
if (toolName === ADD_FOUND_PLACE_TOOL) return FOUND_PLACE_VIA.has(via) ? via : toolName;
if (toolName === TAKE_PROPOSAL_TOOL) return PROPOSAL_VIA.has(via) ? via : toolName;
return toolName;
}
/** The arguments a found place runs its `via` tool with: what that tool takes, nothing else. */
/** The arguments a found place or a taken proposal runs its `via` tool with: what that tool takes, nothing else. */
export function foundPlaceArgs(args: Record<string, unknown>): Record<string, unknown> {
if (args.via === 'assign_place_to_day') return { dayId: args.dayId, placeId: args.placeId };
const { via: _via, ...rest } = args;
@@ -10,6 +10,7 @@ import { ResponseContract } from '../common/response-contract';
import { onClientGone, openSseStream } from '../common/sse-stream';
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
import { AssistantFoundPlacesService } from './assistant-found-places.service';
import { AssistantProposalsService } from './assistant-proposals.service';
import { AssistantShareService } from './assistant-share.service';
import {
AssistantAddFoundPlaceDto,
@@ -17,6 +18,7 @@ import {
AssistantDecisionsDto,
AssistantShareDto,
AssistantStopDto,
AssistantTakeProposalDto,
AssistantUndoDto,
} from './assistant.dto';
import { AssistantService, type TurnChannel } from './assistant.service';
@@ -67,6 +69,7 @@ export class AssistantController {
private readonly assistant: AssistantService,
private readonly foundPlaces: AssistantFoundPlacesService,
private readonly shares: AssistantShareService,
private readonly proposals: AssistantProposalsService,
) {}
@Get('threads')
@@ -125,6 +128,19 @@ export class AssistantController {
return this.foundPlaces.add(user.id, Number(tripId), idParam(threadId), body);
}
/** "Plan this" on a hike or a city walk an answer proposed: a change card that creates it, no model turn. */
@Post('threads/:threadId/proposals')
@HttpCode(200)
@ResponseContract(assistantAddFoundPlaceResponseSchema)
takeProposal(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Param('threadId') threadId: string,
@Body() body: AssistantTakeProposalDto,
) {
return this.proposals.take(user.id, Number(tripId), idParam(threadId), body);
}
/** An answer shared in the trip's chat, marked as from the assistant; the chat's own right decides. */
@Post('threads/:threadId/messages/:messageId/share')
@HttpCode(201)
@@ -10,6 +10,7 @@ import {
assistantDecisionsRequestSchema,
assistantShareRequestSchema,
assistantStopRequestSchema,
assistantTakeProposalRequestSchema,
assistantUndoRequestSchema,
} from '@trek/shared';
@@ -21,4 +22,5 @@ export class AssistantChatDto extends createZodDto(assistantChatRequestSchema) {
export class AssistantDecisionsDto extends createZodDto(assistantDecisionsRequestSchema) {}
export class AssistantShareDto extends createZodDto(assistantShareRequestSchema) {}
export class AssistantStopDto extends createZodDto(assistantStopRequestSchema) {}
export class AssistantTakeProposalDto extends createZodDto(assistantTakeProposalRequestSchema) {}
export class AssistantUndoDto extends createZodDto(assistantUndoRequestSchema) {}
@@ -16,6 +16,7 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module'
import { AssistantAttachmentsController } from './assistant-attachments.controller';
import { AssistantBookingImportService } from './assistant-booking-import.service';
import { AssistantFoundPlacesService } from './assistant-found-places.service';
import { AssistantProposalsService } from './assistant-proposals.service';
import { AssistantRetentionJob } from './assistant-retention.job';
import { AssistantShareService } from './assistant-share.service';
import { AssistantStreamRegistry, InMemoryAssistantStreamRegistry } from './assistant-stream.registry';
@@ -59,6 +60,7 @@ import { Module } from '@nestjs/common';
AssistantToolsService,
AssistantBookingImportService,
AssistantFoundPlacesService,
AssistantProposalsService,
AssistantShareService,
AssistantRetentionJob,
AssistantCardContext,
@@ -43,7 +43,6 @@ import { toolOutputForProvider } from './assistant-output';
import { AssistantStreamRegistry } from './assistant-stream.registry';
import { titleFrom } from './assistant-title';
import {
ADD_FOUND_PLACE_TOOL,
APPROVAL_TTL_MS,
ASSISTANT_SERVER_WRITE_TOOLS,
foundPlaceArgs,
@@ -349,7 +348,7 @@ export class AssistantService {
/**
* Decisions on changes the user proposed themselves (a found place for a
* day): applied or discarded like any others, but no model turn follows, so
* day, a taken proposal): applied or discarded like any others, but no model turn follows, so
* neither the connection nor the daily limit is asked.
*
* @txIndependent like `decide`: the decisions in one transaction, then each
@@ -390,7 +389,7 @@ export class AssistantService {
/**
* Runs one applied change: a booking from a file through the booking import,
* a found place through the tool it names, anything else through its own tool.
* a found place or a taken proposal through the tool it names, anything else through its own tool.
*/
private runChange(
session: AssistantToolSession,
@@ -400,7 +399,7 @@ export class AssistantService {
args: Record<string, unknown>,
): Promise<ToolRun> {
if (name === IMPORT_BOOKING_TOOL) return this.inContext(() => this.bookings.apply(userId, tripId, args));
if (name === ADD_FOUND_PLACE_TOOL) return session.call(writeToolOf(name, args), foundPlaceArgs(args));
if (writeToolOf(name, args) !== name) return session.call(writeToolOf(name, args), foundPlaceArgs(args));
if (name === REMEMBER_TOOL) return this.inContext(() => rememberForTrip(this.settings, userId, tripId, args));
return session.call(name, args);
}
+76 -10
View File
@@ -1,25 +1,91 @@
import type { DomainCards } from '../card-types';
import type { DomainCards, ToolCards } from '../card-types';
import { recordOf } from '../card-values';
import {
appliedTourCard,
createCityWalkCard,
createHikeCard,
createHikeEffect,
loadCreateHike,
loadUpdateTour,
updateTourCard,
type TourLoaded,
} from './tour-change.cards';
import { TOUR_DESCRIBERS } from './tour-describe';
import { DELETE_CREATED_TOUR } from './tour-undo';
import { tourListCard } from './tour-lists.cards';
import { proposalCard, savedTourCard } from './tour-route.cards';
import { cityWalkCard, paddleCard, skiCard } from './tour-sport.cards';
import { DELETE_CREATED_TOUR, DELETE_IMPORTED_TOUR } from './tour-undo';
/**
* The tours in the assistant chat. A tour is a place with a route: its id is
* its place's, so a created one is taken back by deleting that place.
* its place's, so a created one is taken back by deleting that place. The
* planning tools are always asked for their line for drawing, which the
* model never sees (every key holding `geometry` is dropped for it); the
* cards draw it.
*/
/** The planning tools answer their line for drawing; the flag is set by the server and hidden from the model. */
const WITH_GEOMETRY: NonNullable<ToolCards['argPolicy']> = { forced: { include_geometry: true } };
/** get_tour_plan as the model reads it: without the saved line in brief, which only the card draws. */
function withoutPreview(value: unknown): unknown {
const plan = recordOf(value);
if (!plan.line || typeof plan.line !== 'object') return value;
const { preview: _preview, ...line } = recordOf(plan.line);
return { ...plan, line };
}
const createHike: ToolCards<TourLoaded> = {
write: { verbKey: 'addTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.create_hike,
load: loadCreateHike,
change: createHikeCard,
effect: createHikeEffect,
applied: appliedTourCard,
undo: DELETE_CREATED_TOUR,
};
const updateTour: ToolCards<TourLoaded> = {
write: { verbKey: 'updateTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.update_tour,
load: loadUpdateTour,
change: updateTourCard,
// The new way stays beside the receipt: the answer is the tour as saved, naming it.
applied: appliedTourCard,
};
export const TOUR_CARDS: DomainCards = {
id: 'tour',
tools: {
create_hike: {
write: { verbKey: 'addTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.create_hike,
undo: DELETE_CREATED_TOUR,
},
propose_hike: { argPolicy: WITH_GEOMETRY, answer: proposalCard },
propose_city_walk: { argPolicy: WITH_GEOMETRY, answer: cityWalkCard },
plan_paddle_route: { argPolicy: WITH_GEOMETRY, answer: paddleCard },
plan_ski_route: { argPolicy: WITH_GEOMETRY, answer: skiCard },
get_tour_plan: { project: withoutPreview, answer: savedTourCard },
list_tours: { answer: tourListCard },
find_tours_for_day: { answer: tourListCard },
create_hike: createHike,
create_city_walk: {
write: { verbKey: 'addTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.create_city_walk,
change: createCityWalkCard,
applied: appliedTourCard,
undo: DELETE_CREATED_TOUR,
},
update_tour: updateTour,
import_tour_gpx: {
write: { verbKey: 'importTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.import_tour_gpx,
undo: DELETE_IMPORTED_TOUR,
},
take_over_tour: {
write: { verbKey: 'addTour', entity: 'tour' },
describe: TOUR_DESCRIBERS.take_over_tour,
undo: DELETE_CREATED_TOUR,
},
update_tour: { write: { verbKey: 'updateTour', entity: 'tour' }, describe: TOUR_DESCRIBERS.update_tour },
import_tour_gpx: { write: { verbKey: 'importTour', entity: 'tour' }, describe: TOUR_DESCRIBERS.import_tour_gpx },
},
prompt: [
'Hikes: call propose_hike with a name, and day_id and start_time when known; to compare ways, call it up to three times in one step, each with a variant_label and along false. Call create_hike only once the user chose a way.',
'The app draws a proposed, planned or saved tour (route, profile, stops, times, weather, warnings) and lists of tours as cards: do not repeat their figures or list them again, say in one sentence what matters.',
],
};
@@ -0,0 +1,246 @@
import { thinLine } from '../../../../utils/polyline';
import type { TripCard } from '../../assistant-context';
import { clipped, clockOf, finite, isLat, isLng, recordOf, type Row } from '../card-values';
import {
cumulativeMetres,
haversineMetres,
MAX_CARD_CHARS,
TOUR_POI_CATEGORIES,
TOUR_ROUTE_LIMITS,
type TourPoiCategory,
type TourRouteCard,
type TourRouteStop,
type TourRouteStretch,
} from '@trek/shared';
/**
* The readers the tour cards are built with: a tool's own answer or a call's
* arguments taken as they come, nothing trusted, every number bounded the way
* the card kind bounds it. A line is thinned for drawing and keeps either a
* height on every point or on none, so a profile is drawn only from a line
* whose heights are all known.
*/
export type RoutePoint = TourRouteCard['line'][number];
/** A coordinate as a card draws it: five decimals, about a metre. */
export function coord(value: number): number {
return Math.round(value * 1e5) / 1e5;
}
/** A whole number within `[min, max]`, or null for anything else. */
export function whole(value: unknown, min: number, max: number): number | null {
const number = finite(value);
if (number === null) return null;
const rounded = Math.round(number);
return rounded >= min && rounded <= max ? rounded : null;
}
export const metresOf = (value: unknown) => whole(value, 0, 5_000_000);
export const climbOf = (value: unknown) => whole(value, 0, 100_000);
export const heightOf = (value: unknown) => whole(value, -500, 9_000);
export const minutesOf = (value: unknown) => whole(value, 0, 86_400);
/** A SAC step, T1 to T6 as 1 to 6, or null. */
export function sacStepOf(value: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 6 ? value : null;
}
/** A point `{ lat, lng }` (or `[lat, lng]`), or null when it is none. */
export function latLngOf(value: unknown): { lat: number; lng: number } | null {
const [rawLat, rawLng] = Array.isArray(value) ? value : [recordOf(value).lat, recordOf(value).lng];
const lat = finite(rawLat);
const lng = finite(rawLng);
return isLat(lat) && isLng(lng) ? { lat: coord(lat), lng: coord(lng) } : null;
}
/**
* A line to draw from a tool's `[lat, lng]` or `[lat, lng, height]` points:
* bad points left out, thinned to `most`, every height null when one of the
* kept points has none.
*/
export function lineOf(raw: unknown, most: number = TOUR_ROUTE_LIMITS.line): RoutePoint[] {
if (!Array.isArray(raw)) return [];
const points = raw.flatMap((point: unknown): Array<[number, number, number | null]> => {
const at = latLngOf(point);
return at ? [[at.lat, at.lng, heightOf(Array.isArray(point) ? point[2] : null)]] : [];
});
const kept = thinLine(points, most);
const heights = kept.every(([, , height]) => height !== null);
return kept.map(([lat, lng, height]): RoutePoint => [lat, lng, heights ? height : null]);
}
/** A line of points joined straight, without heights: the stops of a way that is not planned. */
export function straightLine(points: ReadonlyArray<{ lat: number; lng: number }>): RoutePoint[] {
return thinLine(points, TOUR_ROUTE_LIMITS.line).map(({ lat, lng }): RoutePoint => [lat, lng, null]);
}
/** Metres along a straight line at each of its points. */
export function straightMetres(points: ReadonlyArray<{ lat: number; lng: number }>): number[] {
return cumulativeMetres(points.map(({ lat, lng }) => [lat, lng] as const)).map(Math.round);
}
/** The lowest and highest height of a line whose heights are known, else nulls. */
export function heightRange(line: readonly RoutePoint[]): { min: number | null; max: number | null } {
const heights = line.map(([, , height]) => height).filter((height): height is number => height !== null);
if (heights.length === 0 || heights.length !== line.length) return { min: null, max: null };
return { min: Math.min(...heights), max: Math.max(...heights) };
}
const POI_CATEGORIES: ReadonlySet<string> = new Set(TOUR_POI_CATEGORIES);
/** A kind of place along a tour, or null. */
export function poiCategoryOf(value: unknown): TourPoiCategory | null {
return typeof value === 'string' && POI_CATEGORIES.has(value) ? (value as TourPoiCategory) : null;
}
/** A stop with every field it does not name empty. */
export function stopOf(
fields: Pick<TourRouteStop, 'role' | 'lat' | 'lng' | 'atM'> & Partial<TourRouteStop>,
): TourRouteStop {
return {
kind: null,
name: null,
offM: null,
minutes: null,
clock: null,
stayMinutes: null,
stageEnd: false,
flag: null,
...fields,
};
}
/** The role of the point at `index` of `count` a way runs through. */
export function roleAt(index: number, count: number): TourRouteStop['role'] {
if (index === 0) return 'start';
return index === count - 1 ? 'end' : 'via';
}
/** A stop's name from a tool's answer: masked and cut, as a card shows it. */
export function stopName(value: unknown): string | null {
return clipped(value, 80);
}
/**
* A text from a call's arguments (a name the change will store): on one
* line and cut to `max`, never masked, so the card shows what is saved.
*/
export function argText(value: unknown, max: number): string | null {
if (typeof value !== 'string') return null;
const line = value.replace(/\s+/g, ' ').trim();
if (line === '') return null;
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
}
/**
* The stops in the order they lie along the way, at most as many as a card
* holds: the ends and the named points first kept, the places along it after.
*/
export function sortedStops(stops: readonly TourRouteStop[]): TourRouteStop[] {
const most = TOUR_ROUTE_LIMITS.stops + TOUR_ROUTE_LIMITS.along;
const own = stops.filter((stop) => stop.role !== 'along');
const kept = [...own.slice(0, TOUR_ROUTE_LIMITS.stops - 1), ...own.slice(-1)].slice(0, own.length);
const along = stops.filter((stop) => stop.role === 'along').slice(0, most - kept.length);
return [...kept, ...along].sort((a, b) => a.atM - b.atM);
}
/** A clock `minutes` after a start, `HH:MM`, wrapped past midnight; null without a start. */
export function clockAfter(start: string | null, minutes: number | null): string | null {
const begin = clockOf(start);
if (!begin || minutes === null) return null;
const total = Number(begin.slice(0, 2)) * 60 + Number(begin.slice(3, 5)) + minutes;
const within = ((total % 1440) + 1440) % 1440;
return `${String(Math.floor(within / 60)).padStart(2, '0')}:${String(within % 60).padStart(2, '0')}`;
}
/** The number of a trip day, from the trip card; null for a day it does not hold. */
export function dayNumberOf(card: TripCard, dayId: number | null): number | null {
if (dayId === null) return null;
const number = card.days.find((day) => day.id === dayId)?.number ?? null;
return number !== null && number >= 1 && number <= 999 ? number : null;
}
/** Whether two points lie within `metres` of each other. */
export function near(a: { lat: number; lng: number }, b: { lat: number; lng: number }, metres: number): boolean {
return haversineMetres(a.lat, a.lng, b.lat, b.lng) <= metres;
}
/** The rows of an array, each as a record. */
export function rowsIn(value: unknown): Row[] {
return Array.isArray(value) ? value.map(recordOf) : [];
}
/** A tour card with every field it does not name empty: no figures, no stretches, no schedule. */
export function routeCard(
fields: Pick<TourRouteCard, 'source' | 'activity' | 'line' | 'routed'> & Partial<TourRouteCard>,
): TourRouteCard {
return {
kind: 'tourRoute',
placeId: null,
name: null,
label: null,
distanceM: null,
ascentM: null,
descentM: null,
minEleM: null,
maxEleM: null,
minutes: null,
minutesBasis: null,
limit: null,
grades: null,
problem: null,
stretches: [],
stops: [],
stages: null,
skiScheme: null,
schedule: null,
warnings: [],
previous: null,
...fields,
};
}
/** How much a stretch tells: a SAC grade by its step, any other stretch the least. */
function weightOf(stretch: TourRouteStretch): number {
return stretch.facet === 'sac' ? stretch.step : 0;
}
/**
* At most `most` stretches, the hardest kept wherever they lie (among equals
* the first), in their order along the way: the end of a long route keeps its
* grades rather than reading as ungraded.
*/
export function hardestStretches(stretches: readonly TourRouteStretch[], most: number): TourRouteStretch[] {
if (stretches.length <= most) return [...stretches];
return [...stretches]
.sort((a, b) => weightOf(b) - weightOf(a))
.slice(0, most)
.sort((a, b) => a.fromM - b.fromM);
}
/** How a card too heavy to send is lightened, one step after the other, the least telling first. */
const LIGHTER: ReadonlyArray<(card: TourRouteCard) => TourRouteCard> = [
(card) => ({
...card,
line: thinLine(card.line, 200),
previous: card.previous ? { ...card.previous, line: thinLine(card.previous.line, 100) } : null,
}),
(card) => ({ ...card, stops: card.stops.filter((stop) => stop.role !== 'along' || stop.kind === 'highest') }),
(card) => ({ ...card, line: thinLine(card.line, 120), stretches: hardestStretches(card.stretches, 40) }),
];
/**
* The card as light as it must be to be sent (MAX_CARD_CHARS as JSON): a
* card of long names, a full line and every stretch is thinned first, then
* loses the places along the way, then more of its line and all but its
* hardest stretches. One that is still too heavy is refused by the registry.
*/
export function fitted(card: TourRouteCard): TourRouteCard {
let shown = card;
for (const lighter of LIGHTER) {
if (JSON.stringify(shown).length <= MAX_CARD_CHARS) return shown;
shown = lighter(shown);
}
return shown;
}
@@ -0,0 +1,234 @@
import type { CardReader, PreviewContext, ToolArgs } from '../card-types';
import { canonicalJson, clipped, finite, recordOf, type Row } from '../card-values';
import { hikeCard } from './tour-route.cards';
import {
argText,
climbOf,
fitted,
latLngOf,
lineOf,
metresOf,
minutesOf,
roleAt,
routeCard,
rowsIn,
sacStepOf,
sortedStops,
stopOf,
straightLine,
straightMetres,
whole,
} from './tour-card-values';
import {
hikingPreferencesOf,
TOUR_ROUTE_LIMITS,
TOURS_MAX_HIKING_DIFFICULTY,
type AssistantChangeEffect,
type AssistantChangePreview,
type HikingPreferences,
type TourRouteCard,
} from '@trek/shared';
/**
* The cards inside the change cards of the tours: a held create_hike shows
* the way the proposal of the same points showed (the planner keeps the
* plan for a quarter of an hour, so the saved line is that line), a held
* create_city_walk its stations joined straight (its way is planned on
* save), a held update_tour the new way beside the one it replaces. A way
* that could not be planned now is drawn straight with the reason.
*/
/** What a held hike change looked up: the plan of its way, and for a change the saved tour it changes. */
export interface TourLoaded {
plan: unknown;
saved: unknown;
}
/** The fields propose_hike takes of a create_hike, without the places along the way the change card does not show. */
const PLAN_FIELDS = ['name', 'waypoints', 'max_hiking_difficulty', 'hiking_preferences', 'round_trip'] as const;
/** The proposal a held create_hike stands for: the same points, limit, preferences and day. */
export function planArgsOf(args: ToolArgs): ToolArgs {
const plan: ToolArgs = { along: false };
for (const key of [...PLAN_FIELDS, 'day_id', 'start_time'] as const) {
if (args[key] !== undefined) plan[key] = args[key];
}
return plan;
}
/** create_hike: plans the way through the points (a proposal of a moment ago is answered from the planner's memo). */
export async function loadCreateHike(args: ToolArgs, read: CardReader): Promise<TourLoaded> {
return { plan: await read('propose_hike', planArgsOf(args)), saved: null };
}
/** A saved hike's limit and preferences, as update_tour keeps them when the change does not name them. */
function keptSettings(tour: Row): { limit: number; preferences: HikingPreferences | null } {
const limit = sacStepOf(tour.max_hiking_difficulty) ?? TOURS_MAX_HIKING_DIFFICULTY;
return { limit, preferences: hikingPreferencesOf(recordOf(tour.hiking_preferences) as HikingPreferences) };
}
/**
* Whether update_tour plans the hike's way anew, as it decides: new points,
* a hike without a line yet, or a limit or preferences other than the saved.
*/
export function replans(args: ToolArgs, saved: unknown): boolean {
const plan = recordOf(saved);
const kept = keptSettings(recordOf(plan.tour));
const limit = sacStepOf(args.max_hiking_difficulty) ?? kept.limit;
const preferences =
args.hiking_preferences === undefined
? kept.preferences
: hikingPreferencesOf(recordOf(args.hiking_preferences) as HikingPreferences);
return (
args.waypoints !== undefined ||
plan.line === null ||
limit !== kept.limit ||
canonicalJson(preferences) !== canonicalJson(kept.preferences)
);
}
/**
* update_tour: the saved tour, and where the change plans the way anew the
* way it will plan, through the new points or the saved ones under the saved
* limit and preferences where the change leaves them, so the planner's memo
* answers it and the card shows the line the change will save.
*/
export async function loadUpdateTour(args: ToolArgs, read: CardReader): Promise<TourLoaded | null> {
const placeId = whole(args.placeId, 1, Number.MAX_SAFE_INTEGER);
if (placeId === null) return null;
const saved = await read('get_tour_plan', { placeId });
if (!saved || !replans(args, saved)) return { plan: null, saved };
const tour = recordOf(recordOf(saved).tour);
const kept = keptSettings(tour);
const points = args.waypoints ?? rowsIn(recordOf(saved).waypoints).map(({ lat, lng }) => ({ lat, lng }));
const plan = await read('propose_hike', {
name: args.name ?? tour.name,
waypoints: points,
max_hiking_difficulty: args.max_hiking_difficulty ?? kept.limit,
hiking_preferences: args.hiking_preferences === undefined ? kept.preferences : args.hiking_preferences,
along: false,
});
return { plan, saved };
}
/** Why a held create is drawn straight: the planner's own reason where it refuses the hike, else "planned on save". */
function heldProblem(card: TourRouteCard | null): TourRouteCard['problem'] {
const why = card?.problem?.why;
const refuses = why !== undefined && why !== 'router_unavailable' && why !== 'not_planned';
return refuses && card?.problem ? card.problem : { why: 'not_planned', position: null, snappedM: null };
}
/** The points of a held hike or walk joined straight, as stops along the straight line. */
function straightCard(points: readonly Row[], activity: 'hike' | 'walk', fields: Partial<TourRouteCard>) {
const stops = points.flatMap((point) => {
const at = latLngOf(point);
return at ? [{ ...at, point }] : [];
});
if (stops.length < 2) return null;
const along = straightMetres(stops);
return routeCard({
source: 'pending',
activity,
routed: false,
problem: { why: 'not_planned', position: null, snappedM: null },
line: straightLine(stops),
stops: sortedStops(
stops.map(({ lat, lng, point }, index) =>
stopOf({
role: roleAt(index, stops.length),
kind: activity === 'walk' ? 'station' : null,
name: argText(point.name, 80),
lat,
lng,
atM: Math.min(along[index] ?? 0, 5_000_000),
stayMinutes: minutesOf(point.stay_minutes),
}),
),
),
...fields,
});
}
/** The points a hike is asked through, a round trip's way back included. */
function hikePoints(args: ToolArgs): Row[] {
const points = rowsIn(args.waypoints);
return args.round_trip === true && points[0] ? [...points, points[0]] : points;
}
/** create_hike: the planned way as the proposal drew it, else the points straight. */
export function createHikeCard(ctx: PreviewContext, args: ToolArgs, loaded: TourLoaded | null): TourRouteCard | null {
const name = argText(args.name, 120);
const planned = loaded ? hikeCard(loaded.plan, args, ctx.card, 'pending') : null;
if (planned?.routed) return fitted({ ...planned, name: name ?? planned.name, label: null });
const straight = straightCard(hikePoints(args), 'hike', {
name,
limit: sacStepOf(args.max_hiking_difficulty) ?? TOURS_MAX_HIKING_DIFFICULTY,
problem: heldProblem(planned),
});
return straight ? fitted(straight) : null;
}
/** create_city_walk: the stations joined straight; the way on foot is planned when the walk is saved. */
export function createCityWalkCard(_ctx: PreviewContext, args: ToolArgs): TourRouteCard | null {
const straight = straightCard(rowsIn(args.stations), 'walk', { name: argText(args.name, 120) });
return straight ? fitted(straight) : null;
}
/** The saved way a change replaces, in brief: its line flat, its length, climb and time. */
function previousOf(saved: unknown): TourRouteCard['previous'] {
const tour = recordOf(recordOf(saved).tour);
const line = lineOf(recordOf(recordOf(saved).line).preview, TOUR_ROUTE_LIMITS.previous);
const distance = finite(tour.distance);
const distanceM = distance === null ? null : metresOf(distance * 1000);
if (line.length < 2 || distanceM === null) return null;
return {
line: line.map(([lat, lng]): [number, number] => [lat, lng]),
distanceM,
ascentM: climbOf(tour.elevation_gain),
minutes: minutesOf(tour.planned_duration_minutes) ?? minutesOf(tour.duration),
};
}
/** update_tour: the new way beside the saved one; a change of name or words alone draws nothing. */
export function updateTourCard(ctx: PreviewContext, args: ToolArgs, loaded: TourLoaded | null): TourRouteCard | null {
if (!loaded?.saved || !replans(args, loaded.saved)) return null;
const tour = recordOf(recordOf(loaded.saved).tour);
const placeId = whole(args.placeId, 1, Number.MAX_SAFE_INTEGER);
const name = argText(args.name, 120) ?? clipped(tour.name, 120);
const previous = previousOf(loaded.saved);
const planned = hikeCard(loaded.plan, args, ctx.card, 'pending');
if (planned?.routed) return fitted({ ...planned, placeId, name, label: null, previous });
const points = args.waypoints === undefined ? rowsIn(recordOf(loaded.saved).waypoints) : rowsIn(args.waypoints);
const straight = straightCard(points, 'hike', { placeId, name, previous, problem: heldProblem(planned) });
return straight ? fitted(straight) : null;
}
/** create_hike on a day: the hike lands on it at its first point, for the day's preview on the map. */
export function createHikeEffect(ctx: PreviewContext, args: ToolArgs): AssistantChangeEffect | null {
const dayId = whole(args.day_id, 1, Number.MAX_SAFE_INTEGER);
const start = latLngOf(rowsIn(args.waypoints)[0]);
if (dayId === null || !ctx.card.days.some((day) => day.id === dayId)) return null;
const name = argText(args.name, 200);
return {
kind: 'add',
dayId,
fromDayId: null,
placeId: null,
assignmentId: null,
place: start && name ? { name, lat: start.lat, lng: start.lng, categoryId: null } : null,
};
}
/**
* The card of an applied create: the one the change showed, now naming the
* tour it created, so the receipt opens it. Null when the change showed none
* or the answer names no tour.
*/
export function appliedTourCard(input: {
result: unknown;
preview: AssistantChangePreview | null;
}): TourRouteCard | null {
const shown = input.preview?.card;
const placeId = whole(recordOf(recordOf(input.result).tour).place_id, 1, Number.MAX_SAFE_INTEGER);
return shown?.kind === 'tourRoute' && placeId !== null ? { ...shown, placeId } : null;
}
@@ -1,6 +1,6 @@
import type { Described, PreviewContext, ToolArgs } from '../card-types';
import { recordOf } from '../card-values';
import { clip, id, Rows, text } from '../preview-format';
import { clip, dayLabel, id, Rows, text } from '../preview-format';
import { TOURS_MAX_HIKING_DIFFICULTY } from '@trek/shared';
/**
@@ -43,6 +43,13 @@ function hours(value: unknown): string {
return `${Math.floor(value / 60)}:${String(value % 60).padStart(2, '0')} h`;
}
/** The day a hike is put on, with the start of its visit when one is given; nothing for a day the trip lacks. */
function onDay(ctx: PreviewContext, dayId: number | null, start: unknown): string {
if (dayId === null || !ctx.card.days.some((day) => day.id === dayId)) return '';
const at = typeof start === 'string' && /^\d{2}:\d{2}$/.test(start) ? `, ${start}` : '';
return `${dayLabel(ctx, dayId)}${at}`;
}
/** The first name a GPX text gives, a track's or the file's own. */
function gpxName(gpx: unknown): string {
if (typeof gpx !== 'string') return '';
@@ -53,12 +60,14 @@ function gpxName(gpx: unknown): string {
export const TOUR_DESCRIBERS = {
create_hike: (ctx, args) => {
const day = onDay(ctx, id(args, 'day_id'), args.start_time);
const rows = new Rows()
.add('route', routeOf(ctx, args.waypoints, args.round_trip))
.add('day', day)
.add('difficulty', grade(args.max_hiking_difficulty ?? TOURS_MAX_HIKING_DIFFICULTY))
.add('time', hours(args.planned_duration_minutes))
.add('description', text(args.description));
return { subject: text(args.name), rows, dayId: null, placeIds: [] };
return { subject: text(args.name), rows, dayId: day ? id(args, 'day_id') : null, placeIds: [] };
},
create_city_walk: (ctx, args) => {
const rows = new Rows().add('route', routeOf(ctx, args.stations, false)).add('description', text(args.description));
@@ -82,6 +91,8 @@ export const TOUR_DESCRIBERS = {
placeIds: placeId === null ? [] : [placeId],
};
},
// A tour of another trip copied in; its own name stays unless the copy is given one.
take_over_tour: (_ctx, args) => ({ subject: text(args.name), rows: new Rows(), dayId: null, placeIds: [] }),
import_tour_gpx: (_ctx, args) => {
const rows = new Rows().add('type', text(args.tour_type));
return { subject: text(args.file_name) || gpxName(args.gpx) || 'GPX', rows, dayId: null, placeIds: [] };
@@ -0,0 +1,49 @@
import type { AnswerInput } from '../card-types';
import { clipped, finite, recordOf } from '../card-values';
import { climbOf, metresOf, minutesOf, rowsIn, sacStepOf, whole } from './tour-card-values';
import { TOUR_LIST_ROWS, tourTypeKeySchema, type TourListCard, type TourListRow } from '@trek/shared';
/**
* The tours of a trip (list_tours) or of one of its days (find_tours_for_day)
* as rows: what each tour is, how long and how high, its stages, and the
* days it is on. A tour without a name or a type this build knows is left
* out; an empty list has no card.
*/
/** A saved tour as a row, or null when it cannot be one. */
function rowOf(raw: unknown): TourListRow | null {
const tour = recordOf(raw);
const placeId = whole(tour.place_id, 1, Number.MAX_SAFE_INTEGER);
const name = clipped(tour.name, 120);
const activity = tourTypeKeySchema.safeParse(tour.tour_type);
if (placeId === null || !name || !activity.success) return null;
const distance = finite(tour.distance);
const estimate = recordOf(tour.time_estimate);
const days = rowsIn(tour.days)
.map((day) => whole(day.day_number, 1, 999))
.filter((number): number is number => number !== null);
return {
placeId,
name,
activity: activity.data,
limit: sacStepOf(tour.max_hiking_difficulty),
distanceM: distance === null ? null : metresOf(distance * 1000),
ascentM: climbOf(tour.elevation_gain),
minutes: minutesOf(tour.planned_duration_minutes) ?? minutesOf(tour.duration) ?? minutesOf(estimate.minutes),
stages: whole(tour.stage_count, 2, 60),
days: [...new Set(days)].sort((a, b) => a - b).slice(0, 10),
};
}
/** list_tours, find_tours_for_day: the first rows and how many more there are. */
export function tourListCard({ value, args }: AnswerInput): TourListCard | null {
const tours = recordOf(value).tours;
const rows = (Array.isArray(tours) ? tours : []).map(rowOf).filter((row): row is TourListRow => row !== null);
if (rows.length === 0) return null;
return {
kind: 'tourList',
dayId: whole(args.dayId, 1, Number.MAX_SAFE_INTEGER),
more: Math.min(rows.length - Math.min(rows.length, TOUR_LIST_ROWS), 10_000),
rows: rows.slice(0, TOUR_LIST_ROWS),
};
}
@@ -0,0 +1,344 @@
import type { TripCard } from '../../assistant-context';
import type { AnswerInput } from '../card-types';
import { clipped, clockOf, finite, recordOf, type Row } from '../card-values';
import {
climbOf,
clockAfter,
dayNumberOf,
fitted,
hardestStretches,
heightOf,
heightRange,
latLngOf,
lineOf,
metresOf,
minutesOf,
near,
poiCategoryOf,
roleAt,
routeCard,
rowsIn,
sacStepOf,
sortedStops,
stopName,
stopOf,
straightLine,
straightMetres,
whole,
type RoutePoint,
} from './tour-card-values';
import {
cumulativeMetres,
HIKE_UNROUTED_REASONS,
legMinutes,
nearestAlongM,
TOUR_ROUTE_LIMITS,
TOUR_WARNING_KINDS,
tourTypeKeySchema,
type TourRouteCard,
type TourRouteSchedule,
type TourRouteStop,
type TourRouteStretch,
} from '@trek/shared';
/**
* The cards of a hike the assistant proposed (propose_hike) and of a saved
* tour it read (get_tour_plan), built from the tool's raw answer. A proposal
* draws its line only when the tool added it for drawing (the assistant
* always asks), so without `line_geometry` there is no card. A way that was
* not planned is drawn straight between the points it was asked through,
* with the reason.
*/
/** How close a summit along the way must lie to the highest point to stand for it. */
const SAME_SUMMIT_M = 150;
/** How close a saved tour's last point must lie to the end of its line to be that end (a loop's included). */
const LOOP_END_M = 60;
const UNROUTED: ReadonlySet<string> = new Set(HIKE_UNROUTED_REASONS);
const WARNINGS: ReadonlySet<string> = new Set(TOUR_WARNING_KINDS);
/** Graded stretches merged where the step repeats, at most the card's share, the hardest kept. */
export function sacStretches(segments: unknown): TourRouteStretch[] {
const merged: Array<Extract<TourRouteStretch, { facet: 'sac' }>> = [];
for (const segment of rowsIn(segments)) {
const step = sacStepOf(segment.sac);
const fromM = metresOf(segment.from_m);
const toM = metresOf(segment.to_m);
if (step === null || fromM === null || toM === null || toM < fromM) continue;
const last = merged.at(-1);
if (last && last.step === step && fromM <= last.toM) last.toM = Math.max(last.toM, toM);
else merged.push({ facet: 'sac', fromM, toM, step });
}
return hardestStretches(merged, TOUR_ROUTE_LIMITS.stretches);
}
/** The walking minutes to a spot along the way, read between the points whose minutes are known. */
function minutesAt(stops: readonly TourRouteStop[], atM: number): number | null {
const timed = stops.filter((stop) => stop.minutes !== null);
const after = timed.findIndex((stop) => stop.atM >= atM);
if (after < 0) return null;
const b = timed[after];
const a = after > 0 ? timed[after - 1] : null;
if (!b || b.minutes === null) return null;
if (!a || a.minutes === null || b.atM === a.atM) return b.atM === atM ? b.minutes : null;
return Math.round(a.minutes + ((b.minutes - a.minutes) * (atM - a.atM)) / (b.atM - a.atM));
}
/** The day of a proposal: its date, sun, weather by the hour; null without a date. */
function scheduleOf(value: unknown, card: TripCard): TourRouteSchedule | null {
const schedule = recordOf(value);
const date = typeof schedule.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(schedule.date) ? schedule.date : null;
if (!date) return null;
const dayId = whole(schedule.day_id, 1, Number.MAX_SAFE_INTEGER);
const hours = rowsIn(schedule.hours).flatMap((hour) => {
const at = whole(hour.hour, 0, 23);
const main = clipped(hour.main, 20);
const tempC = whole(hour.temp_c, -80, 60);
if (at === null || !main || tempC === null) return [];
return [{ hour: at, main, tempC, rainPct: whole(hour.rain_pct, 0, 100), windKmh: whole(hour.wind_kmh, 0, 400) }];
});
return {
date,
dayId,
dayNumber: dayNumberOf(card, dayId),
start: clockOf(schedule.start),
arrive: clockOf(schedule.arrive),
nextDay: schedule.next_day === true,
sunrise: clockOf(schedule.sunrise),
sunset: clockOf(schedule.sunset),
latestStart: clockOf(schedule.latest_start),
weather: schedule.weather === 'forecast' || schedule.weather === 'climate' ? schedule.weather : null,
hours: hours.slice(0, TOUR_ROUTE_LIMITS.hours),
};
}
/** What to look out for, as the proposal names it. */
function warningsOf(value: unknown): TourRouteCard['warnings'] {
return rowsIn(value)
.flatMap((warning): TourRouteCard['warnings'] => {
const kind = typeof warning.kind === 'string' && WARNINGS.has(warning.kind) ? warning.kind : null;
if (!kind) return [];
return [{ kind: kind as TourRouteCard['warnings'][number]['kind'], at: clockOf(warning.at), count: null }];
})
.slice(0, TOUR_ROUTE_LIMITS.warnings);
}
/** The points a proposal was asked through, as stops. */
function waypointStops(waypoints: readonly Row[], start: string | null): TourRouteStop[] {
return waypoints.flatMap((point, index) => {
const at = latLngOf(point);
if (!at) return [];
const minutes = minutesOf(point.minutes);
return [
stopOf({
role: roleAt(index, waypoints.length),
kind: poiCategoryOf(point.poi_category),
name: stopName(point.name),
...at,
atM: metresOf(point.at_m) ?? 0,
minutes,
clock: clockOf(point.arrival) ?? clockAfter(start, minutes),
}),
];
});
}
/** The places along a proposed way and its highest point, timed between the points around them. */
function alongStops(value: Row, own: readonly TourRouteStop[], start: string | null): TourRouteStop[] {
const along = rowsIn(value.along)
.slice(0, TOUR_ROUTE_LIMITS.along)
.flatMap((place) => {
const at = latLngOf(place);
const atM = metresOf(place.at_m);
if (!at || atM === null) return [];
const offM = whole(place.off_m, 0, Number.MAX_SAFE_INTEGER);
return [
stopOf({
role: 'along',
kind: poiCategoryOf(place.category),
name: stopName(place.name),
...at,
atM,
offM: offM === null ? null : Math.min(offM, 2_000),
}),
];
});
const high = recordOf(value.high_point);
const top = latLngOf(high);
const topAt = metresOf(high.at_m);
const summit = [...own, ...along].some(
(stop) => stop.kind === 'peak' && top !== null && near(stop, top, SAME_SUMMIT_M),
);
const highest =
top && topAt !== null && !summit ? [stopOf({ role: 'along', kind: 'highest', ...top, atM: topAt })] : [];
return [...along, ...highest].map((stop) => {
const minutes = minutesAt(own, stop.atM);
return { ...stop, minutes, clock: clockAfter(start, minutes) };
});
}
/** A proposal whose way was not planned: its points joined straight, with why. */
function unroutedHike(value: Row, args: Row, source: TourRouteCard['source']): TourRouteCard | null {
const asked = rowsIn(value.waypoints).length > 0 ? rowsIn(value.waypoints) : rowsIn(args.waypoints);
const points = asked.flatMap((point) => {
const at = latLngOf(point);
return at ? [{ ...at, name: stopName(point.name), kind: poiCategoryOf(point.poi_category) }] : [];
});
if (points.length < 2) return null;
const route = recordOf(value.route);
const unreachable = recordOf(value.unreachable);
const why = typeof route.why === 'string' && UNROUTED.has(route.why) ? route.why : 'not_planned';
const along = straightMetres(points);
return routeCard({
source,
activity: 'hike',
name: clipped(value.name ?? args.name, 120),
label: clipped(value.variant_label ?? args.variant_label, 40),
limit: sacStepOf(value.max_hiking_difficulty ?? args.max_hiking_difficulty),
routed: false,
problem: {
why: why as NonNullable<TourRouteCard['problem']>['why'],
position: whole(route.position, 1, 1_000),
snappedM: whole(unreachable.snapped_m, 0, 1_000_000),
},
line: straightLine(points),
stops: sortedStops(
points.map((point, index) =>
stopOf({ role: roleAt(index, points.length), ...point, atM: Math.min(along[index] ?? 0, 5_000_000) }),
),
),
});
}
/**
* The card of a hike proposal: the way with its figures, grades, stops and
* day. `source` is `pending` when it shows a held create of the same hike.
*/
export function hikeCard(
value: unknown,
args: Row,
card: TripCard,
source: TourRouteCard['source'],
): TourRouteCard | null {
const plan = recordOf(value);
if (recordOf(plan.route).planned !== true) return unroutedHike(plan, args, source);
const line = lineOf(plan.line_geometry);
if (line.length < 2) return null;
const sac = plan.sac === null || plan.sac === undefined ? null : recordOf(plan.sac);
const start = clockOf(plan.start_time);
const own = waypointStops(rowsIn(plan.waypoints), start);
const basis = plan.duration_basis === 'din33466' || plan.duration_basis === 'router' ? plan.duration_basis : null;
const unknown = finite(sac?.unknown_share);
return routeCard({
source,
activity: 'hike',
name: clipped(plan.name, 120),
label: clipped(plan.variant_label, 40),
distanceM: metresOf(plan.distance_m),
ascentM: climbOf(plan.ascent_m),
descentM: climbOf(plan.descent_m),
minEleM: heightOf(plan.min_ele_m),
maxEleM: heightOf(plan.max_ele_m),
minutes: minutesOf(plan.duration_minutes),
minutesBasis: basis,
limit: sacStepOf(plan.max_hiking_difficulty),
grades: sac ? { highest: sacStepOf(sac.highest), unknownShare: Math.min(1, Math.max(0, unknown ?? 0)) } : null,
routed: true,
line,
stretches: sacStretches(sac?.segments),
stops: sortedStops([...own, ...alongStops(plan, own, start)]),
schedule: scheduleOf(plan.schedule, card),
warnings: warningsOf(plan.warnings),
});
}
/** propose_hike: the proposal as a card. */
export function proposalCard({ value, args, card }: AnswerInput): TourRouteCard | null {
const proposal = hikeCard(value, args, card, 'proposal');
return proposal ? fitted(proposal) : null;
}
/**
* The control points of a saved tour (a city walk's stations), placed along
* its line and timed by the signposts' rule; shaping points are left out.
*/
function savedStops(
waypoints: readonly Row[],
line: readonly RoutePoint[],
total: number | null,
walk: boolean,
): TourRouteStop[] {
const points = waypoints.filter((point) => point.shaping !== true && latLngOf(point) !== null);
const length = line.length >= 2 ? (cumulativeMetres(line).at(-1) ?? 0) : 0;
const end = line.at(-1);
// A tour still waiting for its way: its points lie along the straight line between them.
const straight = line.length < 2 ? straightMetres(points.flatMap((point) => latLngOf(point) ?? [])) : null;
const atMs = points.map((point, index) => {
const at = latLngOf(point);
if (straight) return straight[index] ?? 0;
if (!at) return 0;
// A loop ends where it started: its end lies at the end of the line, not at its first pass.
if (index === points.length - 1 && end && near(at, { lat: end[0], lng: end[1] }, LOOP_END_M)) {
return Math.round(length);
}
return Math.round(nearestAlongM(line, at.lat, at.lng));
});
const legs = total !== null && line.length >= 2 && points.length >= 2 ? legMinutes(line, atMs, total) : null;
let walked = 0;
return points.map((point, index) => {
const at = latLngOf(point) ?? { lat: 0, lng: 0 };
if (index > 0 && legs) walked += legs[index - 1] ?? 0;
return stopOf({
role: roleAt(index, points.length),
kind: walk ? 'station' : poiCategoryOf(point.poi_category),
name: stopName(point.name ?? point.linked_place_name),
...at,
atM: Math.min(atMs[index] ?? 0, 5_000_000),
minutes: legs ? walked : null,
stayMinutes: minutesOf(point.stay_minutes),
stageEnd: point.stage_end === true,
});
});
}
/** get_tour_plan: a saved tour of the trip as a card, its line the saved one in brief. */
export function savedTourCard({ value }: AnswerInput): TourRouteCard | null {
const plan = recordOf(value);
const tour = recordOf(plan.tour);
const activity = tourTypeKeySchema.safeParse(tour.tour_type);
const placeId = whole(tour.place_id, 1, Number.MAX_SAFE_INTEGER);
if (!activity.success || placeId === null) return null;
const saved = lineOf(recordOf(plan.line).preview);
const waypoints = rowsIn(plan.waypoints);
const planned = minutesOf(tour.planned_duration_minutes);
const minutes = planned ?? minutesOf(tour.duration);
const distance = finite(tour.distance);
const routed = saved.length >= 2;
const line = routed ? saved : straightLine(waypoints.flatMap((point) => latLngOf(point) ?? []));
if (line.length < 2) return null;
const heights = heightRange(line);
const walked = routed ? (minutesOf(tour.duration) ?? minutes) : null;
const stops = savedStops(waypoints, routed ? line : [], walked, activity.data === 'walk');
return fitted(
routeCard({
source: 'saved',
activity: activity.data,
placeId,
name: clipped(tour.name, 120),
distanceM: distance === null ? null : metresOf(distance * 1000),
ascentM: climbOf(tour.elevation_gain),
descentM: climbOf(tour.elevation_loss),
minEleM: heights.min,
maxEleM: heights.max,
minutes,
minutesBasis: minutes === null ? null : 'planned',
limit: sacStepOf(tour.max_hiking_difficulty),
routed,
problem: routed ? null : { why: 'not_planned', position: null, snappedM: null },
line,
stops: sortedStops(stops),
stages: whole(tour.stage_count, 2, 60),
}),
);
}
@@ -0,0 +1,277 @@
import type { AnswerInput } from '../card-types';
import { clockOf, recordOf, type Row } from '../card-values';
import {
climbOf,
coord,
dayNumberOf,
fitted,
latLngOf,
lineOf,
metresOf,
minutesOf,
roleAt,
routeCard,
rowsIn,
sortedStops,
stopName,
stopOf,
straightLine,
whole,
type RoutePoint,
} from './tour-card-values';
import {
haversineMetres,
skiColourSchemeSchema,
skiRunDifficultySchema,
skiSegmentKindSchema,
TOUR_ROUTE_LIMITS,
type TourRouteCard,
type TourRouteStop,
type TourRouteStretch,
} from '@trek/shared';
/**
* The cards of the other ways an assistant plans: a city walk
* (propose_city_walk), a paddle tour's legs (plan_paddle_route) and a ski
* tour's legs (plan_ski_route), each drawn as one route. The legs of a plan
* are joined into one line, each leg's stretches placed along the whole; a
* leg that could not be planned is counted in a warning and leaves a gap.
*/
/** A station flagged by what goes wrong at it, the first conflict the proposal names. */
function flagOf(conflicts: unknown): TourRouteStop['flag'] {
const first = Array.isArray(conflicts) ? conflicts[0] : null;
if (first === 'late' || first === 'after_frame') return first;
return typeof first === 'string' && first.startsWith('close') ? 'closed' : null;
}
/** Why a walk's way was estimated instead of planned, as the card names it; a router that did not answer else. */
const WALK_PROBLEMS: Readonly<Record<string, 'point_off_the_way' | 'ferry_only'>> = {
station_off_the_way: 'point_off_the_way',
ferry_only: 'ferry_only',
};
function walkProblem(estimate: Row): TourRouteCard['problem'] {
const why = (typeof estimate.why === 'string' ? WALK_PROBLEMS[estimate.why] : undefined) ?? 'router_unavailable';
return { why, position: whole(estimate.position, 1, 1_000), snappedM: null };
}
/** propose_city_walk: the stations in order with their times, the routed way when there is one. */
export function cityWalkCard({ value, args, card }: AnswerInput): TourRouteCard | null {
const walk = recordOf(value);
const stations = rowsIn(walk.stations).filter((station) => latLngOf(station) !== null);
if (stations.length < 2) return null;
const totals = recordOf(walk.totals);
const frame = recordOf(walk.frame);
let metres = 0;
let minutes = 0;
const stops = stations.map((station, index) => {
const leg = recordOf(station.walk_from_previous);
metres += metresOf(leg.metres) ?? 0;
minutes += minutesOf(leg.minutes) ?? 0;
return stopOf({
role: roleAt(index, stations.length),
kind: 'station',
name: stopName(station.name),
...(latLngOf(station) ?? { lat: 0, lng: 0 }),
atM: Math.min(metres, 5_000_000),
minutes: Math.min(minutes, 86_400),
clock: clockOf(station.arrival),
stayMinutes: minutesOf(station.stay_minutes),
flag: flagOf(station.conflicts),
});
});
const routedLine = lineOf(walk.line_geometry);
const routed = routedLine.length >= 2;
const date = typeof walk.date === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(walk.date) ? walk.date : null;
const dayId = whole(args.day_id, 1, Number.MAX_SAFE_INTEGER);
const proposal = routeCard({
source: 'proposal',
activity: 'walk',
distanceM: metresOf(totals.walk_m),
minutes: minutesOf(totals.total_minutes),
minutesBasis: 'stations',
routed,
problem: routed ? null : walkProblem(recordOf(walk.estimate)),
line: routed ? routedLine : straightLine(stops),
stops: sortedStops(stops),
schedule: date
? {
date,
dayId,
dayNumber: dayNumberOf(card, dayId),
start: clockOf(frame.start_time),
arrive: clockOf(totals.ends_at),
nextDay: false,
sunrise: null,
sunset: null,
latestStart: null,
weather: null,
hours: [],
}
: null,
});
return fitted(proposal);
}
/** A planned leg of a plan: its line for drawing, its length, its stretches along it. */
interface PlannedLeg {
line: RoutePoint[];
distanceM: number;
segments: Row[];
leg: Row;
}
/** The legs of a plan in order: the planned ones with their lines, null for each that failed. */
function legsOf(value: unknown): Array<PlannedLeg | null> {
return rowsIn(recordOf(value).legs).map((leg) => {
const line = lineOf(leg.line_geometry);
if (leg.status !== 'ok' || line.length < 2) return null;
return { line, distanceM: metresOf(leg.distance_m) ?? 0, segments: rowsIn(leg.segments), leg };
});
}
/** One leg's stretches placed along the whole route. */
type StretchOf = (segment: Row, fromM: number, toM: number) => TourRouteStretch | null;
/** The metres straight between two points of a line. */
function straightBetween(a: RoutePoint, b: RoutePoint): number {
return Math.round(haversineMetres(a[0], a[1], b[0], b[1]));
}
/**
* The legs joined into one route: one line (heights kept only when every leg
* has them), the stretches each leg names placed along the whole, the ends of
* the legs as stops, and a warning counting the legs that failed. Where legs
* failed between planned ones the line jumps straight from the last planned
* end to the next planned start: that jump is marked as a gap and counted in
* the length, so what follows sits where it lies along the line.
*/
function joined(args: Row, legs: Array<PlannedLeg | null>, stretchOf: StretchOf) {
const asked = rowsIn(args.legs);
const stretches: TourRouteStretch[] = [];
const points: RoutePoint[] = [];
const stops: TourRouteStop[] = [];
let offset = 0;
let failedSince = false;
const lastPlanned = legs.reduce((last, leg, index) => (leg ? index : last), -1);
const first = latLngOf(asked[0]?.from);
if (first) stops.push(stopOf({ role: 'start', ...first, atM: 0 }));
legs.forEach((planned, index) => {
const lastEnd = points.at(-1);
if (planned && lastEnd && failedSince) {
const jump = straightBetween(lastEnd, planned.line[0] ?? lastEnd);
const toM = Math.min(offset + jump, 5_000_000);
stretches.push({ facet: 'mark', fromM: offset, toM, kind: 'gap' });
offset = toM;
}
if (planned) {
failedSince = false;
points.push(...planned.line);
for (const segment of planned.segments) {
const fromM = metresOf(segment.from_m);
const toM = metresOf(segment.to_m);
if (fromM === null || toM === null) continue;
const stretch = stretchOf(segment, Math.min(offset + fromM, 5_000_000), Math.min(offset + toM, 5_000_000));
if (stretch) stretches.push(stretch);
}
offset = Math.min(offset + planned.distanceM, 5_000_000);
} else {
failedSince = true;
}
const end = latLngOf(asked[index]?.to);
// The end of a failed leg between planned ones lies on the straight jump, as far along it as from where it starts.
const pastEnd =
!planned && index < lastPlanned && end && lastEnd ? straightBetween(lastEnd, [end.lat, end.lng, null]) : 0;
const atM = Math.min(offset + pastEnd, 5_000_000);
if (end) stops.push(stopOf({ role: index === legs.length - 1 ? 'end' : 'via', ...end, atM }));
});
const heights = points.every(([, , height]) => height !== null);
const line = lineOf(
points.map(([lat, lng, height]) => [coord(lat), coord(lng), heights ? height : null]),
TOUR_ROUTE_LIMITS.line,
);
const failed = legs.filter((leg) => leg === null).length;
return {
line,
distanceM: offset,
stretches: mergedStretches(stretches),
stops: sortedStops(stops),
warnings: failed > 0 ? [{ kind: 'legs_failed' as const, at: null, count: Math.min(failed, 50) }] : [],
};
}
/** Stretches of one kind that run on into each other joined, at most the card's share. */
function mergedStretches(stretches: readonly TourRouteStretch[]): TourRouteStretch[] {
const merged: TourRouteStretch[] = [];
for (const stretch of stretches) {
const last = merged.at(-1);
if (last && sameKind(last, stretch) && stretch.fromM <= last.toM) last.toM = Math.max(last.toM, stretch.toM);
else merged.push({ ...stretch });
}
return merged.slice(0, TOUR_ROUTE_LIMITS.stretches);
}
function sameKind(a: TourRouteStretch, b: TourRouteStretch): boolean {
if (a.facet === 'ski' && b.facet === 'ski') return a.kind === b.kind && a.difficulty === b.difficulty;
if (a.facet === 'mark' && b.facet === 'mark') return a.kind === b.kind;
return false;
}
const PADDLE_MARKS: Readonly<Record<string, 'portage' | 'rapids' | 'gap'>> = {
portage: 'portage',
rapids: 'rapids',
gap: 'gap',
};
/** plan_paddle_route: the legs on the water as one route, carried and rough stretches marked. */
export function paddleCard({ value, args }: AnswerInput): TourRouteCard | null {
const legs = legsOf(value);
if (!legs.some((leg) => leg !== null)) return null;
const route = joined(args, legs, (segment, fromM, toM) => {
const kind = typeof segment.kind === 'string' ? PADDLE_MARKS[segment.kind] : undefined;
return kind ? { facet: 'mark', fromM, toM, kind } : null;
});
if (route.line.length < 2) return null;
return fitted(routeCard({ source: 'plan', activity: 'kayak', routed: true, ...route }));
}
/** The ways up and down a ski area that are no run: drawn dotted as lifts. */
const LIFTS: ReadonlySet<string> = new Set(['lift', 'lift_down', 'rail']);
/** plan_ski_route: the legs on runs, trails and lifts as one route, with their figures and the area's colours. */
export function skiCard({ value, args }: AnswerInput): TourRouteCard | null {
const plan = recordOf(value);
const legs = legsOf(plan);
const planned = legs.filter((leg): leg is PlannedLeg => leg !== null);
if (planned.length === 0) return null;
const route = joined(args, legs, (segment, fromM, toM) => {
if (typeof segment.kind === 'string' && LIFTS.has(segment.kind)) return { facet: 'mark', fromM, toM, kind: 'lift' };
const kind = skiSegmentKindSchema.safeParse(segment.kind);
if (!kind.success) return null;
const difficulty = skiRunDifficultySchema.safeParse(segment.difficulty);
return { facet: 'ski', fromM, toM, kind: kind.data, difficulty: difficulty.success ? difficulty.data : null };
});
if (route.line.length < 2) return null;
const sum = (read: (leg: Row) => unknown) => planned.reduce((total, { leg }) => total + (Number(read(leg)) || 0), 0);
const seconds = sum((leg) => {
const parts = recordOf(leg.seconds);
return (Number(parts.ski) || 0) + (Number(parts.lift) || 0) + (Number(parts.wait) || 0) + (Number(parts.walk) || 0);
});
const heights = route.line.map(([, , height]) => height).filter((height): height is number => height !== null);
const scheme = skiColourSchemeSchema.safeParse(plan.colour_scheme);
const ski = routeCard({
source: 'plan',
activity: 'ski',
ascentM: climbOf(sum((leg) => leg.climb_m)),
descentM: climbOf(sum((leg) => leg.descent_m)),
minEleM: heights.length === route.line.length ? Math.min(...heights) : null,
maxEleM: heights.length === route.line.length ? Math.max(...heights) : null,
minutes: minutesOf(seconds / 60),
minutesBasis: 'piste',
routed: true,
skiScheme: scheme.success ? scheme.data : null,
...route,
});
return fitted(ski);
}
@@ -1,37 +1,58 @@
import type { ToolCards, UndoSnapshot } from '../card-types';
import { recordOf } from '../card-values';
import { recordOf, type Row } from '../card-values';
import { byId, placeUnchanged, snapshotPlaces } from '../undo-snapshot';
/** The place id of a tour a create answered with (`{ tour: { place_id } }`). */
function tourIdIn(value: unknown): number | null {
const id = recordOf(recordOf(value).tour).place_id;
/** The place id of a tour, or null. */
function placeIdOf(tour: Row): number | null {
const id = tour.place_id;
return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null;
}
/**
* A created tour, unchanged and on no day: its place still has the name and
* description it was created with, has not been written since it was made,
* and nothing plans, books or pays for it. Every write of a tour's route or
* name (a change over MCP, the planner's save, a rename) stamps its place's
* `updated_at`, which the create left equal to `created_at`; the revision is
* not in the snapshot. A tour planned on a day since is kept too.
* A created tour, unchanged and on no other day: its place still has the
* name and description it was created with, has not been written since it
* was made, and nothing plans, books or pays for it. Every write of a tour's
* route or name (a change over MCP, the planner's save, a rename) stamps its
* place's `updated_at`, which the create left equal to `created_at`; the
* revision is not in the snapshot. The visit a hike created on a day comes
* with it (`assignment`); planned on another day since, the tour is kept.
*/
function tourUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
const tour = recordOf(recordOf(result).tour);
function tourUnchanged(tour: Row, assignment: unknown, snapshot: UndoSnapshot): boolean {
const current = byId(snapshotPlaces(snapshot), tour.place_id);
const untouched = typeof current?.created_at === 'string' && current.updated_at === current.created_at;
return (
untouched &&
placeUnchanged({ place: { id: tour.place_id, name: tour.name, description: tour.description ?? null } }, snapshot)
);
const created = { id: tour.place_id, name: tour.name, description: tour.description ?? null };
return untouched && placeUnchanged({ place: created, assignment: recordOf(assignment) }, snapshot);
}
/** A created tour taken back with its place, which takes its route and points along. */
/** A created tour taken back with its place, which takes its route, its points and its visit along. */
export const DELETE_CREATED_TOUR: NonNullable<ToolCards['undo']> = {
tool: 'delete_place',
args: (_args, result) => {
const placeId = tourIdIn(result);
const placeId = placeIdOf(recordOf(recordOf(result).tour));
return placeId ? { placeId } : null;
},
unchanged: tourUnchanged,
unchanged: (result, snapshot) => {
const answer = recordOf(result);
return tourUnchanged(recordOf(answer.tour), answer.assignment, snapshot);
},
};
/** The one tour a GPX file brought in, when it brought in exactly one: a file of several has no single way back. */
function onlyImported(result: unknown): Row | null {
const tours = recordOf(result).tours;
return Array.isArray(tours) && tours.length === 1 ? recordOf(tours[0]) : null;
}
/** A tour taken in from a GPX file of one track, taken back with its place while it is as it came in. */
export const DELETE_IMPORTED_TOUR: NonNullable<ToolCards['undo']> = {
tool: 'delete_place',
args: (_args, result) => {
const tour = onlyImported(result);
const placeId = tour ? placeIdOf(tour) : null;
return placeId ? { placeId } : null;
},
unchanged: (result, snapshot) => {
const tour = onlyImported(result);
return tour !== null && tourUnchanged(tour, null, snapshot);
},
};