mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 22:16:26 +00:00
cd407cf80c
HIK-01. The details of the hiking planner gain an optional Winter activity switch, off by default, so the normal hiking flow asks for nothing more. Switched on, the hike is a winter hike or a snowshoe hike; the choice stays dimmed and out of reach while the switch is off. The label is saved with the tour, comes back when it is opened, survives a draft reload, a copy and a take-over, and switching it off takes only the label away: the route, its grade and the days stay as they were. The label is a label only. Routing, the walking time and the 3D terrain view work as for any hike, and the info dot and the head pill's tooltip say that nothing was checked for snow, avalanches or whether a path can be walked in winter. The saved label shows as a frosted snowflake on the corner of the tour's tile in the tour list and the day plan, and as a pill beside the type in the tour's detail. The server keeps it in tours.winter_activity (CHECK list, NULL for every tour saved before and for every other type), refuses it on a bike tour, a city walk and a paddle tour, and keeps the stored label when an older client saves a hike without the field. A snowshoe hike exports to GPX as snowshoeing. list_tours names the field, with the same words on what it does not say.
596 lines
28 KiB
TypeScript
596 lines
28 KiB
TypeScript
import { paddleCheckSchema, tourObstacleCheckSchema } from './paddle-check.schema';
|
|
import {
|
|
paddleLockSchema,
|
|
paddleStopMinutesSchema,
|
|
portageSpeedKmhSchema,
|
|
trackMarksSchema,
|
|
} from './paddle-plan.schema';
|
|
import { tourPoiCategorySchema } from './tour-poi-category';
|
|
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>;
|
|
|
|
/**
|
|
* HIK-01: a hike marked as one done in winter, walked or on snowshoes. It is
|
|
* optional and only a label: the route, its grade and its time are planned as
|
|
* for any hike, and the label says nothing about whether a way can be walked
|
|
* in winter, about snow or about avalanches. A hike without one keeps null.
|
|
*/
|
|
export const WINTER_ACTIVITIES = ['winter_hike', 'snowshoe'] as const;
|
|
export const winterActivitySchema = z.enum(WINTER_ACTIVITIES);
|
|
export type WinterActivity = z.infer<typeof winterActivitySchema>;
|
|
|
|
/** 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(),
|
|
/** HIK-01: a hike's winter label; null for none and for every other type. Absent in a list cached before it existed. */
|
|
winter_activity: winterActivitySchema.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(),
|
|
});
|
|
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;
|
|
/**
|
|
* 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: 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>;
|
|
|
|
/** 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(),
|
|
});
|
|
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(),
|
|
/** HIK-01, a hike's only: its winter label, null to take it off; left out, an edit keeps the one it has. */
|
|
winter_activity: winterActivitySchema.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 > 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;
|
|
winter_activity?: WinterActivity | 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 (value.winter_activity != null)
|
|
issues.push({ path: ['winter_activity'], message: `${named} has no winter activity` });
|
|
}
|
|
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: 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>;
|