mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +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:
@@ -1,6 +1,8 @@
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { TourStationVisits } from '../../db/entities/TourStationVisits.entity';
|
||||
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import type {
|
||||
TourStationVisitRow,
|
||||
TourStationVisitsRepository,
|
||||
@@ -8,15 +10,26 @@ import type {
|
||||
import type { TourWaypointRow, TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository';
|
||||
import type { ToursRepository } from '../../db/repositories/Tours.repository';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { parseRouteGeometry } from './tours.helpers';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { walkStationsOf, type TourVisit, type TourVisitListResponse, type TourVisitUpdateRequest } from '@trek/shared';
|
||||
import {
|
||||
isClosedLoop,
|
||||
walkRemainderOf,
|
||||
walkStationsOf,
|
||||
type TourVisit,
|
||||
type TourVisitListResponse,
|
||||
type TourVisitUpdateRequest,
|
||||
type WalkRemainder,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** A city walk on one of its days: the assignment, the walk and its stations in order. */
|
||||
export interface WalkOnDay {
|
||||
assignmentId: number;
|
||||
dayId: number;
|
||||
placeId: number;
|
||||
/** The walk ends where it starts, so its line leads back to the start after the last station. */
|
||||
loop: boolean;
|
||||
stations: TourWaypointRow[];
|
||||
visits: TourStationVisitRow[];
|
||||
}
|
||||
@@ -33,6 +46,7 @@ export class TourVisitsService {
|
||||
@InjectRepository(TourStationVisits) private readonly visitsRepo: TourStationVisitsRepository,
|
||||
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
|
||||
@InjectRepository(TourWaypoints) private readonly waypointsRepo: TourWaypointsRepository,
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
private readonly places: PlacesService,
|
||||
) {}
|
||||
|
||||
@@ -52,6 +66,7 @@ export class TourVisitsService {
|
||||
assignmentId,
|
||||
dayId: assignment.day_id,
|
||||
placeId: assignment.place_id,
|
||||
loop: isClosedLoop(points),
|
||||
stations: walkStationsOf(points),
|
||||
visits: await this.visitsRepo.listForAssignment(assignmentId),
|
||||
};
|
||||
@@ -59,6 +74,40 @@ export class TourVisitsService {
|
||||
|
||||
/** CW-09: a walk on one day, read for an assistant; nothing changes. */
|
||||
async progress(tripId: number, placeId: number, dayId: number): Promise<WalkProgress> {
|
||||
return (await this.dayRun(tripId, placeId, dayId)).progress;
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-13: what is left of a walk on one day, read for an assistant: the
|
||||
* reference it counts from, the walking time left along the saved line and
|
||||
* the stays still open, apart. Read afresh each time, so a station checked
|
||||
* off since counts; nothing changes.
|
||||
*/
|
||||
async remaining(tripId: number, placeId: number, dayId: number): Promise<WalkRemaining> {
|
||||
const [{ walk, progress }, place] = await Promise.all([
|
||||
this.dayRun(tripId, placeId, dayId),
|
||||
this.placesRepo.findInTrip(placeId, tripId),
|
||||
]);
|
||||
const remainder = walkRemainderOf({
|
||||
stations: progress.stations.map((station) => ({ ...station, stayMinutes: station.stay_minutes })),
|
||||
line: parseRouteGeometry(place?.route_geometry),
|
||||
walkingMinutes: progress.walking_minutes,
|
||||
});
|
||||
return {
|
||||
place_id: placeId,
|
||||
day_id: progress.day_id,
|
||||
assignment_id: progress.assignment_id,
|
||||
loop: walk.loop,
|
||||
...remainder,
|
||||
derived: REMAINDER_DERIVED,
|
||||
};
|
||||
}
|
||||
|
||||
private async dayRun(
|
||||
tripId: number,
|
||||
placeId: number,
|
||||
dayId: number,
|
||||
): Promise<{ walk: WalkOnDay; progress: WalkProgress }> {
|
||||
const assignmentId = await this.visitsRepo.assignmentOnDay(tripId, placeId, dayId);
|
||||
if (assignmentId === undefined) throw new NotFoundException('City walk not on this day');
|
||||
const walk = await this.walkOnDay(tripId, assignmentId);
|
||||
@@ -78,7 +127,7 @@ export class TourVisitsService {
|
||||
linked_place_id: station.linked_place_id ?? null,
|
||||
}));
|
||||
const open = stations.filter((station) => station.status === 'planned');
|
||||
return {
|
||||
const progress: WalkProgress = {
|
||||
place_id: placeId,
|
||||
day_id: walk.dayId,
|
||||
assignment_id: assignmentId,
|
||||
@@ -90,6 +139,7 @@ export class TourVisitsService {
|
||||
},
|
||||
walking_minutes: tour?.duration ?? null,
|
||||
};
|
||||
return { walk, progress };
|
||||
}
|
||||
|
||||
/** Marks a station of a walk on one of its days, or takes it back to planned. */
|
||||
@@ -144,6 +194,24 @@ export interface WalkProgress {
|
||||
walking_minutes: number | null;
|
||||
}
|
||||
|
||||
/** The fields of a remaining time worked out on reading, never stored with the walk. */
|
||||
const REMAINDER_DERIVED = [
|
||||
'reference',
|
||||
'finished',
|
||||
'walking.remaining_minutes',
|
||||
'walking.after_last_station_minutes',
|
||||
'open_stations.walk_minutes_from_reference',
|
||||
] as const;
|
||||
|
||||
/** CW-13: what is left of a walk on one day, for an assistant. */
|
||||
export type WalkRemaining = {
|
||||
place_id: number;
|
||||
day_id: number;
|
||||
assignment_id: number;
|
||||
loop: boolean;
|
||||
derived: readonly string[];
|
||||
} & WalkRemainder;
|
||||
|
||||
/** A visit as the routes and the trip's socket send it. */
|
||||
export function toVisit(row: TourStationVisitRow): TourVisit {
|
||||
return { assignment_id: row.assignment_id, station_key: row.station_key, status: row.status };
|
||||
|
||||
@@ -178,7 +178,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'get_city_walk_progress',
|
||||
description:
|
||||
'Read how far a city walk (tour_type walk) got on one day it is on: every station in order with its key (its own id, as get_tour_plan gives it), its status that day (planned, visited or skipped, as the travellers checked them off in the day plan), its importance (normal, optional, must; get_tour_plan says null for normal), whether it is protected (a station a proposed change must leave where it is, neither removed, replaced nor moved), its stay in minutes (null when not known, 0 for a point passed through), its opening hours as OpenStreetMap writes them (null when none is known) and its linked place. "open" sums what is still planned: the stations, their known stays and how many stays are unknown; walking_minutes is the walking time of the whole walk. Visits belong to that day only: the same walk on another day, or a copy, has none. Anything the traveller tells you besides (for instance "continue from 2 pm") is theirs to say, not stored here. Reading changes nothing.',
|
||||
'Read how far a city walk (tour_type walk) got on one day it is on: every station in order with its key (its own id, as get_tour_plan gives it), its status that day (planned, visited or skipped, as the travellers checked them off in the day plan), its importance (normal, optional, must; get_tour_plan says null for normal), whether it is protected (a station a proposed change must leave where it is, neither removed, replaced nor moved), its stay in minutes (null when not known, 0 for a point passed through), its opening hours as OpenStreetMap writes them (null when none is known) and its linked place. "open" sums what is still planned: the stations, their known stays and how many stays are unknown; walking_minutes is the walking time of the whole walk. Visits belong to that day only: the same walk on another day, or a copy, has none. Anything the traveller tells you besides (for instance "continue from 2 pm") is theirs to say, not stored here. For the walking time still ahead, read get_city_walk_remaining_time: walking_minutes here is the whole walk. Reading changes nothing.',
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
placeId: idSchema.describe('The place id of the city walk, as list_tours gives it'),
|
||||
@@ -201,6 +201,32 @@ export class ToursMcp {
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_city_walk_remaining_time',
|
||||
description:
|
||||
'Read what is left of a city walk (tour_type walk) on one day it is on, from the stations the travellers checked off that day: the walking time still ahead, the stays still open and the stations still open, each apart. reference says what the estimate counts from: start (nothing visited yet), last_visited (the last station checked off as visited; its stay counts as done) or ambiguous (a station is still planned before the last one visited, so where the walk stands cannot be told: ask the travellers where they are rather than guessing). A status says what was done, never where anyone is now: live_position is always false, TREK knows no live position and no clock. walking.total_minutes is the walking time of the whole walk as saved; walking.remaining_minutes is the part of it still ahead of the reference, the saved walking time shared out by how far along the saved line each station is passed (never by how many stations are open), so stretches already walked are not in it; it holds no stay. walking.upper_bound is true when a station ahead was skipped (listed in skipped_ahead): the saved line still leads through it and the shorter way was never planned, so the time is at most that. walking.after_last_station_minutes is the part from the last station to the end of the line, for a loop (loop true) the way back to its start, still ahead even once every station is done. walking.remaining_minutes is null, with unknown_because, when it cannot be told: no_stations, route_pending (saved with its points only, no line yet), no_walking_time or progress_ambiguous; it is never given as 0 or as the whole walk instead. stays.remaining_known_minutes sums the stays of the open stations as far as entered, stays.unknown lists the open stations whose stay is not known (never counted as 0), stays.optional_known_minutes is the part of the known minutes at optional stations. open_stations lists the stations still planned in order, with importance, stay_minutes and walk_minutes_from_reference (walking time from the reference to it, null where the remaining time is not known); a skipped station is done and not in it. finished is true when no station is open. Visits belong to that day only: the same walk on another day, or a copy, starts from the beginning. A time the traveller tells you (for instance "continue from 2 pm") is theirs to add, not stored here. derived names the fields worked out on reading. Reading changes nothing: no status, tour or day plan.',
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
placeId: idSchema.describe('The place id of the city walk, as list_tours gives it'),
|
||||
dayId: idSchema.describe('The day the walk is on, as find_tours_for_day gives it'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async getCityWalkRemainingTime(
|
||||
{ tripId, placeId, dayId }: { tripId: number; placeId: number; dayId: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
try {
|
||||
return ok(await this.visits.remaining(tripId, placeId, dayId));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof NotFoundException) return errorResult('No city walk of this trip is on that day.');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'find_tours_for_day',
|
||||
description: 'List the saved tours put on one day of a trip, with the same facts as list_tours.',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Tours e2e for city walks (CW-01 to CW-03, CW-10, CW-11): a walk saved and read
|
||||
* Tours e2e for city walks (CW-01 to CW-03, CW-10, CW-11, CW-13): a walk saved and read
|
||||
* with its stations, one saved with its stations only waiting for its way, the
|
||||
* keys of its stations kept through an edit, and the stations checked off on
|
||||
* the days the walk is on, through the real
|
||||
* the days the walk is on, and what is left of it read over MCP, through the real
|
||||
* JwtAuthGuard, AddonGuard and TripAccessGuard against a migrated and seeded
|
||||
* temp SQLite db. The permission check is a container spy and the broadcast
|
||||
* goes to a FakeRealtimeService, as in tours.e2e.test.ts.
|
||||
@@ -17,13 +17,17 @@ import { Places } from '../../src/db/entities/Places.entity';
|
||||
import { TourStationVisits } from '../../src/db/entities/TourStationVisits.entity';
|
||||
import { Tours } from '../../src/db/entities/Tours.entity';
|
||||
import { Trips } from '../../src/db/entities/Trips.entity';
|
||||
import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy';
|
||||
import { createTestRegistry, type McpContext } from '../../src/nest-mcp';
|
||||
import { AssignmentsModule } from '../../src/nest/assignments/assignments.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { withRequestContext } from '../../src/nest/database/request-context';
|
||||
import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service';
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
|
||||
import { ToursMcp } from '../../src/nest/tours/tours.mcp';
|
||||
import { ToursModule } from '../../src/nest/tours/tours.module';
|
||||
import { createSnapshotTestDb } from '../helpers/db-mock';
|
||||
import { makePlace } from '../helpers/factories/places';
|
||||
@@ -36,6 +40,7 @@ import { FakeRealtimeService } from '../helpers/fake-realtime';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { sessionCookie } from './harness';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { stationKeySchema } from '@trek/shared';
|
||||
|
||||
@@ -472,6 +477,50 @@ describe('City walks e2e (real guards + temp SQLite)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('remaining time over MCP (CW-13)', () => {
|
||||
const remainingOf = (placeId: number, dayId: number) =>
|
||||
withRequestContext(app.get(MikroORM), async () => {
|
||||
const mcp = app.get(ToursMcp);
|
||||
const result = await mcp.getCityWalkRemainingTime({ tripId: 5, placeId, dayId }, {
|
||||
userId: VIEWER,
|
||||
} as McpContext);
|
||||
return JSON.parse(result.content[0]?.text ?? '{}');
|
||||
});
|
||||
|
||||
it('is registered as a read of the trip', () => {
|
||||
expect(() =>
|
||||
createTestRegistry([app.get(ToursMcp)], {
|
||||
accessPolicy: trekMcpAccessPolicy,
|
||||
validateAccess: trekMcpValidateAccess,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('counts from the stations checked off over REST, and the same walk on another day from its start', async () => {
|
||||
const { placeId, assignmentId, cathedral, market } = await walkOnDay3();
|
||||
await assign(placeId, 4);
|
||||
expect((await mark(assignmentId, cathedral, { status: 'visited' })).status).toBe(200);
|
||||
expect((await remainingOf(placeId, 3)).walking).toMatchObject({ total_minutes: 60, remaining_minutes: 60 });
|
||||
|
||||
expect((await mark(assignmentId, market, { status: 'visited' })).status).toBe(200);
|
||||
const day3 = await remainingOf(placeId, 3);
|
||||
expect(day3.reference).toMatchObject({ kind: 'last_visited', station: { key: market, name: 'Market' } });
|
||||
expect(day3.walking).toMatchObject({ remaining_minutes: 40, upper_bound: false });
|
||||
expect(day3.open_stations).toEqual([
|
||||
expect.objectContaining({ name: 'Park', importance: 'optional', walk_minutes_from_reference: 40 }),
|
||||
]);
|
||||
expect(day3.stays).toMatchObject({
|
||||
remaining_known_minutes: 0,
|
||||
unknown: [expect.objectContaining({ name: 'Park' })],
|
||||
});
|
||||
|
||||
const day4 = await remainingOf(placeId, 4);
|
||||
expect(day4.reference).toEqual({ kind: 'start' });
|
||||
expect(day4.walking.remaining_minutes).toBe(60);
|
||||
expect(await countRows(orm, TourStationVisits)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refusals', () => {
|
||||
it('404 for an assignment of another trip, a walk that is none, and a station the walk does not have', async () => {
|
||||
const { placeId: hikeId } = await saveWalk(
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
* The service comes out of ToursModule over a snapshot test db; rows are
|
||||
* seeded through the ORM factories and broadcasts go to a FakeRealtimeService.
|
||||
*/
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import { TourStationVisits } from '../../../src/db/entities/TourStationVisits.entity';
|
||||
import { TourVisitsService, type WalkOnDay, type WalkProgress } from '../../../src/nest/tours/tour-visits.service';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { makeDayAssignment } from '../../helpers/factories/itinerary';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
import { countRows } from '../../helpers/factories/rows';
|
||||
import { countRows, updateRows } from '../../helpers/factories/rows';
|
||||
import { addTourWaypoints, makeTour, markStationVisit } from '../../helpers/factories/tours';
|
||||
import { makeDay, makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
@@ -174,6 +175,7 @@ describe('TourVisitsService', () => {
|
||||
assignmentId: onDay1.id,
|
||||
dayId: day1.id,
|
||||
placeId: walk.id,
|
||||
loop: false,
|
||||
stations: expect.any(Array),
|
||||
visits: [{ assignment_id: onDay1.id, station_key: 'stop-market', status: 'skipped' }],
|
||||
});
|
||||
@@ -330,4 +332,66 @@ describe('TourVisitsService', () => {
|
||||
new NotFoundException('City walk not on this day'),
|
||||
);
|
||||
});
|
||||
|
||||
it('TOUR-VISITS-008: remaining counts from the last visit along the saved line, a point without a key passed behind it', async () => {
|
||||
const { trip, day1, walk, onDay1 } = await fixture();
|
||||
// Straight north through the four stations, one stretch of 0.01 degrees between each.
|
||||
const line = [
|
||||
[48.1, 11.5],
|
||||
[48.11, 11.51],
|
||||
[48.12, 11.52],
|
||||
[48.13, 11.53],
|
||||
];
|
||||
await updateRows(orm(), Places, { id: walk.id }, { route_geometry: JSON.stringify(line) });
|
||||
await markStationVisit(orm(), onDay1.id, 'stop-cathedral', 'visited');
|
||||
await markStationVisit(orm(), onDay1.id, 'stop-market', 'visited');
|
||||
const before = await countRows(orm(), TourStationVisits, {});
|
||||
|
||||
const remaining = await visits.remaining(trip.id, walk.id, day1.id);
|
||||
expect(remaining).toMatchObject({
|
||||
place_id: walk.id,
|
||||
day_id: day1.id,
|
||||
assignment_id: onDay1.id,
|
||||
loop: false,
|
||||
reference: { kind: 'last_visited', station: { position: 2, key: 'stop-market', name: null } },
|
||||
walking: { total_minutes: 95, remaining_minutes: 63, upper_bound: false, after_last_station_minutes: 0 },
|
||||
stays: { remaining_known_minutes: 45, unknown: [{ position: 3, key: null, name: null }] },
|
||||
});
|
||||
expect(remaining.open_stations.map((station) => [station.position, station.walk_minutes_from_reference])).toEqual([
|
||||
[3, 31],
|
||||
[4, 63],
|
||||
]);
|
||||
expect(await countRows(orm(), TourStationVisits, {})).toBe(before);
|
||||
expect(t.realtime.broadcastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('TOUR-VISITS-009: a loop keeps its way back once every station is done, and a walk not on that day is a 404', async () => {
|
||||
const { trip, day1, day3, walk } = await fixture();
|
||||
const loop = await makePlace(orm(), trip.id, {
|
||||
name: 'Round',
|
||||
route_geometry: JSON.stringify([
|
||||
[48.1, 11.5],
|
||||
[48.11, 11.5],
|
||||
[48.11, 11.51],
|
||||
[48.1, 11.5],
|
||||
]),
|
||||
});
|
||||
await makeTour(orm(), loop.id, { tourTypeRef: 'walk', duration: 60 });
|
||||
await addTourWaypoints(orm(), loop.id, [
|
||||
{ lat: 48.1, lng: 11.5, station_key: 'stop-start', stay_minutes: 0 },
|
||||
{ lat: 48.11, lng: 11.51, station_key: 'stop-tower', stay_minutes: 15 },
|
||||
{ lat: 48.1, lng: 11.5, station_key: 'stop-back' },
|
||||
]);
|
||||
const onDay1 = await makeDayAssignment(orm(), day1.id, loop.id);
|
||||
await markStationVisit(orm(), onDay1.id, 'stop-start', 'visited');
|
||||
await markStationVisit(orm(), onDay1.id, 'stop-tower', 'visited');
|
||||
|
||||
const remaining = await visits.remaining(trip.id, loop.id, day1.id);
|
||||
expect(remaining).toMatchObject({ loop: true, finished: true, open_stations: [] });
|
||||
expect(remaining.walking.after_last_station_minutes).toBeGreaterThan(0);
|
||||
expect(remaining.walking.remaining_minutes).toBe(remaining.walking.after_last_station_minutes);
|
||||
await expect(visits.remaining(trip.id, walk.id, day3.id)).rejects.toEqual(
|
||||
new NotFoundException('City walk not on this day'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -796,4 +796,158 @@ describe('ToursMcp', () => {
|
||||
'routed where the piste router planned it and free',
|
||||
);
|
||||
});
|
||||
|
||||
/** CW-13: the walk of walkFixture with a saved line through its three stops, the market halfway along. */
|
||||
async function remainingFixture() {
|
||||
const base = await walkFixture();
|
||||
const orm = await createTestOrm(db);
|
||||
const line = [
|
||||
[48.1, 11.5],
|
||||
[48.11, 11.51],
|
||||
[48.12, 11.52],
|
||||
];
|
||||
await updateRows(orm, Places, { id: base.walk.id }, { route_geometry: JSON.stringify(line) });
|
||||
await orm.close();
|
||||
const remaining = async (userId: number, dayId = base.day1.id) =>
|
||||
read(await mcp.getCityWalkRemainingTime({ tripId: base.trip.id, placeId: base.walk.id, dayId }, ctx(userId)));
|
||||
return { ...base, remaining };
|
||||
}
|
||||
|
||||
it('TOURS-MCP-028: get_city_walk_remaining_time counts from the last visit, with walking time, stays and the whole walk apart', async () => {
|
||||
const { member, trip, day1, walk, onDay1, remaining } = await remainingFixture();
|
||||
const first = await remaining(member.id);
|
||||
expect(first).toMatchObject({
|
||||
place_id: walk.id,
|
||||
day_id: day1.id,
|
||||
assignment_id: onDay1.id,
|
||||
loop: false,
|
||||
live_position: false,
|
||||
finished: false,
|
||||
reference: { kind: 'last_visited', station: { position: 1, key: 'stop-cathedral', name: 'Cathedral' } },
|
||||
});
|
||||
// The market ahead was skipped, but the saved line still leads through it.
|
||||
expect(first.walking).toEqual({
|
||||
total_minutes: 80,
|
||||
remaining_minutes: 80,
|
||||
unknown_because: null,
|
||||
upper_bound: true,
|
||||
skipped_ahead: [{ position: 2, key: 'stop-market', name: null }],
|
||||
after_last_station_minutes: 0,
|
||||
});
|
||||
expect(first.stays).toEqual({
|
||||
remaining_known_minutes: 0,
|
||||
unknown: [{ position: 3, key: 'stop-park', name: null }],
|
||||
optional_known_minutes: 0,
|
||||
});
|
||||
expect(first.open_stations).toEqual([
|
||||
{
|
||||
position: 3,
|
||||
key: 'stop-park',
|
||||
name: null,
|
||||
importance: 'must',
|
||||
stay_minutes: null,
|
||||
walk_minutes_from_reference: 80,
|
||||
},
|
||||
]);
|
||||
expect(first.derived).toEqual(expect.arrayContaining(['walking.remaining_minutes', 'reference']));
|
||||
|
||||
// Checked off since: read again, the stretch to the market is behind.
|
||||
await t.get(TourVisitsService).update(String(trip.id), onDay1.id, 'stop-market', { status: 'visited' });
|
||||
const again = await remaining(member.id);
|
||||
expect(again.reference).toEqual({
|
||||
kind: 'last_visited',
|
||||
station: { position: 2, key: 'stop-market', name: null },
|
||||
});
|
||||
expect(again.walking).toMatchObject({ remaining_minutes: 40, upper_bound: false, skipped_ahead: [] });
|
||||
expect(again.open_stations[0].walk_minutes_from_reference).toBe(40);
|
||||
});
|
||||
|
||||
it("TOURS-MCP-029: the same walk on another day counts from its start, none of the first day's visits in it", async () => {
|
||||
const { member, day2, walk, remaining } = await remainingFixture();
|
||||
const orm = await createTestOrm(db);
|
||||
const onDay2 = await makeDayAssignment(orm, day2.id, walk.id);
|
||||
await orm.close();
|
||||
const result = await remaining(member.id, day2.id);
|
||||
expect(result).toMatchObject({ assignment_id: onDay2.id, reference: { kind: 'start' }, finished: false });
|
||||
expect(result.walking).toMatchObject({ remaining_minutes: 80, upper_bound: false });
|
||||
expect(result.open_stations.map((station: { key: string }) => station.key)).toEqual([
|
||||
'stop-cathedral',
|
||||
'stop-market',
|
||||
'stop-park',
|
||||
]);
|
||||
expect(result.stays).toMatchObject({ remaining_known_minutes: 50 });
|
||||
});
|
||||
|
||||
it('TOURS-MCP-030: without a line or with a station open before the last visit, the time stays unknown with its reason', async () => {
|
||||
const { user, trip, walk, onDay1, remaining } = await remainingFixture();
|
||||
const visits = t.get(TourVisitsService);
|
||||
await visits.update(String(trip.id), onDay1.id, 'stop-cathedral', { status: 'planned' });
|
||||
await visits.update(String(trip.id), onDay1.id, 'stop-market', { status: 'visited' });
|
||||
const unclear = await remaining(user.id);
|
||||
expect(unclear.reference).toEqual({
|
||||
kind: 'ambiguous',
|
||||
last_visited: { position: 2, key: 'stop-market', name: null },
|
||||
open_before: [{ position: 1, key: 'stop-cathedral', name: 'Cathedral' }],
|
||||
});
|
||||
expect(unclear.walking).toMatchObject({
|
||||
total_minutes: 80,
|
||||
remaining_minutes: null,
|
||||
unknown_because: 'progress_ambiguous',
|
||||
});
|
||||
|
||||
const orm = await createTestOrm(db);
|
||||
await updateRows(orm, Places, { id: walk.id }, { route_geometry: null });
|
||||
await orm.close();
|
||||
await visits.update(String(trip.id), onDay1.id, 'stop-cathedral', { status: 'visited' });
|
||||
const pending = await remaining(user.id);
|
||||
expect(pending.walking).toMatchObject({
|
||||
total_minutes: 80,
|
||||
remaining_minutes: null,
|
||||
unknown_because: 'route_pending',
|
||||
after_last_station_minutes: null,
|
||||
});
|
||||
expect(pending.open_stations[0].walk_minutes_from_reference).toBeNull();
|
||||
});
|
||||
|
||||
it('TOURS-MCP-031: reading changes nothing, tells an outsider nothing and names no walk for a day without it', async () => {
|
||||
const { user, outsider, trip, day2, walk, remaining } = await remainingFixture();
|
||||
const orm = await createTestOrm(db);
|
||||
const before = await countRows(orm, TourStationVisits, {});
|
||||
const revision = (await findRow(orm, Tours, { place: walk.id }))?.revision;
|
||||
await remaining(user.id);
|
||||
expect(await countRows(orm, TourStationVisits, {})).toBe(before);
|
||||
expect((await findRow(orm, Tours, { place: walk.id }))?.revision).toBe(revision);
|
||||
await orm.close();
|
||||
expect(await remaining(outsider.id)).toEqual({ error: 'Trip not found or access denied.' });
|
||||
expect(await remaining(user.id, day2.id)).toEqual({ error: 'No city walk of this trip is on that day.' });
|
||||
|
||||
const failure = new Error('database gone');
|
||||
const spy = vi.spyOn(t.get(TourVisitsService), 'remaining').mockRejectedValueOnce(failure);
|
||||
try {
|
||||
await expect(
|
||||
mcp.getCityWalkRemainingTime({ tripId: trip.id, placeId: walk.id, dayId: day2.id }, ctx(user.id)),
|
||||
).rejects.toBe(failure);
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('TOURS-MCP-032: the remaining time names its reference, its bounds and that no live position is known', () => {
|
||||
const entry = getEntry(ToursMcp as unknown as ClassRef, 'getCityWalkRemainingTime');
|
||||
const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : '';
|
||||
for (const field of [
|
||||
'reference',
|
||||
'live_position',
|
||||
'upper_bound',
|
||||
'after_last_station_minutes',
|
||||
'unknown_because',
|
||||
'stays.unknown',
|
||||
'never by how many stations are open',
|
||||
'Reading changes nothing',
|
||||
]) {
|
||||
expect(description).toContain(field);
|
||||
}
|
||||
const progress = getEntry(ToursMcp as unknown as ClassRef, 'getCityWalkProgress');
|
||||
expect(progress?.kind === 'tool' ? progress.options.description : '').toContain('get_city_walk_remaining_time');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -241,6 +241,7 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
|
||||
| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`), a stretch drawn by hand (`free`), which no router planned or checked, or on a ride a section of a signposted horse route (`horse_route`) in the direction chosen, with its signposting and the `horse=*` tags its ways carried; being on a route is no permission to ride it. A shaping point (`shaping: true`) only steers the route and is no stop. A stop with `stage_end: true` ends a stage of a multi-day tour and the next one begins there; the stages follow the route in its order. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The stations of a city walk carry every field spelled out: `key` (the same across saves), `name`, `stay_minutes` (`0` for a point only passed, `null` when not known), `importance` (`optional`, `must`, or `null` for normal), `protected` (a station a proposed change has to keep where it is; the travellers can still edit it) and `opening_hours` (as OpenStreetMap writes them, `null` when none is known). Every leg from one control point to the next is listed in `legs` with its kind (`routed` for a leg the router planned, `track` for part of a recorded line no router planned, as for an import without control points of its own, `ferrata`, `free`, `portage` or `horse_route`; a leg of a planned ski tour is `routed` where the piste router planned it and `free`, a straight line between its points, where it did not) and, for a fixed leg or a ski tour's leg, the length of its own line; a routed or track leg has no length of its own stored. `line` gives the saved line in brief: how many points it has, whether they carry heights, and a preview thinned to 200 points. Pass `dayId` with a city walk to read the visits of that day: each station then says `visit_status` (planned, visited or skipped) and `run` what is still open; without a day no visit is told. `derived` names every field worked out on reading rather than stored with the tour. The actual tracks linked to the tour (`activities`) each name their day, the recording (its tour in the trip, origin, file name, when it came in) and what that recording measures: distance, ascent and descent (unknown without heights) and the time from its first to its last time stamp, breaks included (unknown when the file carried no times, never estimated); the planned values stay apart and unchanged. Reading changes nothing, not even the revision. The full track geometry is not included | `trips:read` |
|
||||
| `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` |
|
||||
| `get_city_walk_progress` | How far a city walk got on one day it is on: every station in order with its key, its status that day (planned, visited or skipped, as checked off in the day plan), importance, protection, stay and opening hours, what is still open, and the walking time of the whole walk. Visits belong to that day only. Reading changes nothing | `trips:read` |
|
||||
| `get_city_walk_remaining_time` | What is left of a city walk on one day it is on, counted from the last station checked off as visited (or the start): the walking time still ahead along the saved line, the stays still open and the open stations, kept apart from the walking time of the whole walk. Says what it counts from and that no live position is known; an upper bound while a station ahead is skipped; unknown, with the reason, without a line, a walking time or a clear order of visits. Reading changes nothing | `trips:read` |
|
||||
| `export_tour_gpx` | One saved tour as a GPX file: its whole saved line as one track, with the heights it carries and none it lacks, and a file name. A tour without a line cannot be exported | `trips:read` |
|
||||
| `search_tour_pois` | Places along the saved line of a hike, run, ride, bike tour, paddle tour, ski and winter tour or city walk within a corridor (viewpoints, peaks, shelters, drinking water, food, toilets, picnic, campsites, sights, slipways, boat rental, bike repair, for a bike tour e-bike charging points, for a ski and winter tour coffee shops and places to eat as well, and for a city walk museums and galleries, parks and gardens, coffee shops, places to eat and markets): source (TREK places index or OpenStreetMap), kind, address, website, phone, opening hours as the source has them (a hint, never a promise), the distance straight across to the line (not a detour) and how far along the line; up to 60, the nearest to the line when more match, listed from the start. An e-bike charging point is one OpenStreetMap tags for bicycles (`bicycle=designated` or `yes`, never a charger for cars alone) and carries `charging`: bicycle, sockets, capacity, fee and access as OpenStreetMap states them, empty where it says nothing; whether it works or is free today is not known. When sights are asked for, the best known places near the line (by the page views of their Wikidata item) are searched too, so a famous square no category names is found as a sight, and the highlights among them carry `highlight: true` and come first. A place OpenStreetMap has no name for comes with an empty name. `unavailable` names the categories no source could be searched for right now, so their places are missing rather than absent. `retry_after_s`, when present beside `unavailable`, is how many seconds until asking again makes sense, because the OpenStreetMap mirrors failed and are only probed now and then. Says when stretches could not be searched, only the first part of a long line was, or more places matched. A tour saved with its points only (`route_pending`) has no line to search along yet. Nothing is added | `trips:read` |
|
||||
| `list_tour_library` | The saved tours of every trip the user can open, to choose one to take over into another trip: name, type, distance, ascent, descent, planned times and the trip each is in. Narrowed by a part of the name, a type or a trip; the trip being planned can be left out. Up to 100 tours, `truncated` says there were more | `trips:read` |
|
||||
|
||||
Reference in New Issue
Block a user