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; /** * 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; /** * 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; /** 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; /** 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; /** 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; /** * 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; /** 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; /** * 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; /** 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; 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; /** * 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; export const tourTypeListResponseSchema = z.object({ tourTypes: z.array(tourTypeSchema), }); export type TourTypeListResponse = z.infer; /** * 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; export const tourListResponseSchema = z.object({ tours: z.array(tourListItemSchema), }); export type TourListResponse = z.infer; export const tourWaypointRoleSchema = z.enum(['start', 'via', 'end']); export type TourWaypointRole = z.infer; /** 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/` 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; 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; /** 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; /** * 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; /** What a client sends: the fields the server fills in may be left out. */ export type TourCreateInput = z.input; type Issue = { path: Array; 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; /** * 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; /** Saved-tour editor payload: list metadata plus its persisted routing controls. */ export const tourDetailResponseSchema = tourCreateResponseSchema; export type TourDetailResponse = z.infer; export const tourImportGpxResponseSchema = z.object({ tours: z.array(tourListItemSchema), caution: z.boolean(), skipped: z.number().int(), }); export type TourImportGpxResponse = z.infer;