mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
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:
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user