feat(tours): a hike's day with sun, weather and warnings, and the proposed line kept for create

This commit is contained in:
Maurice
2026-10-22 12:14:21 +02:00
parent 72ff9600f4
commit 621b08d444
31 changed files with 2405 additions and 186 deletions
+8 -1
View File
@@ -38,7 +38,11 @@ describe('a hike the server plans through its points', () => {
it('HIKE-PLAN-SCHEMA-001: takes two points and nothing else, and the limit, the preferences and the names it can be given', () => {
expect(hikePlanRequestSchema.safeParse({ waypoints: [point(0), point(1)] }).success).toBe(true);
const full = {
waypoints: [{ ...point(0), name: 'Parking' }, point(1), { ...point(2), name: 'Summit' }],
waypoints: [
{ ...point(0), name: 'Parking' },
{ ...point(1), poi_category: 'shelter' },
{ ...point(2), name: 'Summit', poi_category: 'peak' },
],
max_hiking_difficulty: 4,
hiking_preferences: { avoid_steps: true },
};
@@ -58,6 +62,9 @@ describe('a hike the server plans through its points', () => {
);
expect(hikePlanRequestSchema.safeParse({ waypoints: two, tripId: 1 }).success).toBe(false);
expect(hikePlanRequestSchema.safeParse({ waypoints: [{ lat: 91, lng: 0 }, point(1)] }).success).toBe(false);
expect(
hikePlanRequestSchema.safeParse({ waypoints: [{ ...point(0), poi_category: 'volcano' }, point(1)] }).success,
).toBe(false);
});
it('HIKE-PLAN-SCHEMA-003: reads a routed plan with or without heights and grades', () => {
+4 -1
View File
@@ -28,13 +28,16 @@ export const HIKE_PLAN_PROFILE_SAMPLES = 200;
*/
export const HIKE_MAX_LINE_POINTS = 10_000;
const { lat, lng, name } = tourWaypointSchema.shape;
const { lat, lng, name, poi_category } = tourWaypointSchema.shape;
export const hikePlanPointSchema = z
.object({
lat,
lng,
name: name.describe('What the point is, such as a car park, a hut or a summit, where it has a name'),
poi_category: poi_category.describe(
'What kind of place the point is, where it is one: peak for a summit, shelter for a hut, drinking_water, viewpoint',
),
})
.strict();
export type HikePlanPoint = z.infer<typeof hikePlanPointSchema>;
+192
View File
@@ -0,0 +1,192 @@
import type { HourlyEntry, WeatherResult } from '../weather/weather.schema';
import {
arrivalsOf,
HIKE_DAYLIGHT_HOURS,
HIKE_SCHEDULE_HOURS,
hikeScheduleOf,
hikeScheduleSchema,
hikeWarningSchema,
type HikeScheduleInput,
} from './hike-schedule';
import { describe, expect, it } from 'vitest';
/** A day's hours, clear, 10 % rain, 12 km/h of wind, with the hours `over` names changed. */
function hoursOf(over: Record<number, Partial<HourlyEntry>> = {}): HourlyEntry[] {
return Array.from({ length: 24 }, (_, hour) => ({
hour,
temp: 8 + hour / 2,
precipitation: 0,
precipitation_probability: 10,
main: 'Clear',
wind: 12.4,
humidity: 60,
...over[hour],
}));
}
/** A day's weather; a forecast says the day's highest likelihood of rain beside its hours, as Open-Meteo's does. */
function day(type: 'forecast' | 'climate', over: Record<number, Partial<HourlyEntry>> = {}): WeatherResult {
return {
type,
temp: 14,
main: 'Clear',
description: '',
sunrise: '06:30',
sunset: '19:00',
...(type === 'forecast' ? { precipitation_probability_max: 10 } : {}),
hourly: hoursOf(over),
};
}
const input = (over: Partial<HikeScheduleInput> = {}): HikeScheduleInput => ({
date: '2027-07-10',
dayId: 4,
start: '08:00',
minutes: 200,
weather: day('forecast'),
...over,
});
const kinds = (over: Partial<HikeScheduleInput>) => hikeScheduleOf(input(over)).warnings.map(({ kind }) => kind);
describe('the day of a hike with a start', () => {
it('HIKE-SCHED-001: arrives the walking time after the start, and shows the hours from one before to one after', () => {
const { schedule, warnings } = hikeScheduleOf(input());
expect(hikeScheduleSchema.parse(schedule)).toEqual(schedule);
expect(schedule).toMatchObject({
date: '2027-07-10',
day_id: 4,
start: '08:00',
arrive: '11:20',
next_day: false,
sunrise: '06:30',
sunset: '19:00',
latest_start: null,
weather: 'forecast',
});
expect(schedule.hours.map(({ hour }) => hour)).toEqual([7, 8, 9, 10, 11, 12]);
expect(schedule.hours[1]).toEqual({ hour: 8, main: 'Clear', temp_c: 12, rain_pct: 10, wind_kmh: 12 });
expect(warnings).toEqual([]);
});
it('HIKE-SCHED-002: warns of a start before sunrise and of an arrival after sunset, at the sun times', () => {
expect(hikeScheduleOf(input({ start: '05:45' })).warnings).toEqual([{ kind: 'dark_start', at: '06:30' }]);
const late = hikeScheduleOf(input({ start: '16:00' }));
expect(late.schedule.arrive).toBe('19:20');
expect(late.warnings).toEqual([{ kind: 'dark_end', at: '19:00' }]);
expect(kinds({ start: '04:00', minutes: 16 * 60 })).toEqual(['dark_start', 'dark_end']);
});
it('HIKE-SCHED-003: warns of dusk for an arrival within the hour before sunset, 59 minutes yes and 61 no', () => {
// A walk of 3 h 20 min against a sunset at 19:00.
expect(kinds({ start: '14:41' })).toEqual(['dusk']);
expect(kinds({ start: '14:39' })).toEqual([]);
expect(kinds({ start: '14:40' })).toEqual(['dusk']);
expect(kinds({ start: '15:40' })).toEqual(['dusk']);
expect(hikeScheduleOf(input({ start: '14:41' })).warnings).toEqual([{ kind: 'dusk', at: '19:00' }]);
});
it('HIKE-SCHED-004: warns of thunder and rain only in an hour walked, and only on a forecast', () => {
const stormy = { 10: { main: 'Thunderstorm' }, 13: { main: 'Thunderstorm' }, 9: { precipitation_probability: 60 } };
const { warnings } = hikeScheduleOf(input({ weather: day('forecast', stormy) }));
expect(warnings).toEqual([
{ kind: 'thunder', at: '10:00' },
{ kind: 'rain', at: '09:00' },
]);
for (const warning of warnings) expect(hikeWarningSchema.parse(warning)).toEqual(warning);
// A storm after the arrival (11:20) or before the start is no warning; rain at 59 % neither.
const outside = { 12: { main: 'Thunderstorm' }, 7: { main: 'Thunderstorm' }, 9: { precipitation_probability: 59 } };
expect(kinds({ weather: day('forecast', outside) })).toEqual([]);
// The hour the hike starts in warns from the start, not from the full hour.
expect(
hikeScheduleOf(input({ start: '08:30', weather: day('forecast', { 8: { main: 'Thunderstorm' } }) })).warnings,
).toEqual([{ kind: 'thunder', at: '08:30' }]);
// An arrival on the full hour walks nothing of the hour that follows.
expect(kinds({ minutes: 180, weather: day('forecast', { 11: { main: 'Thunderstorm' } }) })).toEqual([]);
const climate = hikeScheduleOf(input({ weather: day('climate', stormy) }));
expect(climate.warnings).toEqual([]);
expect(climate.schedule.weather).toBe('climate');
expect(climate.schedule.hours.every((hour) => hour.rain_pct === null)).toBe(true);
});
it('HIKE-SCHED-012: shows no likelihood of rain for a past day as observed, and warns of its storms only', () => {
// A past day is answered as a forecast without the day's highest likelihood, its hours at 0 %.
const { precipitation_probability_max: _max, ...observed } = day('forecast', {
9: { main: 'Thunderstorm', precipitation_probability: 0 },
10: { precipitation_probability: 90 },
});
const past = hikeScheduleOf(input({ weather: observed }));
expect(past.schedule.weather).toBe('forecast');
expect(past.schedule.hours.every((hour) => hour.rain_pct === null)).toBe(true);
expect(past.warnings).toEqual([{ kind: 'thunder', at: '09:00' }]);
});
it('HIKE-SCHED-005: puts an arrival past midnight on the next day, after sunset', () => {
const { schedule, warnings } = hikeScheduleOf(input({ start: '20:30', minutes: 240 }));
expect(schedule).toMatchObject({ arrive: '00:30', next_day: true });
expect(schedule.hours.map(({ hour }) => hour)).toEqual([19, 20, 21, 22, 23]);
expect(warnings.map(({ kind }) => kind)).toEqual(['dark_end']);
});
it('HIKE-SCHED-006: shows at most the first hours of a very long hike', () => {
const { schedule } = hikeScheduleOf(input({ start: '00:10', minutes: 23 * 60 }));
expect(schedule.hours).toHaveLength(HIKE_SCHEDULE_HOURS);
expect(schedule.hours[0]?.hour).toBe(0);
});
});
describe('the day of a hike without a start', () => {
it('HIKE-SCHED-007: says the latest start that ends half an hour before sunset, with every second hour of daylight', () => {
const { schedule, warnings } = hikeScheduleOf(input({ start: null }));
expect(schedule).toMatchObject({ start: null, arrive: null, next_day: false, latest_start: '15:10' });
expect(schedule.hours.map(({ hour }) => hour)).toEqual([7, 9, 11, 13, 15, 17]);
expect(warnings).toEqual([]);
const long = hikeScheduleOf(
input({ start: null, weather: { ...day('forecast'), sunrise: '04:00', sunset: '22:00' } }),
);
expect(long.schedule.hours).toHaveLength(HIKE_DAYLIGHT_HOURS);
});
it('HIKE-SCHED-008: has no latest start where the hike does not fit into the daylight', () => {
expect(hikeScheduleOf(input({ start: null, minutes: 720 })).schedule.latest_start).toBe('06:30');
expect(hikeScheduleOf(input({ start: null, minutes: 721 })).schedule.latest_start).toBeNull();
});
});
describe('a hike whose weather could not be had', () => {
it('HIKE-SCHED-009: keeps the start and arrival, without sun, hours or warnings', () => {
for (const weather of [null, { temp: 0, main: '', description: '', type: '', error: 'no_forecast' }]) {
const { schedule, warnings } = hikeScheduleOf(input({ start: '05:00', weather }));
expect(schedule).toEqual({
date: '2027-07-10',
day_id: 4,
start: '05:00',
arrive: '08:20',
next_day: false,
sunrise: null,
sunset: null,
latest_start: null,
weather: null,
hours: [],
});
expect(warnings).toEqual([]);
}
const unknown = hikeScheduleOf(input({ start: null, weather: { ...day('forecast'), type: 'current' } }));
expect(unknown.schedule).toMatchObject({ weather: null, latest_start: null, hours: [] });
});
it('HIKE-SCHED-010: reads no sun from a time it cannot read, and no hours without the sun', () => {
const { schedule } = hikeScheduleOf(
input({ start: null, weather: { ...day('forecast'), sunrise: '', sunset: null } }),
);
expect(schedule).toMatchObject({ sunrise: null, sunset: null, latest_start: null, hours: [] });
});
});
describe('when each point is reached', () => {
it('HIKE-SCHED-011: adds the walking minutes to the start, past midnight onto the next day, none without a start', () => {
expect(arrivalsOf('08:00', [0, 95, null, 1000])).toEqual(['08:00', '09:35', null, '00:40']);
expect(arrivalsOf(null, [0, 95])).toEqual([null, null]);
});
});
+216
View File
@@ -0,0 +1,216 @@
import { idSchema } from '../common/primitives.schema';
import { clockTimeSchema } from '../place/place-hours';
import type { WeatherResult } from '../weather/weather.schema';
import { z } from 'zod';
/**
* A hike's day: when it starts and arrives, the sun, the weather by the hour
* and what to look out for, from the day's detailed weather at one point of
* the way (its highest where the heights are known). Pure: the server asks the
* weather and hands it in, the rules are here once.
*
* - The arrival is the start plus the walking time; past midnight it is on
* the next day.
* - With a start, the hours run from the one before it to the one after the
* arrival. Without one, every second hour of daylight is shown, and the
* latest start that still ends half an hour before sunset (none where that
* lies before sunrise).
* - A start before sunrise, an arrival after sunset or within the hour before
* it are warned of; so is the first walking hour with a thunderstorm or
* rain more likely than not, on a forecast only, since a day further out is
* read from last year's weather.
* - How likely rain is shows only where a forecast says it; a past day,
* answered as observed, shows none and warns of no rain.
* - Without the weather there are no sun times, no hours and no warnings; the
* start and the arrival still stand.
*/
/** The most hours a schedule shows; a longer hike shows its first ones. */
export const HIKE_SCHEDULE_HOURS = 18;
/** The most hours of daylight shown for a hike without a start, every second one. */
export const HIKE_DAYLIGHT_HOURS = 8;
/** How long before sunset an arrival is close enough to dusk to be warned of, in minutes. */
export const HIKE_DUSK_MINUTES = 60;
/** How long before sunset the latest start has a hike back, in minutes. */
export const HIKE_SUNSET_MARGIN_MINUTES = 30;
/** How likely rain is, in percent, before a walking hour warns of it. */
export const HIKE_RAIN_PCT = 60;
/** The weather a schedule warns of: thunderstorms, rain, an end after dark, a start before light, an end at dusk. */
export const HIKE_WARNING_KINDS = ['thunder', 'rain', 'dark_end', 'dark_start', 'dusk'] as const;
const MINUTES_A_DAY = 24 * 60;
export const hikeScheduleHourSchema = z.object({
hour: z.number().int().min(0).max(23),
/** The hour's weather in one word, as the weather of a day names it (Clear, Rain, Thunderstorm). */
main: z.string(),
temp_c: z.number().int(),
/** Null where the day's weather knows no likelihood: last year's weather, or a past day as observed. */
rain_pct: z.number().int().min(0).max(100).nullable(),
wind_kmh: z.number().int().nonnegative().nullable(),
});
export type HikeScheduleHour = z.infer<typeof hikeScheduleHourSchema>;
export const hikeScheduleSchema = z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
/** The trip's day the hike is planned on; null where only a date was given. */
day_id: idSchema.nullable(),
start: clockTimeSchema.nullable(),
arrive: clockTimeSchema.nullable(),
/** The arrival falls on the day after the start. */
next_day: z.boolean(),
sunrise: clockTimeSchema.nullable(),
sunset: clockTimeSchema.nullable(),
/** Without a start: the latest one that ends half an hour before sunset; null where none does. */
latest_start: clockTimeSchema.nullable(),
/** A forecast, last year's weather for a day further out (climate), or none to be had. */
weather: z.enum(['forecast', 'climate']).nullable(),
hours: z.array(hikeScheduleHourSchema).max(HIKE_SCHEDULE_HOURS),
});
export type HikeSchedule = z.infer<typeof hikeScheduleSchema>;
export const hikeWarningSchema = z.object({
kind: z.enum(HIKE_WARNING_KINDS),
/** The clock it is about: the first such hour, or the sunrise or sunset passed. */
at: clockTimeSchema.nullable(),
});
export type HikeWarning = z.infer<typeof hikeWarningSchema>;
export interface HikeScheduleInput {
date: string;
dayId: number | null;
/** HH:MM, or null where the hike has no start yet. */
start: string | null;
/** The walking time. */
minutes: number;
/** The day's detailed weather, or null where it could not be had. */
weather: WeatherResult | null;
}
/** Minutes after midnight of a clock, null where it is no clock. */
function minutesOf(clock: string | null | undefined): number | null {
if (!clock || !clockTimeSchema.safeParse(clock).success) return null;
return Number(clock.slice(0, 2)) * 60 + Number(clock.slice(3, 5));
}
/** A time of day as a clock, past midnight wrapped onto the next day. */
function clockOf(minutes: number): string {
const within = ((Math.round(minutes) % MINUTES_A_DAY) + MINUTES_A_DAY) % MINUTES_A_DAY;
return `${String(Math.floor(within / 60)).padStart(2, '0')}:${String(within % 60).padStart(2, '0')}`;
}
/** The kind of weather a day was answered with, or null for no weather. */
function weatherKindOf(weather: WeatherResult | null): HikeSchedule['weather'] {
if (!weather || weather.error) return null;
return weather.type === 'forecast' || weather.type === 'climate' ? weather.type : null;
}
function hourOf(entry: NonNullable<WeatherResult['hourly']>[number], likelihood: boolean): HikeScheduleHour {
return {
hour: entry.hour,
main: entry.main,
temp_c: Math.round(entry.temp),
rain_pct: likelihood ? Math.max(0, Math.min(100, Math.round(entry.precipitation_probability))) : null,
wind_kmh: Number.isFinite(entry.wind) ? Math.max(0, Math.round(entry.wind)) : null,
};
}
/** The hours from `first` to `last` the weather has, every `step`, at most `most`. */
function hoursBetween(hourly: readonly HikeScheduleHour[], first: number, last: number, step: number, most: number) {
return hourly
.filter((entry) => entry.hour >= first && entry.hour <= last && (entry.hour - first) % step === 0)
.slice(0, most);
}
/** The first hour walked whose weather matches, as the clock it starts at (never before the start). */
function firstWalked(
hourly: readonly HikeScheduleHour[],
start: number,
arrive: number,
matches: (hour: HikeScheduleHour) => boolean,
) {
const first = Math.floor(start / 60);
const last = Math.max(first, Math.ceil(arrive / 60) - 1);
const hit = hourly.find((entry) => entry.hour >= first && entry.hour <= last && matches(entry));
return hit ? clockOf(Math.max(hit.hour * 60, start)) : null;
}
/** The warnings of a hike with a start, in the order they matter most: the dark first. */
function warningsOf(
start: number,
arrive: number,
sun: { rise: number | null; set: number | null },
hourly: readonly HikeScheduleHour[],
kind: HikeSchedule['weather'],
): HikeWarning[] {
const warnings: HikeWarning[] = [];
if (sun.rise !== null && start < sun.rise) warnings.push({ kind: 'dark_start', at: clockOf(sun.rise) });
if (sun.set !== null && arrive > sun.set) warnings.push({ kind: 'dark_end', at: clockOf(sun.set) });
else if (sun.set !== null && sun.set - arrive <= HIKE_DUSK_MINUTES)
warnings.push({ kind: 'dusk', at: clockOf(sun.set) });
if (kind !== 'forecast') return warnings;
const thunder = firstWalked(hourly, start, arrive, (hour) => hour.main === 'Thunderstorm');
if (thunder) warnings.push({ kind: 'thunder', at: thunder });
const rain = firstWalked(hourly, start, arrive, (hour) => (hour.rain_pct ?? 0) >= HIKE_RAIN_PCT);
if (rain) warnings.push({ kind: 'rain', at: rain });
return warnings;
}
/** A hike's day by the rules above: its schedule, and what to look out for. */
export function hikeScheduleOf(input: HikeScheduleInput): { schedule: HikeSchedule; warnings: HikeWarning[] } {
const kind = weatherKindOf(input.weather);
const known = kind === null ? null : input.weather;
// A forecast says how likely rain is, with the day's highest likelihood beside its hours. Last year's
// weather does not, nor a past day as observed, which is answered as a forecast without it.
const likelihood = known?.type === 'forecast' && known.precipitation_probability_max !== undefined;
const hourly = (known?.hourly ?? []).map((entry) => hourOf(entry, likelihood));
const sun = { rise: minutesOf(known?.sunrise), set: minutesOf(known?.sunset) };
const start = minutesOf(input.start);
const minutes = Math.max(0, Math.round(input.minutes));
const base = {
date: input.date,
day_id: input.dayId,
sunrise: sun.rise === null ? null : clockOf(sun.rise),
sunset: sun.set === null ? null : clockOf(sun.set),
weather: kind,
};
if (start === null) {
const latest = sun.set === null ? null : sun.set - HIKE_SUNSET_MARGIN_MINUTES - minutes;
const daylight =
sun.rise === null || sun.set === null
? []
: hoursBetween(hourly, Math.ceil(sun.rise / 60), Math.ceil(sun.set / 60) - 1, 2, HIKE_DAYLIGHT_HOURS);
return {
schedule: {
...base,
start: null,
arrive: null,
next_day: false,
latest_start: latest !== null && sun.rise !== null && latest >= sun.rise ? clockOf(latest) : null,
hours: daylight,
},
warnings: [],
};
}
const arrive = start + minutes;
const first = Math.floor(start / 60) - 1;
const last = Math.floor(arrive / 60) + 1;
return {
schedule: {
...base,
start: clockOf(start),
arrive: clockOf(arrive),
next_day: arrive >= MINUTES_A_DAY,
latest_start: null,
hours: hoursBetween(hourly, first, last, 1, HIKE_SCHEDULE_HOURS),
},
warnings: warningsOf(start, arrive, sun, hourly, kind),
};
}
/** The clock of each point reached `minutes` after a start, null for every point without one. */
export function arrivalsOf(start: string | null, minutes: ReadonlyArray<number | null>): Array<string | null> {
const from = minutesOf(start);
return minutes.map((walked) => (from === null || walked === null ? null : clockOf(from + walked)));
}
+99
View File
@@ -0,0 +1,99 @@
import { haversineMetres } from '../geo/haversine';
import {
cumulativeMetres,
legMinutes,
nearestAlongM,
pointsPerLeg,
pointsPerLine,
type AlongPoint,
} from './line-along';
import { describe, expect, it } from 'vitest';
/** A point `k` steps of a ten-thousandth of a degree north of 47° N on 11° E, about 11 m each, at `height`. */
const north = (k: number, height?: number): AlongPoint =>
height === undefined ? [47 + k / 1e4, 11] : [47 + k / 1e4, 11, height];
const STEP_M = haversineMetres(47, 11, 47.0001, 11);
describe('metres along a line', () => {
it('LINE-ALONG-001: measures each point from the start, the first at nothing', () => {
const along = cumulativeMetres([north(0), north(1), north(3)]);
expect(along[0]).toBe(0);
expect(along[1]).toBeCloseTo(STEP_M, 6);
expect(along[2]).toBeCloseTo(3 * STEP_M, 6);
expect(cumulativeMetres([])).toEqual([]);
});
it('LINE-ALONG-002: finds the spot nearest a point beside the line, and holds it to the line ends', () => {
const line = [north(0), north(10), north(20)];
expect(nearestAlongM(line, 47.0005, 11.0003)).toBeCloseTo(5 * STEP_M, 0);
expect(nearestAlongM(line, 47.0015, 10.9998)).toBeCloseTo(15 * STEP_M, 0);
expect(nearestAlongM(line, 46.99, 11)).toBe(0);
expect(nearestAlongM(line, 47.01, 11)).toBeCloseTo(20 * STEP_M, 6);
expect(nearestAlongM([north(0)], 47, 11)).toBe(0);
expect(nearestAlongM([north(0), north(0)], 47, 11)).toBe(0);
});
it('LINE-ALONG-003: on a loop back to its start, places the start on the first pass and the far side halfway', () => {
const loop: AlongPoint[] = [
[47, 11],
[47.001, 11],
[47.001, 11.0015],
[47, 11.0015],
[47, 11],
];
const along = cumulativeMetres(loop);
const total = along[along.length - 1] ?? 0;
expect(nearestAlongM(loop, 47.00001, 11.00001)).toBeLessThan(5);
expect(nearestAlongM(loop, 47.0011, 11.0016)).toBeCloseTo(along[2] ?? 0, 0);
expect(along[2]).toBeCloseTo(total / 2, 0);
});
});
describe('the walking minutes of each leg', () => {
it('LINE-ALONG-004: splits the whole time over the legs, a climbing leg longer than a flat one, summing to the total', () => {
// Flat for 10 steps, then 10 steps climbing 30 m each: 300 m up.
const line = [north(0, 500), north(10, 500), ...Array.from({ length: 10 }, (_, k) => north(11 + k, 530 + 30 * k))];
const along = cumulativeMetres(line);
const atMs = [0, along[1] ?? 0, along[along.length - 1] ?? 0];
const legs = legMinutes(line, atMs, 75);
expect(legs).toHaveLength(2);
expect(legs.reduce((sum, leg) => sum + leg, 0)).toBe(75);
expect(legs[1]).toBeGreaterThan(3 * (legs[0] ?? 0));
expect(legs.every(Number.isInteger)).toBe(true);
});
it('LINE-ALONG-005: weighs legs without heights by their length, and rounds a time that is not whole', () => {
const line = [north(0), north(10), north(40)];
const legs = legMinutes(line, [0, 10 * STEP_M, 40 * STEP_M], 40.4);
expect(legs).toEqual([10, 30]);
// A leg as short as a few metres keeps a share of its own rather than none.
expect(legMinutes(line, [0, 3, 40 * STEP_M], 1000)[0]).toBeGreaterThan(0);
});
it('LINE-ALONG-006: splits evenly where no leg has any length, and gives nothing for a single point', () => {
const line = [north(0, 500), north(1, 510)];
expect(legMinutes(line, [0, 0, 0], 7)).toEqual([4, 3]);
expect(legMinutes(line, [0], 7)).toEqual([]);
expect(legMinutes(line, [0, STEP_M], -3)).toEqual([0]);
});
});
describe('the points each line gets for drawing', () => {
it('LINE-ALONG-007: shares the points by length, two at least for each, never more than a line has', () => {
const shares = pointsPerLine([1000, 3000], 100);
expect(shares).toEqual([26, 74]);
expect(pointsPerLine([1000, 3000], 100, [10, 5000])).toEqual([10, 74]);
expect(pointsPerLine([0, 0, 0], 4)).toEqual([2, 2, 2]);
expect(pointsPerLine([500, 0], 10)).toEqual([8, 2]);
expect(pointsPerLine([], 300)).toEqual([]);
});
it('LINE-ALONG-008: shares the points over the planned legs in order, a leg not planned drawing none', () => {
const legs = [{ lengthM: 1000, points: 5000 }, null, { lengthM: 3000, points: 5000 }];
expect(pointsPerLeg(legs, 100)).toEqual([26, null, 74]);
expect(pointsPerLeg([null, null], 300)).toEqual([null, null]);
expect(pointsPerLeg([{ lengthM: 10, points: 3 }], 300)).toEqual([3]);
expect(pointsPerLeg([], 300)).toEqual([]);
});
});
+172
View File
@@ -0,0 +1,172 @@
import { haversineMetres } from '../geo/haversine';
import { hikingTimeMinutes } from './tour-time-estimate';
/**
* Measures along a planned line, in metres from its start, the way the
* server answers a planned way and a chat card draws it: how far along each
* point lies, where along the line a point beside it is nearest, and how a
* walking time splits over the legs between the points a way was planned
* through. Pure, for the server and the client alike.
*/
const RAD = Math.PI / 180;
/** A point of a line, `[lat, lng]`, with its height in metres where the line has one (null where it is unknown). */
export type AlongPoint = readonly [number, number, ...Array<number | null>];
/** One stretch of a line: its two ends and how far along the line each lies. */
interface Stretch {
a: AlongPoint;
b: AlongPoint;
from: number;
to: number;
}
/** Metres along the line at each of its points, the first at 0. */
export function cumulativeMetres(line: readonly AlongPoint[]): number[] {
let along = 0;
let before: AlongPoint | undefined;
return line.map((point) => {
if (before) along += haversineMetres(before[0], before[1], point[0], point[1]);
before = point;
return along;
});
}
/** The stretches of a line in order, each with where along the line it starts and ends. */
function stretchesOf(line: readonly AlongPoint[]): Stretch[] {
const along = cumulativeMetres(line);
const stretches: Stretch[] = [];
line.forEach((b, index) => {
const a = line[index - 1];
if (a) stretches.push({ a, b, from: along[index - 1] ?? 0, to: along[index] ?? 0 });
});
return stretches;
}
/**
* Metres along the line to the spot of it nearest to a point, measured as
* cumulativeMetres measures the line. Where the line passes the point twice
* (a loop back to its start, a summit walked up and down one path) the first
* pass counts. A line of fewer than two points gives 0.
*/
export function nearestAlongM(line: readonly AlongPoint[], lat: number, lng: number): number {
// Laid flat around the point: east scaled by the latitude, so the nearest spot is found as on a map.
const east = Math.cos(lat * RAD);
let best = { gap: Number.POSITIVE_INFINITY, at: 0 };
for (const { a, b, from, to } of stretchesOf(line)) {
const dx = (b[1] - a[1]) * east;
const dy = b[0] - a[0];
const px = (lng - a[1]) * east;
const py = lat - a[0];
const span = dx * dx + dy * dy;
const t = span === 0 ? 0 : Math.max(0, Math.min(1, (px * dx + py * dy) / span));
const gap = Math.hypot(px - t * dx, py - t * dy);
if (gap < best.gap) best = { gap, at: from + t * (to - from) };
}
return best.at;
}
/** The height of a point, null where it has none. */
function heightOf(point: AlongPoint): number | null {
const height = point[2];
return typeof height === 'number' && Number.isFinite(height) ? height : null;
}
/** The climb and descent between two places along the line, each stretch counted for its part between them. */
function climbBetween(stretches: readonly Stretch[], from: number, to: number) {
let climb = 0;
let descent = 0;
for (const stretch of stretches) {
const inside = Math.min(to, stretch.to) - Math.max(from, stretch.from);
const start = heightOf(stretch.a);
const end = heightOf(stretch.b);
if (inside <= 0 || stretch.to <= stretch.from || start === null || end === null) continue;
const change = ((end - start) * inside) / (stretch.to - stretch.from);
if (change > 0) climb += change;
else descent -= change;
}
return { climb, descent };
}
/**
* Whole numbers in proportion to `weights` that sum to `total` exactly: each
* share rounded down, what is left over handed to the largest remainders.
* Weights that are all nothing split the total evenly.
*/
function sharesOf(weights: readonly number[], total: number): number[] {
const sum = weights.reduce((all, weight) => all + weight, 0);
const parts = sum > 0 ? weights : weights.map(() => 1);
const whole = parts.reduce((all, weight) => all + weight, 0);
const exact = parts.map((weight) => (whole > 0 ? (weight * total) / whole : 0));
const shares = exact.map((value) => Math.floor(value));
let left = total - shares.reduce((all, share) => all + share, 0);
const order = exact
.map((value, index) => ({ index, rest: value - Math.floor(value) }))
.sort((a, b) => b.rest - a.rest);
for (const { index } of order) {
if (left <= 0) break;
shares[index] = (shares[index] ?? 0) + 1;
left -= 1;
}
return shares;
}
/**
* The walking minutes of each leg between the points a way was planned
* through (`atMs`, metres along the line in order), as the signposts' rule
* (DIN 33466, hikingTimeMinutes) weighs the leg's length, climb and descent,
* scaled so the legs sum to the way's whole time `total`. A line without
* heights weighs the legs by their length alone. One leg fewer than points.
*/
export function legMinutes(line: readonly AlongPoint[], atMs: readonly number[], total: number): number[] {
const stretches = stretchesOf(line);
const weights = atMs.slice(1).map((to, index) => {
const from = atMs[index] ?? 0;
const length = Math.max(0, to - from);
const { climb, descent } = climbBetween(stretches, from, from + length);
// The rule rounds to whole minutes and scales with the leg, so it is asked of a leg a thousand times
// as long and the answer shrunk back: a short leg keeps the seconds it walks instead of none.
return hikingTimeMinutes(length, climb * 1000, descent * 1000) / 1000;
});
return sharesOf(weights, Math.max(0, Math.round(total)));
}
/**
* How many of `most` points each of several lines gets for drawing, by its
* length: at least two each, so every line keeps its ends, and never more
* than its own points (`available`) where those are given.
*/
export function pointsPerLine(
lengths: readonly number[],
most: number,
available: readonly number[] = lengths.map(() => Number.POSITIVE_INFINITY),
): number[] {
const spare = Math.max(0, most - 2 * lengths.length);
const extra = sharesOf(
lengths.map((length) => Math.max(0, length)),
spare,
);
return extra.map((share, index) => Math.max(2, Math.min(available[index] ?? 2, 2 + share)));
}
/** A planned leg as pointsPerLeg weighs it: its length in metres and how many points its line holds. */
export interface DrawnLeg {
lengthM: number;
points: number;
}
/**
* How many of `most` points each leg of a plan draws with, shared by length
* as pointsPerLine shares them, in the order of the legs. A leg that was not
* planned (null) draws nothing and takes no share.
*/
export function pointsPerLeg(legs: ReadonlyArray<DrawnLeg | null>, most: number): Array<number | null> {
const drawn = legs.filter((leg): leg is DrawnLeg => leg !== null);
const shares = pointsPerLine(
drawn.map((leg) => leg.lengthM),
most,
drawn.map((leg) => leg.points),
).reverse();
return legs.map((leg) => (leg === null ? null : (shares.pop() ?? null)));
}
@@ -0,0 +1,14 @@
import { includeGeometryFlagSchema, TOUR_DISPLAY_LINE_POINTS } from './tour-display.schema';
import { describe, expect, it } from 'vitest';
describe('the line a planning tool adds for drawing', () => {
it('TOUR-DISPLAY-001: is asked for with a yes or a no, or left out, and says so to a model', () => {
expect(includeGeometryFlagSchema.parse(true)).toBe(true);
expect(includeGeometryFlagSchema.parse(false)).toBe(false);
expect(includeGeometryFlagSchema.parse(undefined)).toBeUndefined();
expect(includeGeometryFlagSchema.safeParse('true').success).toBe(false);
expect(includeGeometryFlagSchema.description).toContain('line_geometry');
expect(TOUR_DISPLAY_LINE_POINTS).toBe(300);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { z } from 'zod';
/**
* What a planning tool answers for drawing. A tool that plans a way (a hike,
* a city walk, a paddle or a ski tour) can add the line it planned, thinned
* for display, under a key holding `geometry`, so a chat card draws it. Only
* when asked: a client that reads the answer as text has no use for a few
* hundred coordinates, and the assistant hides every such key from the model.
*/
/** The flag a planning tool takes to add its line for drawing. */
export const includeGeometryFlagSchema = z
.boolean()
.optional()
.describe('Adds the line for drawing as line_geometry; leave out');
/** The most points of a line a planning tool answers for drawing, all its legs together. */
export const TOUR_DISPLAY_LINE_POINTS = 300;
+78 -21
View File
@@ -1,5 +1,6 @@
import { HIKE_PLAN_LINE_POINTS } from './hike-plan.schema';
import {
HIKE_ALONG_MAX,
HIKE_MAX_WAYPOINTS,
HIKE_PROPOSAL_SAC_SEGMENTS,
hikeCreateRequestSchema,
@@ -29,38 +30,75 @@ const proposal = {
heights_known: true,
min_ele_m: 720,
max_ele_m: 1360,
line: [
line_geometry: [
[47.4, 11.1, 720],
[47.41, 11.1, 1360],
],
profile: [
profile_geometry: [
[0, 720],
[8200, 1360],
],
sac: { highest: 2, segments: [{ from_m: 0, to_m: 1200, sac: 2 }], unknown_share: 0.4 },
high_point: { lat: 47.41, lng: 11.1, ele_m: 1360, at_m: 8200 },
waypoints: [
{ lat: 47.4, lng: 11.1, name: 'Parking', at_m: 0 },
{ lat: 47.41, lng: 11.1, name: null, at_m: 8200 },
{
lat: 47.4,
lng: 11.1,
name: 'Parking',
poi_category: null,
at_m: 0,
snapped_m: 12,
minutes: 0,
arrival: '08:30',
},
{
lat: 47.41,
lng: 11.1,
name: null,
poi_category: 'peak',
at_m: 8200,
snapped_m: 4,
minutes: 200,
arrival: '11:50',
},
],
along: [{ name: 'Hut', category: 'shelter', lat: 47.405, lng: 11.101, at_m: 4100, off_m: 40 }],
max_hiking_difficulty: 3,
hiking_preferences: null,
round_trip: false,
day_id: 7,
date: '2027-07-10',
start_time: '08:30',
schedule: {
date: '2027-07-10',
day_id: 7,
start: '08:30',
arrive: '11:50',
next_day: false,
sunrise: '05:31',
sunset: '21:10',
latest_start: null,
weather: 'forecast',
hours: [{ hour: 8, main: 'Clear', temp_c: 14, rain_pct: 10, wind_kmh: 8 }],
},
warnings: [{ kind: 'thunder', at: '11:00' }],
};
describe('a hike proposed over MCP', () => {
it('TOUR-HIKE-SCHEMA-001: takes two points and everything a proposal can be asked with', () => {
expect(hikeProposeRequestSchema.safeParse({ waypoints: two }).success).toBe(true);
it('TOUR-HIKE-SCHEMA-001: takes a name, two points and everything a proposal can be asked with', () => {
expect(hikeProposeRequestSchema.safeParse({ name: 'Herzogstand', waypoints: two }).success).toBe(true);
const full = {
name: 'Herzogstand',
waypoints: [{ ...point(0), name: 'Parking' }, point(1), { ...point(2), name: 'Summit' }],
waypoints: [{ ...point(0), name: 'Parking' }, point(1), { ...point(2), name: 'Summit', poi_category: 'peak' }],
max_hiking_difficulty: 4,
hiking_preferences: { avoid_steps: true },
round_trip: true,
variant_label: 'by the lake',
day_id: 7,
date: '2027-07-10',
start_time: '08:30',
along: false,
include_geometry: true,
};
expect(hikeProposeRequestSchema.parse(full)).toEqual(full);
expect(hikeProposeRequestSchema.safeParse({ ...full, hiking_preferences: null }).success).toBe(true);
@@ -69,15 +107,19 @@ describe('a hike proposed over MCP', () => {
it('TOUR-HIKE-SCHEMA-002: refuses one point, too many, a long label, a date or a time it cannot read, and a key it does not know', () => {
const many = Array.from({ length: HIKE_MAX_WAYPOINTS + 1 }, (_, index) => point(index));
for (const request of [
{ waypoints: [point(0)] },
{ waypoints: many },
{ waypoints: two, variant_label: 'x'.repeat(41) },
{ waypoints: two, variant_label: ' ' },
{ waypoints: two, date: '10.07.2027' },
{ waypoints: two, start_time: '8:30' },
{ waypoints: two, start_time: '24:00' },
{ waypoints: two, max_hiking_difficulty: 7 },
{ waypoints: two, tripId: 1 },
{ waypoints: two },
{ name: 'X', waypoints: [point(0)] },
{ name: 'X', waypoints: many },
{ name: 'X', waypoints: two, variant_label: 'x'.repeat(41) },
{ name: 'X', waypoints: two, variant_label: ' ' },
{ name: 'X', waypoints: two, date: '10.07.2027' },
{ name: 'X', waypoints: two, start_time: '8:30' },
{ name: 'X', waypoints: two, start_time: '24:00' },
{ name: 'X', waypoints: two, max_hiking_difficulty: 7 },
{ name: 'X', waypoints: two, day_id: 0 },
{ name: 'X', waypoints: two, include_geometry: 'yes' },
{ name: 'X', waypoints: [point(0), { ...point(1), poi_category: 'volcano' }] },
{ name: 'X', waypoints: two, tripId: 1 },
]) {
expect(hikeProposeRequestSchema.safeParse(request).success, JSON.stringify(request).slice(0, 80)).toBe(false);
}
@@ -97,17 +139,29 @@ describe('a hike proposed over MCP', () => {
heights_known: false,
min_ele_m: null,
max_ele_m: null,
line: [],
profile: [],
line_geometry: undefined,
profile_geometry: undefined,
sac: null,
waypoints: proposal.waypoints.map((waypoint) => ({ ...waypoint, at_m: null })),
high_point: null,
along: null,
waypoints: proposal.waypoints.map((waypoint) => ({ ...waypoint, at_m: null, snapped_m: null, minutes: null })),
schedule: null,
warnings: [],
};
expect(hikeProposalSchema.safeParse(unrouted).success).toBe(true);
const { line_geometry: _line, profile_geometry: _profile, ...withoutGeometry } = proposal;
expect(hikeProposalSchema.safeParse(withoutGeometry).success).toBe(true);
});
it('TOUR-HIKE-SCHEMA-004: keeps a proposal within what a card draws', () => {
const long = Array.from({ length: HIKE_PLAN_LINE_POINTS + 1 }, () => [47.4, 11.1]);
expect(hikeProposalSchema.safeParse({ ...proposal, line: long }).success).toBe(false);
expect(hikeProposalSchema.safeParse({ ...proposal, line_geometry: long }).success).toBe(false);
const crowded = Array.from({ length: HIKE_ALONG_MAX + 1 }, () => proposal.along[0]);
expect(hikeProposalSchema.safeParse({ ...proposal, along: crowded }).success).toBe(false);
expect(hikeProposalSchema.safeParse({ ...proposal, warnings: [{ kind: 'snow', at: null }] }).success).toBe(false);
expect(
hikeProposalSchema.safeParse({ ...proposal, waypoints: [{ ...proposal.waypoints[0], arrival: '8:30' }] }).success,
).toBe(false);
const segments = Array.from({ length: HIKE_PROPOSAL_SAC_SEGMENTS + 1 }, (_, index) => ({
from_m: index,
to_m: index + 1,
@@ -119,7 +173,7 @@ describe('a hike proposed over MCP', () => {
false,
);
expect(hikeProposalSchema.safeParse({ ...proposal, duration_basis: 'guess' }).success).toBe(false);
expect(hikeProposalSchema.safeParse({ ...proposal, line: [[47.4, 191]] }).success).toBe(false);
expect(hikeProposalSchema.safeParse({ ...proposal, line_geometry: [[47.4, 191]] }).success).toBe(false);
expect(
hikeProposalSchema.safeParse({ ...proposal, waypoints: [{ ...proposal.waypoints[0], lat: -91 }] }).success,
).toBe(false);
@@ -146,6 +200,8 @@ describe('a hike created, changed or taken in from a file over MCP', () => {
hiking_preferences: { avoid_climbs: true },
round_trip: true,
planned_duration_minutes: 360,
day_id: 3,
start_time: '08:00',
}).success,
).toBe(true);
expect(hikeCreateRequestSchema.safeParse({ waypoints: two }).success).toBe(false);
@@ -154,6 +210,7 @@ describe('a hike created, changed or taken in from a file over MCP', () => {
hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: two, planned_duration_minutes: 1441 }).success,
).toBe(false);
expect(hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: two, variant_label: 'a' }).success).toBe(false);
expect(hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: two, start_time: '25:00' }).success).toBe(false);
});
it('TOUR-HIKE-SCHEMA-007: changes a hike only against the revision it was read at', () => {
+96 -26
View File
@@ -11,7 +11,10 @@ import {
hikeSacSegmentSchema,
hikeTrailGradesSchema,
} from './hike-plan.schema';
import { hikeScheduleSchema, hikeWarningSchema, HIKE_WARNING_KINDS } from './hike-schedule';
import { includeGeometryFlagSchema } from './tour-display.schema';
import { tourGpxImportFieldsSchema } from './tour-gpx-import.schema';
import { tourPoiCategorySchema, type TourPoiCategory } from './tour-poi-category';
import {
hikingPreferencesSchema,
plannedTourDurationMinutesSchema,
@@ -36,7 +39,18 @@ export const HIKE_MAX_WAYPOINTS = 25;
/** The longest label a variant is given, short enough for a chip that compares two ways. */
export const HIKE_VARIANT_LABEL_MAX = 40;
/** The most graded stretches a proposal shows; more are joined, the harder grade kept. */
export const HIKE_PROPOSAL_SAC_SEGMENTS = 200;
export const HIKE_PROPOSAL_SAC_SEGMENTS = 80;
/** The kinds of places a proposal looks for along its way. */
export const HIKE_ALONG_CATEGORIES = [
'peak',
'shelter',
'drinking_water',
'viewpoint',
] as const satisfies readonly TourPoiCategory[];
/** How far from the way a place along it may lie, in metres. */
export const HIKE_ALONG_CORRIDOR_M = 250;
/** The most places along the way a proposal names, the nearest to it. */
export const HIKE_ALONG_MAX = 12;
/** The largest GPX text a tool takes in, as the road trip's GPX import does. */
export const TOUR_GPX_TEXT_MAX = 1_000_000;
@@ -62,11 +76,20 @@ const plannedMinutesSchema = plannedTourDurationMinutesSchema.describe(
'How long the travellers plan to be out, in minutes, breaks included; left out the walking time stands alone',
);
const hikeDateSchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.describe(
'The day the hike is planned for, YYYY-MM-DD, where it is on no day of the trip; day_id takes its own date',
);
const hikeDayIdSchema = idSchema.describe('The day of the trip the hike is planned on, as list_days gives it');
const hikeStartTimeSchema = clockTimeSchema.describe('When the hike starts, HH:MM');
export const hikeProposeRequestSchema = z
.object({
name: tourNameSchema
.optional()
.describe('What the hike is called, such as "Over the Herzogstand to the Heimgarten"'),
name: tourNameSchema.describe('What the hike is called, such as "Over the Herzogstand to the Heimgarten"'),
waypoints: hikeWaypointsSchema,
max_hiking_difficulty: hikeLimitSchema.optional(),
hiking_preferences: hikePreferencesSchema.optional(),
@@ -80,12 +103,16 @@ export const hikeProposeRequestSchema = z
.describe(
'A short name for this way when several are compared, such as "short and steep" or "by the lake"; given back as it is',
),
date: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
day_id: hikeDayIdSchema.optional(),
date: hikeDateSchema.optional(),
start_time: hikeStartTimeSchema.optional(),
along: z
.boolean()
.optional()
.describe('The day the hike is planned for, YYYY-MM-DD; given back as it is'),
start_time: clockTimeSchema.optional().describe('When the hike starts, HH:MM; given back as it is'),
.describe(
'Looks for summits, huts, water and viewpoints along the way; true when left out, false when comparing ways',
),
include_geometry: includeGeometryFlagSchema,
})
.strict();
export type HikeProposeRequest = z.infer<typeof hikeProposeRequestSchema>;
@@ -114,14 +141,53 @@ export const hikeProposalSacSchema = hikeTrailGradesSchema.extend({
});
export type HikeProposalSac = z.infer<typeof hikeProposalSacSchema>;
/** The highest point of a way with heights, and where along it it lies. */
export const hikeHighPointSchema = z.object({
lat,
lng,
ele_m: z.number().int(),
at_m: z.number().int().nonnegative(),
});
export type HikeHighPoint = z.infer<typeof hikeHighPointSchema>;
/** A place along a proposed way: what it is, where, how far along the way and how far off it, in metres. */
export const hikeAlongPlaceSchema = z.object({
name: z.string().nullable(),
category: tourPoiCategorySchema,
lat,
lng,
at_m: z.number().int().nonnegative(),
off_m: z.number().int().nonnegative(),
});
export type HikeAlongPlace = z.infer<typeof hikeAlongPlaceSchema>;
/** A point a hike was asked through, as a proposal shows it. */
export const hikeProposalWaypointSchema = z.object({
lat,
lng,
name: z.string().nullable(),
poi_category: tourPoiCategorySchema.nullable(),
/** Where it lies along the way; null where no way was planned. */
at_m: z.number().int().nonnegative().nullable(),
/** How far the router moved it onto a way, in metres; null where no way was planned. */
snapped_m: z.number().int().nonnegative().nullable(),
/** The walking minutes from the start to it; null where no way was planned. */
minutes: z.number().int().nonnegative().nullable(),
/** When it is reached, HH:MM; null without a start. */
arrival: clockTimeSchema.nullable(),
});
export type HikeProposalWaypoint = z.infer<typeof hikeProposalWaypointSchema>;
/**
* What propose_hike answers, and what a chat card for the hike is drawn
* from: the way, its measures, its height profile and grades, the points it
* was asked through with where each lies along the way, and what the request
* named for the schedule, given back as it came.
* from: the way, its measures, its grades and highest point, the points it
* was asked through with where and when each is reached, the places along it,
* the day's schedule and what to look out for, and the request's own words
* given back. The line and the height profile come only when asked for
* (include_geometry), under keys a model never reads.
*/
export const hikeProposalSchema = z.object({
name: z.string().nullable(),
name: z.string(),
variant_label: z.string().nullable(),
route: hikeRouteOutcomeSchema,
/** Where a point lies further from any way than a tour allows: which one, and how far it was off. */
@@ -138,25 +204,25 @@ export const hikeProposalSchema = z.object({
heights_known: z.boolean(),
min_ele_m: z.number().int().nullable(),
max_ele_m: z.number().int().nullable(),
/** The way thinned for display, `[lat, lng, ele]` where the heights are known; empty where none was planned. */
line: z.array(hikeLinePointSchema).max(HIKE_PLAN_LINE_POINTS),
/** The height profile thinned for display, `[metres along the way, height in metres]`. */
profile: z.array(hikeProfileSampleSchema).max(HIKE_PLAN_PROFILE_SAMPLES),
/** The way thinned for display, `[lat, lng, ele]` where the heights are known; only with include_geometry. */
line_geometry: z.array(hikeLinePointSchema).max(HIKE_PLAN_LINE_POINTS).optional(),
/** The height profile thinned for display, `[metres along the way, height]`; only with include_geometry. */
profile_geometry: z.array(hikeProfileSampleSchema).max(HIKE_PLAN_PROFILE_SAMPLES).optional(),
sac: hikeProposalSacSchema.nullable(),
/** The points in the order walked, a round trip's way back included; at_m null where no way was planned. */
waypoints: z.array(
z.object({
lat,
lng,
name: z.string().nullable(),
at_m: z.number().int().nonnegative().nullable(),
}),
),
high_point: hikeHighPointSchema.nullable(),
/** The points in the order walked, a round trip's way back included. */
waypoints: z.array(hikeProposalWaypointSchema),
/** Summits, huts, water and viewpoints along the way, in the order passed; null where not looked for or not found. */
along: z.array(hikeAlongPlaceSchema).max(HIKE_ALONG_MAX).nullable(),
max_hiking_difficulty: tourMaxHikingDifficultySchema,
hiking_preferences: hikingPreferencesSchema.nullable(),
round_trip: z.boolean(),
day_id: idSchema.nullable(),
date: z.string().nullable(),
start_time: z.string().nullable(),
/** The day's schedule, sun and weather; null without a date. */
schedule: hikeScheduleSchema.nullable(),
warnings: z.array(hikeWarningSchema).max(HIKE_WARNING_KINDS.length),
});
export type HikeProposal = z.infer<typeof hikeProposalSchema>;
@@ -169,6 +235,10 @@ export const hikeCreateRequestSchema = z
hiking_preferences: hikePreferencesSchema.optional(),
round_trip: roundTripSchema.optional(),
planned_duration_minutes: plannedMinutesSchema.optional(),
day_id: hikeDayIdSchema.optional().describe('The day of the trip the hike is put on, as list_days gives it'),
start_time: hikeStartTimeSchema
.optional()
.describe('When the hike starts on that day, HH:MM, kept as the start of its visit; only with day_id'),
})
.strict();
export type HikeCreateRequest = z.infer<typeof hikeCreateRequestSchema>;
@@ -37,6 +37,7 @@ describe('a city walk prepared from a briefing (CW-11)', () => {
theme_places_max: 0,
corridor_m: 500,
lang: 'de',
include_geometry: true,
};
expect(cityWalkBriefingRequestSchema.parse(full)).toEqual(full);
});
@@ -64,6 +65,7 @@ describe('a city walk prepared from a briefing (CW-11)', () => {
},
{ ...briefing, theme_places_max: CITY_WALK_BRIEFING_LIMITS.themePlaces + 1 },
{ ...briefing, corridor_m: 5000 },
{ ...briefing, include_geometry: 'yes' },
];
expect(refused.map((body) => cityWalkBriefingRequestSchema.safeParse(body).success)).toEqual(
refused.map(() => false),
@@ -1,5 +1,6 @@
import { idSchema } from '../common/primitives.schema';
import { clockTimeSchema } from '../place/place-hours';
import { includeGeometryFlagSchema } from './tour-display.schema';
import { tourPoiSearchRequestSchema, walkThemeSchema } from './tour-poi-search.schema';
import { TOUR_STATION_NAME_MAX, tourDescriptionSchema, tourNameSchema, tourWaypointSchema } from './tours.schema';
@@ -103,6 +104,7 @@ export const cityWalkBriefingRequestSchema = z
.optional()
.describe('How far from the start, the wishes and the appointments to search, in metres; 1000 when left out'),
lang: tourPoiSearchRequestSchema.shape.lang,
include_geometry: includeGeometryFlagSchema,
})
.strict();
export type CityWalkBriefingRequest = z.infer<typeof cityWalkBriefingRequestSchema>;