feat(tours): read the walking time left of a city walk on a day over MCP, apart from its stays and the whole walk

This commit is contained in:
Maurice
2026-10-21 12:16:57 +02:00
parent 4eaf5e6a11
commit 43353b9394
12 changed files with 743 additions and 17 deletions
+1
View File
@@ -77,6 +77,7 @@ export * from './tours/tour-walk';
export * from './tours/tour-walk-briefing.schema';
export * from './tours/tour-change.schema';
export * from './tours/walk-hours';
export * from './tours/walk-remaining';
export * from './tours/tours.metrics';
export * from './tours/tour-gpx-import.schema';
export * from './tours/paddle-plan.schema';
+67 -1
View File
@@ -1,4 +1,4 @@
import { stationKeySchema, TOUR_STATION_NAME_MAX } from './tours.schema';
import { STATION_IMPORTANCES, stationKeySchema, TOUR_STATION_NAME_MAX } from './tours.schema';
import { z } from 'zod';
@@ -74,3 +74,69 @@ export type WalkImportRefusal = z.infer<typeof walkImportRefusalSchema>;
/** The body of a refusal: the English text for API and MCP clients, and its reason. */
export const walkImportRefusalResponseSchema = z.object({ error: z.string(), reason: walkImportRefusalSchema });
/**
* CW-13: a station of a walk as the remaining time names it: its place in the
* walk's order, its key (null for one saved before keys) and its name.
*/
export const walkStationRefSchema = z.object({
position: z.number().int().positive(),
key: stationKeySchema.nullable(),
name: z.string().nullable(),
});
export type WalkStationRef = z.infer<typeof walkStationRefSchema>;
/** Why the remaining walking time is not known: no stations, no line yet, no walking time, or no clear progress. */
export const WALK_REMAINDER_GAPS = ['no_stations', 'route_pending', 'no_walking_time', 'progress_ambiguous'] as const;
export const walkRemainderGapSchema = z.enum(WALK_REMAINDER_GAPS);
export type WalkRemainderGap = z.infer<typeof walkRemainderGapSchema>;
const minutesSchema = z.number().int().nonnegative();
/**
* CW-13: what is left of a city walk on one of its days, worked out from the
* stations checked off that day and the saved line. The reference is the last
* station visited, or the start before any is: a status says what was done,
* never where the travellers are now. Walking time and stays stay apart.
*/
export const walkRemainderSchema = z.object({
reference: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('start') }),
z.object({ kind: z.literal('last_visited'), station: walkStationRefSchema }),
/** A station still planned before the last one visited: where the walk is cannot be told. */
z.object({
kind: z.literal('ambiguous'),
last_visited: walkStationRefSchema,
open_before: z.array(walkStationRefSchema),
}),
]),
live_position: z.literal(false),
finished: z.boolean(),
walking: z.object({
/** The walking time of the whole walk as saved. */
total_minutes: minutesSchema.nullable(),
remaining_minutes: minutesSchema.nullable(),
unknown_because: walkRemainderGapSchema.nullable(),
/** A station ahead was skipped: the saved line still leads through it, so the time is at most this. */
upper_bound: z.boolean(),
skipped_ahead: z.array(walkStationRefSchema),
/** The part of it from the last station to the end of the line, a loop's way back to its start. */
after_last_station_minutes: minutesSchema.nullable(),
}),
stays: z.object({
/** The stays of the open stations as far as entered; a visited station's stay counts as done. */
remaining_known_minutes: minutesSchema,
unknown: z.array(walkStationRefSchema),
/** Of the known minutes, those of optional stations. */
optional_known_minutes: minutesSchema,
}),
open_stations: z.array(
walkStationRefSchema.extend({
importance: z.enum(['normal', ...STATION_IMPORTANCES]),
stay_minutes: minutesSchema.nullable(),
/** The walking time from the reference to it along the saved line; null where the remaining time is not known. */
walk_minutes_from_reference: minutesSchema.nullable(),
}),
),
});
export type WalkRemainder = z.infer<typeof walkRemainderSchema>;
+10
View File
@@ -6,6 +6,7 @@ import {
stationArrivals,
stationFit,
stationHoursOn,
stationShares,
type StationHours,
} from './walk-hours';
@@ -317,6 +318,15 @@ describe('stationArrivals', () => {
expect(out?.map((arrival) => arrival.arrival)).toEqual([600, 610, 620]);
});
it('gives each stop its share of the route, from 0 at the start to 1 at the end', () => {
const shares = stationShares([stop(48, 11), stop(48.001, 11), stop(48.002, 11)], line) ?? [];
expect(shares).toHaveLength(3);
expect(shares[0]).toBe(0);
expect(shares[1]).toBeCloseTo(0.5, 6);
expect(shares[2]).toBe(1);
expect(stationShares([stop(48, 11)], [[48, 11]])).toBeNull();
});
it('is null without a start, a walking time or a route', () => {
const stops = [stop(48, 11, 10)];
expect(stationArrivals(stops, line, null, 40)).toBeNull();
+21 -10
View File
@@ -322,6 +322,23 @@ function passedAt(stops: readonly WalkStop[], flat: ReadonlyArray<[number, numbe
return indices;
}
/**
* How far along the route each stop is passed, from 0 at its start to 1 at its
* end, by distance, the stops matched in their order as `passedAt` does. Null
* for a route of fewer than two points.
*/
export function stationShares(stops: readonly WalkStop[], route: readonly RoutePoint[]): number[] | null {
if (route.length < 2) return null;
const flat = flatRoute(route);
const along = [0];
for (let i = 1; i < flat.length; i += 1) {
along.push((along[i - 1] ?? 0) + Math.sqrt(flatGap(flat[i] ?? [0, 0], flat[i - 1] ?? [0, 0])));
}
const total = along[along.length - 1] || 1;
const indices = passedAt(stops, flat, Math.cos((route[0]?.[0] ?? 0) * RAD));
return indices.map((index) => (along[index] ?? 0) / total);
}
/**
* The estimated arrival at each stop, in minutes after midnight and whole: the
* start, the walking time shared out by how far along the route the stop is
@@ -335,19 +352,13 @@ export function stationArrivals(
start: number | null,
walkingMinutes: number | null,
): WalkArrival[] | null {
if (start === null || walkingMinutes === null || route.length < 2) return null;
const flat = flatRoute(route);
const along = [0];
for (let i = 1; i < flat.length; i += 1) {
along.push((along[i - 1] ?? 0) + Math.sqrt(flatGap(flat[i] ?? [0, 0], flat[i - 1] ?? [0, 0])));
}
const total = along[along.length - 1] || 1;
const indices = passedAt(stops, flat, Math.cos((route[0]?.[0] ?? 0) * RAD));
if (start === null || walkingMinutes === null) return null;
const shares = stationShares(stops, route);
if (!shares) return null;
let stays = 0;
let unknown = false;
return stops.map((stop, index) => {
const share = (along[indices[index] ?? 0] ?? 0) / total;
const arrival = { arrival: Math.round(start + walkingMinutes * share + stays), atLeast: unknown };
const arrival = { arrival: Math.round(start + walkingMinutes * (shares[index] ?? 0) + stays), atLeast: unknown };
if (stop.stayMinutes == null) unknown = true;
else stays += stop.stayMinutes;
return arrival;
+160
View File
@@ -0,0 +1,160 @@
import { walkRemainderSchema } from './tour-walk.schema';
import { walkRemainderOf, type WalkRemainderStop } from './walk-remaining';
import { describe, expect, it } from 'vitest';
// Five stations, one every 0.001 degrees north: the line is four equal stretches.
const line: Array<[number, number]> = [
[48, 11],
[48.001, 11],
[48.002, 11],
[48.003, 11],
[48.004, 11],
];
const NAMES = ['Station', 'Cathedral', 'Market', 'Museum', 'Park'];
function walk(
statuses: Array<WalkRemainderStop['status']>,
extra: Partial<WalkRemainderStop>[] = [],
): WalkRemainderStop[] {
return statuses.map((status, index) => ({
lat: 48 + index * 0.001,
lng: 11,
key: `stop-${index + 1}`,
name: NAMES[index] ?? null,
status,
stayMinutes: 20,
importance: null,
...extra[index],
}));
}
const remainder = (stations: WalkRemainderStop[], minutes: number | null = 80) =>
walkRemainderSchema.parse(walkRemainderOf({ stations, line, walkingMinutes: minutes }));
describe('walkRemainderOf (CW-13)', () => {
it('reads the whole walking time from the start before anything is visited', () => {
const result = remainder(walk(['planned', 'planned', 'planned', 'planned', 'planned']));
expect(result.reference).toEqual({ kind: 'start' });
expect(result.live_position).toBe(false);
expect(result.walking).toEqual({
total_minutes: 80,
remaining_minutes: 80,
unknown_because: null,
upper_bound: false,
skipped_ahead: [],
after_last_station_minutes: 0,
});
expect(result.stays).toEqual({ remaining_known_minutes: 100, unknown: [], optional_known_minutes: 0 });
expect(result.open_stations.map((station) => station.walk_minutes_from_reference)).toEqual([0, 20, 40, 60, 80]);
});
it('counts from the last station visited, never the stretches already walked or their stays', () => {
const result = remainder(walk(['visited', 'visited', 'planned', 'planned', 'planned']));
expect(result.reference).toEqual({
kind: 'last_visited',
station: { position: 2, key: 'stop-2', name: 'Cathedral' },
});
expect(result.walking.remaining_minutes).toBe(60);
expect(result.walking.total_minutes).toBe(80);
expect(result.stays.remaining_known_minutes).toBe(60);
expect(result.open_stations.map((station) => [station.position, station.walk_minutes_from_reference])).toEqual([
[3, 20],
[4, 40],
[5, 60],
]);
expect(result.finished).toBe(false);
});
it('is an upper bound while a station ahead is skipped, and leaves its stay out', () => {
const result = remainder(walk(['visited', 'planned', 'skipped', 'planned', 'planned']));
expect(result.walking.remaining_minutes).toBe(80);
expect(result.walking.upper_bound).toBe(true);
expect(result.walking.skipped_ahead).toEqual([{ position: 3, key: 'stop-3', name: 'Market' }]);
expect(result.open_stations.map((station) => station.position)).toEqual([2, 4, 5]);
expect(result.stays.remaining_known_minutes).toBe(60);
});
it('moves nothing for a skip behind the last visit', () => {
const result = remainder(walk(['skipped', 'visited', 'planned', 'planned', 'planned']));
expect(result.reference).toMatchObject({ kind: 'last_visited', station: { position: 2 } });
expect(result.walking).toMatchObject({ remaining_minutes: 60, upper_bound: false });
});
it('leaves the time unknown when a station is still planned before the last one visited', () => {
const result = remainder(walk(['visited', 'planned', 'visited', 'planned', 'planned']));
expect(result.reference).toEqual({
kind: 'ambiguous',
last_visited: { position: 3, key: 'stop-3', name: 'Market' },
open_before: [{ position: 2, key: 'stop-2', name: 'Cathedral' }],
});
expect(result.walking).toMatchObject({
remaining_minutes: null,
unknown_because: 'progress_ambiguous',
after_last_station_minutes: null,
});
expect(result.open_stations.map((station) => [station.position, station.walk_minutes_from_reference])).toEqual([
[2, null],
[4, null],
[5, null],
]);
// The stays are still what they are.
expect(result.stays.remaining_known_minutes).toBe(60);
});
it('takes a station without a key behind the last visit as passed, not as a gap', () => {
const result = remainder(walk(['visited', 'planned', 'visited', 'planned', 'planned'], [{}, { key: null }]));
expect(result.reference).toMatchObject({ kind: 'last_visited', station: { position: 3 } });
expect(result.walking.remaining_minutes).toBe(40);
expect(result.open_stations.map((station) => station.position)).toEqual([4, 5]);
});
it("keeps a loop's way back after its last station, also once every station is done", () => {
const loop: Array<[number, number]> = [...line, [48.002, 11.001], [48, 11]];
const stations = walk(['visited', 'visited', 'visited', 'visited', 'visited']);
const result = walkRemainderOf({ stations, line: loop, walkingMinutes: 120 });
expect(result.finished).toBe(true);
expect(result.walking.after_last_station_minutes).toBeGreaterThan(0);
expect(result.walking.remaining_minutes).toBe(result.walking.after_last_station_minutes);
expect(result.stays).toEqual({ remaining_known_minutes: 0, unknown: [], optional_known_minutes: 0 });
});
it('is nothing left once a walk that ends at its last station is done', () => {
const result = remainder(walk(['visited', 'visited', 'skipped', 'visited', 'visited']));
expect(result.finished).toBe(true);
expect(result.walking).toMatchObject({ remaining_minutes: 0, upper_bound: false });
});
it('lists stays nobody entered instead of counting them as nothing, and sums the optional ones', () => {
const result = remainder(
walk(
['visited', 'planned', 'planned', 'planned', 'planned'],
[{}, { stayMinutes: null }, { importance: 'optional', stayMinutes: 15 }, { stayMinutes: 0 }],
),
);
expect(result.stays).toEqual({
remaining_known_minutes: 35,
unknown: [{ position: 2, key: 'stop-2', name: 'Cathedral' }],
optional_known_minutes: 15,
});
expect(result.open_stations[1]).toMatchObject({ importance: 'optional', stay_minutes: 15 });
});
it('says why the time is not known: no stations, no line yet, no walking time', () => {
expect(walkRemainderOf({ stations: [], line, walkingMinutes: 80 }).walking.unknown_because).toBe('no_stations');
const stations = walk(['visited', 'planned']);
const pending = walkRemainderOf({ stations, line: null, walkingMinutes: null });
expect(pending.walking).toMatchObject({ remaining_minutes: null, unknown_because: 'route_pending' });
expect(walkRemainderOf({ stations, line: [[48, 11]], walkingMinutes: 80 }).walking.unknown_because).toBe(
'route_pending',
);
const noTime = remainder(stations, null);
expect(noTime.walking).toMatchObject({
total_minutes: null,
remaining_minutes: null,
unknown_because: 'no_walking_time',
});
expect(noTime.open_stations[0]?.walk_minutes_from_reference).toBeNull();
expect(noTime.stays.remaining_known_minutes).toBe(20);
});
});
+116
View File
@@ -0,0 +1,116 @@
import type { WalkRemainder, WalkStationRef } from './tour-walk.schema';
import type { StationImportance } from './tours.schema';
import { stationShares, type WalkStop } from './walk-hours';
/** A station of a walk with its status on one day; planned is a station nobody checked off. */
export interface WalkRemainderStop extends WalkStop {
key: string | null;
name: string | null;
status: 'planned' | 'visited' | 'skipped';
importance?: StationImportance | 'normal' | null;
}
export interface WalkRemainderInput {
/** The walk's stations in order: no shaping point, no way back of a loop. */
stations: readonly WalkRemainderStop[];
/** The saved line; empty or null while the walk waits for its way. */
line: ReadonlyArray<readonly [number, number, ...number[]]> | null;
walkingMinutes: number | null;
}
function refOf(stations: readonly WalkRemainderStop[], index: number): WalkStationRef {
const station = stations[index];
return { position: index + 1, key: station?.key ?? null, name: station?.name ?? null };
}
/**
* The last station visited, by its index, and the stations still planned
* before it. A skip is a decision, not a place reached, so it moves nothing;
* a station without a key cannot be checked off and is never in the way.
*/
function progressOf(stations: readonly WalkRemainderStop[]): { last: number; openBefore: number[] } {
let last = -1;
stations.forEach((station, index) => {
if (station.status === 'visited') last = index;
});
const openBefore: number[] = [];
for (let index = 0; index < last; index += 1) {
const station = stations[index];
if (station?.status === 'planned' && station.key) openBefore.push(index);
}
return { last, openBefore };
}
function gapOf({ stations, line, walkingMinutes }: WalkRemainderInput, ambiguous: boolean) {
if (stations.length === 0) return 'no_stations' as const;
if (!line || line.length < 2) return 'route_pending' as const;
if (walkingMinutes === null) return 'no_walking_time' as const;
return ambiguous ? ('progress_ambiguous' as const) : null;
}
/**
* CW-13: what is left of a city walk on one of its days. The reference is the
* last station visited, or the start before any is; the walking time left is
* the walk's time shared out by how far along the saved line each station is
* passed (as the arrivals of `walk-hours.ts` do), never by how many stations
* are open. A station skipped ahead keeps the line through it, so the time is
* an upper bound; a station planned before the last one visited leaves the
* progress unclear and the time unknown. Stays are counted apart, an unknown
* one listed rather than taken as nothing.
*/
export function walkRemainderOf(input: WalkRemainderInput): WalkRemainder {
const { stations, line, walkingMinutes } = input;
const { last, openBefore } = progressOf(stations);
const ambiguous = openBefore.length > 0;
const gap = gapOf(input, ambiguous);
const shares = gap === null && line ? stationShares(stations, line) : null;
const walking = walkingMinutes ?? 0;
const passed = (index: number) => Math.round(walking * (shares?.[index] ?? 0));
const fromReference = last < 0 ? 0 : passed(last);
const ahead = stations.map((_, index) => index).filter((index) => index > last);
const openIndices = [...openBefore, ...ahead.filter((index) => stations[index]?.status === 'planned')];
const open = openIndices.map((index): WalkRemainder['open_stations'][number] => {
const station = stations[index];
return {
...refOf(stations, index),
importance: station?.importance ?? 'normal',
stay_minutes: station?.stayMinutes ?? null,
walk_minutes_from_reference: shares ? passed(index) - fromReference : null,
};
});
const knownStays = open.filter((station) => station.stay_minutes !== null);
const skippedAhead = ahead.filter((index) => stations[index]?.status === 'skipped');
return {
reference: ambiguous
? {
kind: 'ambiguous',
last_visited: refOf(stations, last),
open_before: openBefore.map((i) => refOf(stations, i)),
}
: last < 0
? { kind: 'start' }
: { kind: 'last_visited', station: refOf(stations, last) },
live_position: false,
finished: stations.length > 0 && open.length === 0,
walking: {
total_minutes: walkingMinutes,
remaining_minutes: shares ? walking - fromReference : null,
unknown_because: gap,
upper_bound: skippedAhead.length > 0,
skipped_ahead: skippedAhead.map((index) => refOf(stations, index)),
after_last_station_minutes: shares ? walking - passed(stations.length - 1) : null,
},
stays: {
remaining_known_minutes: knownStays.reduce((sum, station) => sum + (station.stay_minutes ?? 0), 0),
unknown: open
.filter((station) => station.stay_minutes === null)
.map(({ position, key, name }) => ({ position, key, name })),
optional_known_minutes: knownStays
.filter((station) => station.importance === 'optional')
.reduce((sum, station) => sum + (station.stay_minutes ?? 0), 0),
},
open_stations: open,
};
}