mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
d5d2d6b25e
A tour can be a city walk. Its points are stations that keep a key across saves, a name, a stay (0 for a point passed through, empty when unknown), an importance and a protection; the visits of a walk's stations are kept per day assignment, so another day or a copy starts with nothing visited. A station that is gone takes its visits with it. An assistant reads how far a walk got on a day, what is open and which times are known.
551 lines
25 KiB
TypeScript
551 lines
25 KiB
TypeScript
import { paddleCheckSchema, tourObstacleCheckSchema } from './paddle-check.schema';
|
|
import {
|
|
paddleLockSchema,
|
|
paddleStopMinutesSchema,
|
|
portageSpeedKmhSchema,
|
|
trackMarksSchema,
|
|
} from './paddle-plan.schema';
|
|
import { tourOriginSchema, tourProvenanceSchema } from './tour-provenance.schema';
|
|
|
|
import { z } from 'zod';
|
|
|
|
/**
|
|
* Tours contracts — the generalisation of "hike" into a domain of self-contained,
|
|
* single-day, non-motorized activities that follow a track (hike, bike, run, ski,
|
|
* kayak, city walk, food tour, ...). 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 +
|
|
* single-day, Road-Trip = motorized + multi-day.
|
|
*/
|
|
|
|
export const tourTypeKeySchema = z.enum(['hike', 'bike', 'run', 'ski', 'kayak', 'walk', 'food']);
|
|
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.
|
|
*/
|
|
export const plannableTourTypeSchema = z.enum(['hike', 'walk', 'bike', 'kayak']);
|
|
export type PlannableTourType = z.infer<typeof plannableTourTypeSchema>;
|
|
|
|
/**
|
|
* 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>;
|
|
|
|
/** 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]));
|
|
}
|
|
|
|
/** Tours are single-day activities, so a planned total is bounded to 24 hours. */
|
|
export const MAX_PLANNED_TOUR_DURATION_MINUTES = 24 * 60;
|
|
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>;
|
|
|
|
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(),
|
|
/** 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(),
|
|
});
|
|
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;
|
|
|
|
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.
|
|
*/
|
|
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(),
|
|
}),
|
|
]);
|
|
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;
|
|
/** CW-03: a station's key, the same across saves. */
|
|
export const stationKeySchema = z.string().regex(/^[A-Za-z0-9_-]{6,64}$/);
|
|
/** 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>;
|
|
|
|
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(),
|
|
});
|
|
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: z.string().trim().min(1).max(255),
|
|
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(),
|
|
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: z.string().trim().max(2000).nullable().optional(),
|
|
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 > MAX_PLANNED_TOUR_DURATION_MINUTES
|
|
) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ['break_additional_minutes'],
|
|
message: 'walking time plus breaks must be 1440 minutes or less unless a total is overridden',
|
|
});
|
|
}
|
|
tourTypeIssues(value).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`,
|
|
});
|
|
}
|
|
}
|
|
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 };
|
|
|
|
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 exactly one profile and nothing of a hike's or a paddler's; a
|
|
* paddle tour exactly one boat and its water, and no hiking limit.
|
|
*/
|
|
function tourTypeIssues(value: {
|
|
tour_type: PlannableTourType;
|
|
max_hiking_difficulty?: number | null;
|
|
hiking_preferences?: HikingPreferences | null;
|
|
bike_profile?: BikeProfile | 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 = { kayak: 'a paddle tour', bike: 'a bike tour', walk: 'a city walk', hike: 'a hike' }[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' });
|
|
// A portage and a lock are a paddler's; a via ferrata is climbed, never ridden.
|
|
(value.waypoints ?? []).forEach((point, index) => {
|
|
if (type !== 'kayak' && point.fixed_leg?.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') && point.fixed_leg?.kind === 'ferrata')
|
|
issues.push({ path: ['waypoints', index, 'fixed_leg'], message: `${named} has no via ferrata` });
|
|
// 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;
|
|
}
|
|
|
|
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),
|
|
});
|
|
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: z.string().trim().min(1).max(255),
|
|
});
|
|
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>;
|