Files
TREK/shared/src/tours/tours.schema.ts
T

829 lines
40 KiB
TypeScript

import { horseAccessSharesSchema, horseSignpostedSchema } from './horse-route.schema';
import { paddleCheckSchema, tourObstacleCheckSchema } from './paddle-check.schema';
import {
paddleLockSchema,
paddleStopMinutesSchema,
portageSpeedKmhSchema,
trackMarksSchema,
} from './paddle-plan.schema';
import { runPaceSecondsPerKmSchema } from './run-pace';
import {
skiDisciplineSchema,
skiIssues,
skiPlannedLegsSchema,
skiRouteBuildSchema,
skiRouteOptionsSchema,
skiTechniqueSchema,
type SkiDiscipline,
type SkiTechnique,
} from './ski.schema';
import { tourActivityRefSchema, tourActivitySchema } from './tour-activity.schema';
import { tourPackingLinkSchema } from './tour-packing-link.schema';
import { tourPoiCategorySchema } from './tour-poi-category';
import { tourOriginSchema, tourProvenanceSchema } from './tour-provenance.schema';
import { maxTourMinutesFor, TOUR_DAY_MINUTES, tourStageCountOf, tourStageIssues } from './tour-stages';
import { tourTimeEstimateSchema } from './tour-time-estimate';
import { z } from 'zod';
/**
* Tours contracts — the generalisation of "hike" into a domain of self-contained,
* non-motorized activities that follow a track (hike, bike, run, ski, kayak,
* city walk, food tour, ...), a day each or (MDT-01) one route split into
* stages over several days. Tour types use a controlled key list.
*
* Isolated bounded context:
* no columns on `places`/`days`/`users`. A tour is a Place (route_geometry reused
* as-is) plus a day_assignments row (reused as-is, `assignPlaceToDay`) plus this
* facet table keyed by place_id. Boundary vs. Road-Trip: Tours = non-motorized,
* Road-Trip = motorized.
*/
export const tourTypeKeySchema = z.enum(['hike', 'bike', 'run', 'ski', 'kayak', 'walk', 'food', 'horse']);
export type TourTypeKey = z.infer<typeof tourTypeKeySchema>;
/**
* The types a tour can be planned, saved and imported as, in the order they
* are offered. `kayak` is the key the paddle tour has had in `tour_types` from
* the start; the app calls it a paddle tour, and the boat is a field of its
* own. A bike tour (BIK-01) names its profile the same way. `walk` is the city
* walk (CW-01): stations joined on foot, without a hiking limit. `run` is a
* run (RUN-01), planned on foot with the runner's own pace. `horse` is a
* ride (HOR-01): sections of signposted horse routes and stretches the rider
* draws by hand, never routed. `ski` is a ski and winter tour (SKI-02): a
* discipline from alpine skiing to winter hiking, its legs planned by the
* piste router where the piste data reaches and drawn straight elsewhere.
*/
export const plannableTourTypeSchema = z.enum(['hike', 'walk', 'run', 'bike', 'kayak', 'horse', 'ski']);
export type PlannableTourType = z.infer<typeof plannableTourTypeSchema>;
/**
* The types a tour can be imported and kept as: every type the planner plans.
* An imported track (a ski track among them, SKI-02) comes in as it was
* recorded and stays so until a tour is planned from it.
*/
export const importableTourTypeSchema = z.enum(['hike', 'walk', 'run', 'bike', 'kayak', 'horse', 'ski']);
export type ImportableTourType = z.infer<typeof importableTourTypeSchema>;
/**
* BIK-01 to BIK-04: what a bike tour is ridden on, the one profile it is routed
* for. Touring/city keeps to paved ways, cycle paths and quiet roads; road
* keeps to asphalt; gravel takes gravel and farm tracks; mtb takes rough tracks
* as they come. Each asks the router for something else (see the client's
* bike routing for what was measured); none says a way is rideable or safe.
*/
export const BIKE_PROFILES = ['touring', 'road', 'gravel', 'mtb'] as const;
export const bikeProfileSchema = z.enum(BIKE_PROFILES);
export type BikeProfile = z.infer<typeof bikeProfileSchema>;
/**
* RUN-01, RUN-02: what a run is planned as, the one profile it is routed for.
* Running keeps to runnable, mostly paved ways in towns, parks and along
* rivers; trail running to forest tracks and natural paths, up to mountain
* paths (T2). A profile asks the router for ways, it says nothing about
* whether one can be run, and it changes nothing of the running time.
*/
export const RUN_PROFILES = ['running', 'trail'] as const;
export const runProfileSchema = z.enum(RUN_PROFILES);
export type RunProfile = z.infer<typeof runProfileSchema>;
/** PAD-01: the one boat a paddle tour is planned for. */
export const paddleBoatTypeSchema = z.enum(['kayak', 'canoe', 'sup', 'other']);
export type PaddleBoatType = z.infer<typeof paddleBoatTypeSchema>;
/** PAD-01: the water a paddle tour is on. River and lake are the first supported. */
export const paddleWaterContextSchema = z.enum(['river', 'lake']);
export type PaddleWaterContext = z.infer<typeof paddleWaterContextSchema>;
/** PAD-11: how the router crosses lakes: straight over (the default), or along the shore, which a paddler picks for wind. */
export const paddleLakeStyleSchema = z.enum(['direct', 'shore']);
export type PaddleLakeStyle = z.infer<typeof paddleLakeStyleSchema>;
/** The highest planning speed a paddler can enter, in km/h. */
export const MAX_PADDLE_SPEED_KMH = 20;
export const paddleSpeedKmhSchema = z.number().finite().min(0.5).max(MAX_PADDLE_SPEED_KMH);
/**
* The planning speed TREK suggests for a boat, in km/h: a starting value to
* adjust, not a measurement. "Other" has none; its paddler enters a speed.
*/
export function recommendedPaddleSpeedKmh(boat: PaddleBoatType | null | undefined): number | null {
return boat === 'kayak' || boat === 'canoe' || boat === 'sup' ? 4 : null;
}
export const tourMaxHikingDifficultySchema = z.number().int().min(1).max(6);
export type TourMaxHikingDifficulty = z.infer<typeof tourMaxHikingDifficultySchema>;
/**
* MF-08, RTE-01: what a hiker asks the router for besides the difficulty
* limit: steep climbs, steps and ferries, each avoided where there is a way
* round. Each is a weighting whose effect on the router was measured, never a
* ban, and none says anything about how hard the path is. A hike without any
* keeps null, which is what every tour saved before had.
*/
export const HIKING_PREFERENCE_KEYS = ['avoid_climbs', 'avoid_steps', 'avoid_ferries'] as const;
export type HikingPreferenceKey = (typeof HIKING_PREFERENCE_KEYS)[number];
export const hikingPreferencesSchema = z
.object({
/** Gentler ways where there are some, even when they are longer. */
avoid_climbs: z.boolean().optional(),
/** Steps and stairs only where there is no way round. */
avoid_steps: z.boolean().optional(),
/** A ferry only where there is no way round over land. */
avoid_ferries: z.boolean().optional(),
})
.strict();
export type HikingPreferences = z.infer<typeof hikingPreferencesSchema>;
/** The preferences a hike keeps: only those switched on, null when none is. */
export function hikingPreferencesOf(value: HikingPreferences | null | undefined): HikingPreferences | null {
const kept = HIKING_PREFERENCE_KEYS.filter((key) => value?.[key] === true);
return kept.length === 0 ? null : Object.fromEntries(kept.map((key) => [key, true]));
}
/** A planned total is bounded to a day; a multi-day tour's moving time to a day a stage (MDT-01). */
export const MAX_PLANNED_TOUR_DURATION_MINUTES = TOUR_DAY_MINUTES;
export const plannedTourDurationMinutesSchema = z
.number()
.int()
.min(0)
.max(MAX_PLANNED_TOUR_DURATION_MINUTES)
.nullable();
export const tourWebsiteSchema = z
.string()
.trim()
.max(500)
.url()
.superRefine((value, ctx) => {
let parsed: URL;
try {
parsed = new URL(value);
} catch {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'must be a valid URL' });
return;
}
if (parsed.protocol !== 'https:') {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'must be an HTTPS URL' });
}
if (parsed.username || parsed.password) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'must not contain URL credentials' });
}
})
.transform((value) => new URL(value).toString());
export const tourTypeSchema = z.object({
key: tourTypeKeySchema,
label_key: z.string(),
icon: z.string(),
color: z.string(),
/** NULL = no engine routing (e.g. kayak — manual waypoints only). */
routing_profile: z.string().nullable(),
is_sport: z.boolean(),
enabled: z.boolean(),
sort_order: z.number().int(),
});
export type TourType = z.infer<typeof tourTypeSchema>;
/** CW-03: a station's key, the same across saves. */
export const stationKeySchema = z.string().regex(/^[A-Za-z0-9_-]{6,64}$/);
/** MDT-02: a stop a stage of a multi-day tour starts or ends at, by its key and where it is. */
export const tourStageStopSchema = z.object({
key: stationKeySchema,
lat: z.number(),
lng: z.number(),
});
export const tourSchema = z.object({
place_id: z.number().int().positive(),
tour_type: tourTypeKeySchema,
distance: z.number().nullable(),
elevation_gain: z.number().nullable(),
elevation_loss: z.number().nullable(),
duration: z.number().nullable(),
planned_duration_minutes: plannedTourDurationMinutesSchema.optional(),
break_additional_minutes: plannedTourDurationMinutesSchema.optional(),
difficulty: z.string().nullable(),
// Nullable reference to a tour in wanderer.
wanderer_ref: z.string().nullable(),
match_confidence: z.number().min(0).max(1).nullable(),
// NULL for a GPX import: the track carries no difficulty. A drawn tour always has one.
max_hiking_difficulty: tourMaxHikingDifficultySchema.nullable(),
/** MF-08: what a hike asks the router for besides its limit; null for none and for a paddle tour. Absent in a list cached before it existed. */
hiking_preferences: hikingPreferencesSchema.nullable().optional(),
/** The import (a GPX file, a wanderer snapshot) this tour was planned from, while it is still in the trip. */
derived_from_place_id: z.number().int().positive().nullable().optional(),
/** BIK-01: a bike tour's profile; null for every other type. Absent in a list cached before it existed. */
bike_profile: bikeProfileSchema.nullable().optional(),
/** RUN-01: a run's profile; null for every other type. Absent in a list cached before it existed. */
run_profile: runProfileSchema.nullable().optional(),
/** RUN-01: the pace a run is planned at, seconds per km; null while none is entered and for every other type. */
run_pace_seconds_per_km: z.number().nullable().optional(),
/** SKI-02: a ski tour's discipline; null for every other type. Absent in a list cached before it existed. */
ski_discipline: skiDisciplineSchema.nullable().optional(),
/** SKI-02: how a cross-country tour is skied; null for every other discipline and type. */
ski_technique: skiTechniqueSchema.nullable().optional(),
/** The piste router's options a ski tour was planned with; null for every other type and an imported track. */
ski_options: skiRouteOptionsSchema.nullable().optional(),
/** The piste data build a ski tour's legs were planned on; null where the router planned none. */
ski_route_build: z.string().nullable().optional(),
/** The ski tour's legs the piste router planned, by the index of the point each starts at; null if unknown. */
ski_planned_legs: skiPlannedLegsSchema.nullable().optional(),
/** A paddle tour's boat and water; null for every other type. Absent in a list cached before they existed. */
boat_type: paddleBoatTypeSchema.nullable().optional(),
water_context: paddleWaterContextSchema.nullable().optional(),
/** The speed the paddler chose; null while the boat's suggested speed applies. */
paddle_speed_kmh: z.number().nullable().optional(),
/** PAD-07: the carrying speed of a paddle tour's portages; null while the starting value applies. */
portage_speed_kmh: z.number().nullable().optional(),
/** PAD-11: how its lakes were routed; null (or absent) means straight over. */
paddle_lake_style: paddleLakeStyleSchema.nullable().optional(),
/** PAD-03: portages and locks laid onto a recorded track; null for a planned tour and a track without any. */
track_marks: trackMarksSchema.nullable().optional(),
});
export type Tour = z.infer<typeof tourSchema>;
/**
* P2C-03: the technical revision of a saved tour, raised by every write that
* changes it. Not the version of a source the tour was imported from (that is
* its provenance), and not a copy (a copy is a tour of its own at 1).
*/
export const tourRevisionSchema = z.number().int().min(1);
/**
* The revision a write was based on. Given, the write is refused with a 409
* while the tour has moved on; left out, it writes as before, so an older
* client or a queued offline write keeps working.
*/
export const expectedTourRevisionSchema = tourRevisionSchema.nullable().optional();
/** The 409 a write against a tour that has moved on answers with: the revision it has now. */
export const TOUR_REVISION_CONFLICT = 'tour_revision_conflict';
export const tourRevisionConflictSchema = z.object({
error: z.string(),
code: z.literal(TOUR_REVISION_CONFLICT),
revision: tourRevisionSchema,
});
export type TourRevisionConflict = z.infer<typeof tourRevisionConflictSchema>;
export const tourTypeListResponseSchema = z.object({
tourTypes: z.array(tourTypeSchema),
});
export type TourTypeListResponse = z.infer<typeof tourTypeListResponseSchema>;
/**
* A tour as shown in the Tours selection list: the `tours` facet joined with
* its owning place's name, plus
* two read-model-only fields the list needs and the facet table does not carry:
* `planned` (whether the place already has a day_assignments row — the same
* "attach only" fact PlacesSidebar's All/Unplanned/Planned filter uses) and
* `caution` (derived from match_confidence < 0.5, the "use with caution"
* threshold — surfaced as a badge instead of making the client re-derive it).
*/
export const tourListItemSchema = tourSchema.extend({
name: z.string(),
description: z.string().nullable().optional(),
website: z.string().nullable().optional(),
planned: z.boolean(),
caution: z.boolean(),
/** Read-model provenance signal: planner-authored tours persist routing controls; GPX imports do not. */
has_waypoints: z.boolean().optional(),
/** An import planned over in place still keeps the track it was recorded with, and can go back to it. */
has_recorded_track: z.boolean().optional(),
/** Which way the tour came in; absent only in a list cached before TREK recorded it. */
origin: tourOriginSchema.optional(),
/**
* PAD-09: the check bench of a paddle tour, null when it has no line yet.
* Only paddle tours carry it. A value this build cannot read (a newer
* server) is dropped rather than refused, so a queued write still lands.
*/
paddle_check: paddleCheckSchema.nullable().optional().catch(undefined),
/** P2C-03: the revision a write names to be refused once the tour moved on; absent in a list cached before it existed. */
revision: tourRevisionSchema.optional(),
/** CW-03: a city walk's stations, its points that are no shaping points; null for any other type. */
station_count: z.number().int().nonnegative().nullable().optional(),
/** CW-02: the stays entered at a city walk's stations, summed; null for any other type. */
station_stay_minutes: z.number().int().nonnegative().nullable().optional(),
/** CW-02: how many of a city walk's stations have no stay entered yet; null for any other type. */
station_stays_unknown: z.number().int().nonnegative().nullable().optional(),
/**
* CW-11: a tour saved with its points only, its way not planned yet (a city
* walk an assistant created). The planner plans the way when the tour is
* opened, and a save keeps it. Absent in a list cached before it existed.
*/
route_pending: z.boolean().optional(),
/**
* ACT-01: when the track an import read was recorded, from the times on its
* points (ISO 8601, UTC); null when the file carried none and for a planned
* tour. Absent in a list cached before it existed.
*/
track_started_at: z.string().nullable().optional(),
track_ended_at: z.string().nullable().optional(),
/**
* TIME-01: the time a tour without a planned time (an import) is estimated
* to take, worked out on reading by `tourTimeEstimate` and never stored;
* null where nothing estimates it. Its minutes are null when the track has
* no heights. Apart from the times recorded in the file and from a planned
* total the travellers enter. Absent in a list cached before it existed.
*/
time_estimate: tourTimeEstimateSchema.nullable().optional(),
/** ACT-01: the recordings assigned to this tour, one per execution. Absent in a list cached before it existed. */
activity_tracks: z.array(tourActivityRefSchema).optional(),
/** ACT-01: the tour this recording documents, when it is assigned to one; null otherwise. */
activity_of: z.number().int().positive().nullable().optional(),
/**
* MDT-01: the stages a multi-day tour's stops split it into; null for a day
* tour. Absent in a list cached before it existed.
*/
stage_count: z.number().int().min(2).nullable().optional(),
/**
* MDT-02: the stops the stages run between, in route order: the start, every
* stop that ends a stage, and the end. A stage is put on a day by the key of
* the stop it starts at. Null for a day tour; absent in a list cached before it existed.
*/
stage_stops: z.array(tourStageStopSchema).min(3).nullable().optional(),
});
export type TourListItem = z.infer<typeof tourListItemSchema>;
export const tourListResponseSchema = z.object({
tours: z.array(tourListItemSchema),
});
export type TourListResponse = z.infer<typeof tourListResponseSchema>;
export const tourWaypointRoleSchema = z.enum(['start', 'via', 'end']);
export type TourWaypointRole = z.infer<typeof tourWaypointRoleSchema>;
/** The most points a fixed leg may carry: a long via ferrata is a few hundred. */
export const TOUR_FIXED_LEG_MAX_POINTS = 5000;
/** How far a fixed leg's ends may lie from the waypoints it joins, in metres. */
export const TOUR_FIXED_LEG_END_TOLERANCE_M = 60;
/**
* The largest body a tour create or edit may send, in bytes. A tour goes whole,
* its full line with heights and every fixed leg's line besides, at roughly
* fifty bytes a point: one horse route section at TOUR_FIXED_LEG_MAX_POINTS is
* a quarter of a megabyte on its own, far past the API's hundred kilobytes.
*/
export const TOUR_WRITE_MAX_BYTES = 2 * 1024 * 1024;
/** The longest a ferry leg's crossing or its walk may take, a whole day. */
const FERRY_LEG_MAX_MINUTES = 24 * 60;
const fixedLegGeometrySchema = z
.array(z.tuple([z.number().finite().min(-90).max(90), z.number().finite().min(-180).max(180)]))
.min(2)
.max(TOUR_FIXED_LEG_MAX_POINTS);
/**
* A leg the router does not plan: the line from this waypoint to the next is
* taken as it stands. A via ferrata put onto the tour from the map follows its
* own line from OpenStreetMap, since a router walks around a ferrata wherever
* it can. A free leg (P2A-04) is drawn by hand, straight from one waypoint to
* the next: off the path network, across a lake, wherever no router goes.
* Nothing about a free leg was checked by a router. A portage (PAD-03) is a
* free leg on land: the boat is carried along it, so it counts as land and
* not as water, with the time getting out and back in takes. A horse route
* section (HOR-01) is a stretch of a signposted horse route from
* OpenStreetMap, taken in the direction the rider chose, with the `horse=*`
* tags its ways carried then; being on the route is no permission to ride it.
* A ferry leg (CW-12) is a city walk's crossing by boat the travellers took on
* purpose: the router's way from the station to the next, with where on it the
* boat runs, so the boat's time never counts as walking.
*/
export const tourFixedLegSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('ferrata'),
/** The OSM way or group the line came from, `way/<id>` style. */
ref: z.string().trim().min(1).max(64),
name: z.string().trim().max(255).nullable(),
geometry: fixedLegGeometrySchema,
}),
z.object({
kind: z.literal('free'),
geometry: fixedLegGeometrySchema,
}),
z.object({
kind: z.literal('portage'),
geometry: fixedLegGeometrySchema,
/** Getting out, unloading and back in, on the first leg of a portage; null takes the starting value. */
extra_minutes: paddleStopMinutesSchema.nullable().optional(),
}),
z.object({
kind: z.literal('horse_route'),
/** The OSM relation the section came from, `relation/<id>` style. */
ref: z
.string()
.max(64)
.regex(/^relation\/\d+$/),
name: z.string().trim().max(255).nullable(),
geometry: fixedLegGeometrySchema,
/** Whether the leg runs the way the route is signposted; null both ways or not said. */
signposted: horseSignpostedSchema.nullable().optional(),
/** The `horse=*` tags along the section when it was taken, in metres per value. */
access: horseAccessSharesSchema.optional(),
}),
z.object({
kind: z.literal('ferry'),
geometry: fixedLegGeometrySchema,
/** The points of the line the boat leaves from and lands at, by their index. */
crossing: z.tuple([z.number().int().nonnegative(), z.number().int().nonnegative()]),
/** The crossing's time as the router gave it; null when it gave none. */
minutes: z.number().int().min(0).max(FERRY_LEG_MAX_MINUTES).nullable(),
/** The walk to the boat and on from it along this leg, by the router. */
walk_minutes: z.number().int().min(0).max(FERRY_LEG_MAX_MINUTES),
}),
]);
export type TourFixedLeg = z.infer<typeof tourFixedLegSchema>;
export type TourFixedLegKind = TourFixedLeg['kind'];
/** CW-02: the longest stay a station takes, a whole day. */
export const MAX_STATION_STAY_MINUTES = 24 * 60;
/** The longest name a tour point keeps. */
export const TOUR_STATION_NAME_MAX = 200;
/** The longest opening hours a station keeps, as OpenStreetMap writes them. */
export const TOUR_STATION_HOURS_MAX = 500;
/**
* How far a router may move a point of a tour onto its way before the point
* counts as one no way of the tour's type reaches: the planner's limit, and
* the server's for a city walk it plans on foot (CW-11).
*/
export const TOUR_MAX_SNAP_M = 300;
/** CW-03: how much a station matters, besides normal (null). */
export const STATION_IMPORTANCES = ['optional', 'must'] as const;
export const stationImportanceSchema = z.enum(STATION_IMPORTANCES);
export type StationImportance = z.infer<typeof stationImportanceSchema>;
/** A tour's name, as a create, an edit and a copy take it. */
export const tourNameSchema = z.string().trim().min(1).max(255);
/** A tour's description, as a create and an edit take it. */
export const tourDescriptionSchema = z.string().trim().max(2000).nullable().optional();
export const tourWaypointSchema = z.object({
lat: z.number().finite().min(-90).max(90),
lng: z.number().finite().min(-180).max(180),
role: tourWaypointRoleSchema,
sequence: z.number().int().nonnegative(),
/** The leg to the next waypoint as a fixed line; absent or null for a routed one. */
fixed_leg: tourFixedLegSchema.nullable().optional(),
/** PAD-07: a lock at this point of a paddle tour, with the times the paddler expects there. */
lock: paddleLockSchema.nullable().optional(),
/**
* P2A-03: a shaping point, there only to steer the route: no stop, no place
* to get in or out. Absent means a stop, as every point saved before was.
*/
shaping: z.boolean().optional(),
/**
* PR1-10: the place of the trip this point stands for, chosen on purpose.
* The point keeps its own coordinates and the place stays a place; a place
* that is gone (or not of this trip) leaves the point unlinked.
*/
linked_place_id: z.number().int().positive().nullable().optional(),
/**
* CW-03: the point's own key, kept across saves, so the visits of a city
* walk's stations outlast a reorder. The server gives one to a point that
* comes without.
*/
key: stationKeySchema.optional(),
/** The point's name where it has one: a station of a city walk, a place found along the route. */
name: z.string().trim().max(TOUR_STATION_NAME_MAX).nullable().optional(),
/** CW-02: how long a city walk's station is stayed at; 0 is a point passed through, null not known. */
stay_minutes: z.number().int().min(0).max(MAX_STATION_STAY_MINUTES).nullable().optional(),
/** CW-03: how much a station matters; null is normal. */
importance: stationImportanceSchema.nullable().optional(),
/** CW-10: a station a proposed change keeps where it is; the traveller can still edit it. */
protected: z.boolean().optional(),
/** CW-04: the station's opening hours as OpenStreetMap writes them, where it was found there. */
opening_hours: z.string().trim().max(TOUR_STATION_HOURS_MAX).nullable().optional(),
/**
* PLN-03: the kind of place the point was taken in from, found along the
* tour (a restaurant, a museum), so its row carries that kind's icon as the
* map drew the place. Null on a point set by hand.
*/
poi_category: tourPoiCategorySchema.nullable().optional(),
/**
* MDT-01: a stop where a stage of a multi-day tour ends and the next one
* begins. Absent means the route goes on, as on every point saved before.
*/
stage_end: z.boolean().optional(),
});
export type TourWaypoint = z.infer<typeof tourWaypointSchema>;
/**
* Metres between two `[lat, lng]` points, close enough at the length of a
* leg's end gap. Read as plain arrays: a workspace compiled without strict
* mode sees the schema's tuples as "maybe two numbers".
*/
function fixedLegGapM(a: readonly unknown[], b: readonly unknown[]): number {
const [aLat, aLng, bLat, bLng] = [Number(a[0]), Number(a[1]), Number(b[0]), Number(b[1])];
const rad = Math.PI / 180;
const x = (bLng - aLng) * rad * Math.cos(((aLat + bLat) / 2) * rad);
const y = (bLat - aLat) * rad;
return Math.hypot(x, y) * 6_371_000;
}
const enrichedRoutePointSchema = z.tuple([
z.number().finite().min(-90).max(90),
z.number().finite().min(-180).max(180),
z.number().finite(),
]);
export const tourCreateRequestSchema = z
.object({
name: tourNameSchema,
tour_type: plannableTourTypeSchema.default('hike'),
route_geometry: z.array(enrichedRoutePointSchema).min(2),
waypoints: z.array(tourWaypointSchema).min(2),
/** A hike's routing limit (2 when left out); a paddle tour has none. */
max_hiking_difficulty: tourMaxHikingDifficultySchema.nullable().optional(),
/** MF-08, a hike's only: what it asks the router for besides its limit. */
hiking_preferences: hikingPreferencesSchema.nullable().optional(),
/** BIK-01, a bike tour's only, and its one requirement. */
bike_profile: bikeProfileSchema.nullable().optional(),
/** RUN-01, a run's only, and its one requirement. */
run_profile: runProfileSchema.nullable().optional(),
/** RUN-01, a run's only: the pace it is planned at, seconds per km; null while none is entered. */
run_pace_seconds_per_km: runPaceSecondsPerKmSchema.nullable().optional(),
/** SKI-02, a ski tour's only, and its one requirement: the discipline, for cross-country the technique too. */
ski_discipline: skiDisciplineSchema.nullable().optional(),
ski_technique: skiTechniqueSchema.nullable().optional(),
/** A ski tour's only: what the piste router was asked for (the server keeps what its discipline reads). */
ski_options: skiRouteOptionsSchema.nullable().optional(),
/** A ski tour's only: the piste data build its legs were planned on, so newer data can be offered later. */
ski_route_build: skiRouteBuildSchema.nullable().optional(),
/** A ski tour's only: the legs the piste router planned, by the index of the point each starts at. */
ski_planned_legs: skiPlannedLegsSchema.nullable().optional(),
boat_type: paddleBoatTypeSchema.nullable().optional(),
water_context: paddleWaterContextSchema.nullable().optional(),
paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(),
portage_speed_kmh: portageSpeedKmhSchema.nullable().optional(),
paddle_lake_style: paddleLakeStyleSchema.nullable().optional(),
duration_seconds: z.number().finite().nonnegative().nullable().optional(),
planned_duration_minutes: plannedTourDurationMinutesSchema.optional(),
break_additional_minutes: plannedTourDurationMinutesSchema.optional(),
description: tourDescriptionSchema,
website: tourWebsiteSchema.nullable().optional(),
/**
* Creating only: the import of this trip the tour was planned from. It is
* where the tour came from, so an edit later neither sets nor changes it.
*/
derived_from_place_id: z.number().int().positive().nullable().optional(),
/** P2C-03, editing only: the revision the draft was opened at. A create ignores it. */
expected_revision: expectedTourRevisionSchema,
})
.superRefine((value, ctx) => {
if (
value.planned_duration_minutes == null &&
value.duration_seconds != null &&
value.break_additional_minutes != null &&
Math.round(value.duration_seconds / 60) + value.break_additional_minutes >
maxTourMinutesFor(tourStageCountOf(value.waypoints))
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['break_additional_minutes'],
message: plannedTimeLimitMessage(tourStageCountOf(value.waypoints)),
});
}
tourTypeIssues(value).forEach((issue) => ctx.addIssue({ code: z.ZodIssueCode.custom, ...issue }));
tourStageIssues(value.waypoints).forEach((issue) => ctx.addIssue({ code: z.ZodIssueCode.custom, ...issue }));
value.waypoints.forEach((point, index) => {
const leg = point.fixed_leg;
const next = value.waypoints[index + 1];
if (leg) {
const first = leg.geometry[0];
const last = leg.geometry[leg.geometry.length - 1];
const joins =
!!next &&
!!first &&
!!last &&
fixedLegGapM(first, [point.lat, point.lng]) <= TOUR_FIXED_LEG_END_TOLERANCE_M &&
fixedLegGapM(last, [next.lat, next.lng]) <= TOUR_FIXED_LEG_END_TOLERANCE_M;
if (!joins) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['waypoints', index, 'fixed_leg'],
message: `Waypoint ${index}'s fixed leg must run from it to the next waypoint`,
});
}
if (leg.kind === 'ferry' && !(leg.crossing[0] < leg.crossing[1] && leg.crossing[1] < leg.geometry.length)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['waypoints', index, 'fixed_leg', 'crossing'],
message: 'a crossing runs between two points of its leg, the first before the second',
});
}
}
const expectedRole: TourWaypointRole =
index === 0 ? 'start' : index === value.waypoints.length - 1 ? 'end' : 'via';
if (point.sequence !== index || point.role !== expectedRole) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['waypoints', index],
message: `Waypoint ${index} must be ${expectedRole} with sequence ${index}`,
});
}
});
})
// A hike left without a limit is routed at T2, as before the field could be null.
.transform((value) => ({
...value,
max_hiking_difficulty: value.tour_type === 'hike' ? (value.max_hiking_difficulty ?? 2) : null,
}));
export type TourCreateRequest = z.infer<typeof tourCreateRequestSchema>;
/** What a client sends: the fields the server fills in may be left out. */
export type TourCreateInput = z.input<typeof tourCreateRequestSchema>;
type Issue = { path: Array<string | number>; message: string };
/** How a refusal names each type. */
const TYPE_NAMED: Record<PlannableTourType, string> = {
kayak: 'a paddle tour',
bike: 'a bike tour',
walk: 'a city walk',
run: 'a run',
hike: 'a hike',
horse: 'a horse tour',
ski: 'a ski tour',
};
/**
* What a save over its time limit is told: a day for a day tour, as it always
* was, and a day a stage for a multi-day tour (MDT-01).
*/
export function plannedTimeLimitMessage(stageCount: number): string {
return stageCount > 1
? `walking time plus breaks must be ${maxTourMinutesFor(stageCount)} minutes or less, a day a stage, unless a total is overridden`
: 'walking time plus breaks must be 1440 minutes or less unless a total is overridden';
}
const PADDLE_FIELDS = [
'boat_type',
'water_context',
'paddle_speed_kmh',
'portage_speed_kmh',
'paddle_lake_style',
] as const;
/**
* What belongs to which type: a hike carries a routing limit and no boat; a
* bike tour and a run exactly one profile each and nothing of a hike's or a
* paddler's, a run also its pace; a paddle tour exactly one boat and its
* water, and no hiking limit; a horse tour (HOR-01) only legs taken from a
* horse route or drawn by hand, and no riding time worked out for it; a ski
* tour (SKI-02) its discipline (and a cross-country technique), its piste
* router options, and no via ferrata.
*/
function tourTypeIssues(value: {
tour_type: PlannableTourType;
duration_seconds?: number | null;
max_hiking_difficulty?: number | null;
hiking_preferences?: HikingPreferences | null;
bike_profile?: BikeProfile | null;
run_profile?: RunProfile | null;
run_pace_seconds_per_km?: number | null;
ski_discipline?: SkiDiscipline | null;
ski_technique?: SkiTechnique | null;
ski_options?: object | null;
ski_route_build?: string | null;
ski_planned_legs?: readonly number[] | null;
boat_type?: PaddleBoatType | null;
water_context?: PaddleWaterContext | null;
paddle_speed_kmh?: number | null;
portage_speed_kmh?: number | null;
paddle_lake_style?: PaddleLakeStyle | null;
waypoints?: ReadonlyArray<{
fixed_leg?: { kind: string } | null;
lock?: unknown;
stay_minutes?: number | null;
importance?: string | null;
protected?: boolean;
}>;
}): Issue[] {
const issues: Issue[] = [];
const type = value.tour_type;
const named = TYPE_NAMED[type];
if (type === 'kayak') {
if (value.boat_type == null) issues.push({ path: ['boat_type'], message: 'a paddle tour needs a boat type' });
if (value.water_context == null)
issues.push({ path: ['water_context'], message: 'a paddle tour needs a water context' });
} else {
for (const field of PADDLE_FIELDS) {
if (value[field] != null) issues.push({ path: [field], message: `${field} belongs to a paddle tour` });
}
}
if (type === 'hike') {
if (value.max_hiking_difficulty === null)
issues.push({ path: ['max_hiking_difficulty'], message: 'a hike needs a hiking difficulty' });
} else {
if (value.max_hiking_difficulty != null)
issues.push({ path: ['max_hiking_difficulty'], message: `${named} has no hiking difficulty` });
if (hikingPreferencesOf(value.hiking_preferences) !== null)
issues.push({ path: ['hiking_preferences'], message: `${named} has no hiking preferences` });
}
if (type === 'bike' && value.bike_profile == null)
issues.push({ path: ['bike_profile'], message: 'a bike tour needs a bike profile' });
if (type !== 'bike' && value.bike_profile != null)
issues.push({ path: ['bike_profile'], message: 'bike_profile belongs to a bike tour' });
issues.push(...runIssues(value));
issues.push(...skiIssues(type === 'ski', value));
if (type !== 'ski') {
for (const field of ['ski_options', 'ski_route_build', 'ski_planned_legs'] as const) {
if (value[field] != null) issues.push({ path: [field], message: `${field} belongs to a ski tour` });
}
}
// Each planned leg is one of this tour's legs, named once.
const legCount = Math.max(0, (value.waypoints?.length ?? 0) - 1);
(value.ski_planned_legs ?? []).forEach((leg, index, all) => {
if (leg >= legCount || all.indexOf(leg) !== index)
issues.push({ path: ['ski_planned_legs', index], message: 'not a leg of this tour, or named twice' });
});
// No router plans a ride, so no time is worked out for it; a planned total the rider enters is theirs.
// A ski tour's time is the piste router's, sent only when it planned every leg.
if (type === 'horse' && value.duration_seconds != null)
issues.push({ path: ['duration_seconds'], message: 'a horse tour has no riding time worked out' });
const points = value.waypoints ?? [];
// A portage and a lock are a paddler's; a via ferrata is climbed, never ridden.
points.forEach((point, index) => {
const kind = point.fixed_leg?.kind;
if (type !== 'kayak' && kind === 'portage')
issues.push({ path: ['waypoints', index, 'fixed_leg'], message: 'a portage belongs to a paddle tour' });
if (type !== 'kayak' && point.lock != null)
issues.push({ path: ['waypoints', index, 'lock'], message: 'a lock belongs to a paddle tour' });
if (
(type === 'bike' || type === 'walk' || type === 'run' || type === 'horse' || type === 'ski') &&
kind === 'ferrata'
)
issues.push({ path: ['waypoints', index, 'fixed_leg'], message: `${named} has no via ferrata` });
// CW-12: a crossing by boat is taken on a city walk only, where the way on foot has none.
if (type !== 'walk' && kind === 'ferry')
issues.push({ path: ['waypoints', index, 'fixed_leg'], message: 'a ferry leg belongs to a city walk' });
if (type !== 'horse' && kind === 'horse_route')
issues.push({
path: ['waypoints', index, 'fixed_leg'],
message: 'a horse route section belongs to a horse tour',
});
// HOR-01: nothing of a ride is routed, so no leg may pass for one that was.
if (type === 'horse' && index < points.length - 1 && kind !== 'horse_route' && kind !== 'free')
issues.push({
path: ['waypoints', index, 'fixed_leg'],
message: 'each leg of a horse tour follows a horse route or is drawn by hand',
});
// A stay, an importance and a protection are a city walk's stations'.
if (type !== 'walk' && (point.stay_minutes != null || point.importance != null || point.protected === true))
issues.push({ path: ['waypoints', index], message: 'stations belong to a city walk' });
});
return issues;
}
/** RUN-01: a run names its profile; its profile and pace belong to no other type. */
function runIssues(value: {
tour_type: PlannableTourType;
run_profile?: RunProfile | null;
run_pace_seconds_per_km?: number | null;
}): Issue[] {
if (value.tour_type === 'run')
return value.run_profile == null ? [{ path: ['run_profile'], message: 'a run needs a run profile' }] : [];
return (['run_profile', 'run_pace_seconds_per_km'] as const)
.filter((field) => value[field] != null)
.map((field) => ({ path: [field], message: `${field} belongs to a run` }));
}
export const tourCreateResponseSchema = z.object({
tour: tourListItemSchema,
waypoints: z.array(tourWaypointSchema),
/** Where the tour came from (P2C-02); absent only in a detail cached before TREK sent it. */
provenance: tourProvenanceSchema.optional(),
/** PAD-06: a paddle tour's obstacle checklist; dropped, never refused, when this build cannot read it. */
obstacle_check: tourObstacleCheckSchema.optional().catch(undefined),
/** ACT-01: the recorded tracks assigned to the tour; absent only in a detail cached before TREK sent them. */
activities: z.array(tourActivitySchema).optional(),
/**
* P2D-XD-01: the packing list or category the tour points at, null for none.
* The packing link writes answer with it; an answer without it says nothing
* about the link, so a cache keeps the one it had.
*/
packing_link: tourPackingLinkSchema.nullable().optional(),
});
export type TourCreateResponse = z.infer<typeof tourCreateResponseSchema>;
/**
* P2C-07 "save as new tour": the name the copy gets. Everything else comes
* from the tour it is copied from; the copy has its own id, line and control
* points, and no days.
*/
export const tourCopyRequestSchema = z.object({
name: tourNameSchema,
});
export type TourCopyRequest = z.infer<typeof tourCopyRequestSchema>;
/** Saved-tour editor payload: list metadata plus its persisted routing controls. */
export const tourDetailResponseSchema = tourCreateResponseSchema;
export type TourDetailResponse = z.infer<typeof tourDetailResponseSchema>;
export const tourImportGpxResponseSchema = z.object({
tours: z.array(tourListItemSchema),
caution: z.boolean(),
skipped: z.number().int(),
});
export type TourImportGpxResponse = z.infer<typeof tourImportGpxResponseSchema>;