mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
feat(tours): plan ski and winter tours, snowshoeing and winter hiking among them
Ski becomes "Ski & winter", a type the planner plans with six disciplines (alpine, snowboard, cross-country, ski touring, snowshoeing, winter hiking), nothing preselected; saving needs a discipline, and a technique for cross-country. Every leg is drawn straight between the points through planner/skiRouting.ts, the one place the piste router will plug in, with its heights from the elevation source and no ski time worked out. The hike's winter label goes entirely: its migration, column, field, marks, GPX rule, MCP field and strings. A hike is summer only; a ski tour with the snowshoe discipline exports as snowshoeing. The unreleased ski migration takes the six disciplines and the collection migration loses the winter column. The pistes are offered for a ski tour, shown until switched off, and with no tour open, never for a hike. Export, copy, collections, stages (untimed), actual tracks, the atlas, list and day marks (one glyph per discipline), 3D, planning over a ski import and list_tours/get_tour_plan know the planned ski tour.
This commit is contained in:
@@ -44,7 +44,7 @@ export type CollectionTourTakeOverRequest = z.infer<typeof collectionTourTakeOve
|
||||
/**
|
||||
* A tour kept in a collection as the collection lists it: its measures as
|
||||
* they were saved, null staying null, and what the trip's list shows of its
|
||||
* type (winter label, ski discipline and technique, run profile and pace,
|
||||
* type (ski discipline and technique, run profile and pace,
|
||||
* bike profile, stage count).
|
||||
*/
|
||||
export const collectionTourItemSchema = tourListItemSchema
|
||||
@@ -64,7 +64,6 @@ export const collectionTourItemSchema = tourListItemSchema
|
||||
station_stay_minutes: true,
|
||||
station_stays_unknown: true,
|
||||
bike_profile: true,
|
||||
winter_activity: true,
|
||||
run_profile: true,
|
||||
run_pace_seconds_per_km: true,
|
||||
ski_discipline: true,
|
||||
|
||||
@@ -9,15 +9,15 @@ import {
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('ski tracks (SKI-02)', () => {
|
||||
it('SKI-SCHEMA-001: knows four disciplines and three cross-country techniques, unknown among them on purpose', () => {
|
||||
expect(SKI_DISCIPLINES).toEqual(['alpine', 'snowboard', 'cross_country', 'touring']);
|
||||
describe('ski and winter tours (SKI-02)', () => {
|
||||
it('SKI-SCHEMA-001: knows six disciplines and three cross-country techniques, unknown among them on purpose', () => {
|
||||
expect(SKI_DISCIPLINES).toEqual(['alpine', 'snowboard', 'cross_country', 'touring', 'snowshoe', 'winter_hike']);
|
||||
expect(SKI_TECHNIQUES).toEqual(['classic', 'skating', 'unknown']);
|
||||
});
|
||||
|
||||
it('SKI-SCHEMA-002: ski is a type a track can be imported as, never one the planner plans', () => {
|
||||
expect(importableTourTypeSchema.options).toEqual([...plannableTourTypeSchema.options, 'ski']);
|
||||
expect(plannableTourTypeSchema.safeParse('ski').success).toBe(false);
|
||||
it('SKI-SCHEMA-002: ski is a type the planner plans and a track can be imported as, never without its discipline', () => {
|
||||
expect(plannableTourTypeSchema.options).toContain('ski');
|
||||
expect([...importableTourTypeSchema.options].sort()).toEqual([...plannableTourTypeSchema.options].sort());
|
||||
const planned = {
|
||||
name: 'Valley run',
|
||||
tour_type: 'ski',
|
||||
@@ -30,16 +30,21 @@ describe('ski tracks (SKI-02)', () => {
|
||||
{ lat: 47.01, lng: 11.01, role: 'end', sequence: 1 },
|
||||
],
|
||||
};
|
||||
expect(tourCreateRequestSchema.safeParse(planned).success).toBe(false);
|
||||
expect(() => tourCreateRequestSchema.parse(planned)).toThrow(/a ski tour needs a discipline/);
|
||||
expect(tourCreateRequestSchema.parse({ ...planned, ski_discipline: 'snowshoe' })).toMatchObject({
|
||||
tour_type: 'ski',
|
||||
ski_discipline: 'snowshoe',
|
||||
max_hiking_difficulty: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('SKI-SCHEMA-003: a ski track names its discipline, a cross-country one its technique, and nothing else may', () => {
|
||||
expect(skiIssues(true, { ski_discipline: 'alpine' })).toEqual([]);
|
||||
expect(skiIssues(true, { ski_discipline: 'cross_country', ski_technique: 'unknown' })).toEqual([]);
|
||||
expect(skiIssues(true, {})).toEqual([{ path: ['ski_discipline'], message: 'a ski track needs a discipline' }]);
|
||||
expect(skiIssues(true, {})).toEqual([{ path: ['ski_discipline'], message: 'a ski tour needs a discipline' }]);
|
||||
expect(skiIssues(true, { ski_discipline: 'cross_country' })[0]?.path).toEqual(['ski_technique']);
|
||||
expect(skiIssues(true, { ski_discipline: 'touring', ski_technique: 'skating' })[0]?.message).toMatch(
|
||||
/only a cross-country track/,
|
||||
/only a cross-country tour/,
|
||||
);
|
||||
expect(skiIssues(false, { ski_discipline: 'alpine', ski_technique: 'classic' }).map((i) => i.path)).toEqual([
|
||||
['ski_discipline'],
|
||||
@@ -111,4 +116,71 @@ describe('ski tracks (SKI-02)', () => {
|
||||
expect(tourListItemSchema.parse(listed).ski_discipline).toBeUndefined();
|
||||
expect(tourListItemSchema.safeParse({ ...listed, ski_discipline: 'freeride' }).success).toBe(false);
|
||||
});
|
||||
|
||||
describe('a planned ski tour (SKI-02)', () => {
|
||||
const point = (lat: number, lng: number, role: 'start' | 'via' | 'end', sequence: number) => ({
|
||||
lat,
|
||||
lng,
|
||||
role,
|
||||
sequence,
|
||||
});
|
||||
const planned = {
|
||||
name: 'Hut to hut on snowshoes',
|
||||
tour_type: 'ski' as const,
|
||||
ski_discipline: 'touring' as const,
|
||||
route_geometry: [
|
||||
[47, 11, 1200],
|
||||
[47.01, 11.01, 1500],
|
||||
],
|
||||
waypoints: [point(47, 11, 'start', 0), point(47.01, 11.01, 'end', 1)],
|
||||
};
|
||||
|
||||
it('SKI-SCHEMA-007: saves every discipline, a cross-country tour only with its technique', () => {
|
||||
for (const discipline of SKI_DISCIPLINES.filter((value) => value !== 'cross_country')) {
|
||||
expect(tourCreateRequestSchema.safeParse({ ...planned, ski_discipline: discipline }).success).toBe(true);
|
||||
}
|
||||
expect(() => tourCreateRequestSchema.parse({ ...planned, ski_discipline: 'cross_country' })).toThrow(
|
||||
/a cross-country tour needs a technique/,
|
||||
);
|
||||
expect(
|
||||
tourCreateRequestSchema.parse({ ...planned, ski_discipline: 'cross_country', ski_technique: 'skating' }),
|
||||
).toMatchObject({ ski_technique: 'skating' });
|
||||
expect(() => tourCreateRequestSchema.parse({ ...planned, ski_technique: 'classic' })).toThrow(
|
||||
/only a cross-country tour has a technique/,
|
||||
);
|
||||
});
|
||||
|
||||
it('SKI-SCHEMA-008: works out no ski time and takes nothing of another type, a free leg allowed', () => {
|
||||
expect(() => tourCreateRequestSchema.parse({ ...planned, duration_seconds: 3600 })).toThrow(
|
||||
/a ski tour has no ski time worked out/,
|
||||
);
|
||||
expect(tourCreateRequestSchema.safeParse({ ...planned, duration_seconds: null }).success).toBe(true);
|
||||
expect(() => tourCreateRequestSchema.parse({ ...planned, max_hiking_difficulty: 3 })).toThrow(
|
||||
/a ski tour has no hiking difficulty/,
|
||||
);
|
||||
expect(() => tourCreateRequestSchema.parse({ ...planned, hiking_preferences: { avoid_steps: true } })).toThrow(
|
||||
/a ski tour has no hiking preferences/,
|
||||
);
|
||||
expect(() => tourCreateRequestSchema.parse({ ...planned, bike_profile: 'mtb' })).toThrow(/bike tour/);
|
||||
expect(() => tourCreateRequestSchema.parse({ ...planned, boat_type: 'sup' })).toThrow(/paddle tour/);
|
||||
const [start, end] = planned.waypoints;
|
||||
const line: Array<[number, number]> = [
|
||||
[47, 11],
|
||||
[47.01, 11.01],
|
||||
];
|
||||
const withLeg = (fixed_leg: object) => ({ ...planned, waypoints: [{ ...start, fixed_leg }, end] });
|
||||
expect(tourCreateRequestSchema.safeParse(withLeg({ kind: 'free', geometry: line })).success).toBe(true);
|
||||
const ferrata = withLeg({ kind: 'ferrata', ref: 'way/1', name: null, geometry: line });
|
||||
expect(() => tourCreateRequestSchema.parse(ferrata)).toThrow(/a ski tour has no via ferrata/);
|
||||
});
|
||||
|
||||
it('SKI-SCHEMA-009: keeps the ski fields off every other type', () => {
|
||||
const hike = { ...planned, tour_type: 'hike' as const, ski_discipline: 'winter_hike' as const };
|
||||
expect(() => tourCreateRequestSchema.parse(hike)).toThrow(/ski_discipline belongs to a ski tour/);
|
||||
const { ski_discipline: _discipline, ...walk } = planned;
|
||||
expect(() => tourCreateRequestSchema.parse({ ...walk, tour_type: 'walk', ski_technique: 'classic' })).toThrow(
|
||||
/ski_technique belongs to a ski tour/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* SKI-02: a ski track comes in as a GPX file and is kept as it was recorded.
|
||||
* TREK routes no ski track and works out no ski time: its distance and heights
|
||||
* are the file's, and nothing is said about snow, avalanches or whether a run
|
||||
* or a trail is open. What the skier names on purpose is the discipline, and
|
||||
* for cross-country the technique.
|
||||
* SKI-02: a ski and winter tour, planned in the planner or imported from a
|
||||
* GPX file. A planned one has every leg drawn between its points (the piste
|
||||
* router comes later), an imported one is kept as it was recorded. TREK works
|
||||
* out no ski time, and nothing is said about snow, avalanches or whether a
|
||||
* run or a trail is open. What the traveller names on purpose is the
|
||||
* discipline: alpine, snowboard, cross-country, ski touring, snowshoeing or
|
||||
* winter hiking (both of which live here and not on a hike), and for
|
||||
* cross-country the technique.
|
||||
*/
|
||||
export const SKI_DISCIPLINES = ['alpine', 'snowboard', 'cross_country', 'touring'] as const;
|
||||
export const SKI_DISCIPLINES = ['alpine', 'snowboard', 'cross_country', 'touring', 'snowshoe', 'winter_hike'] as const;
|
||||
export const skiDisciplineSchema = z.enum(SKI_DISCIPLINES);
|
||||
export type SkiDiscipline = z.infer<typeof skiDisciplineSchema>;
|
||||
|
||||
@@ -23,9 +26,10 @@ export type SkiTechnique = z.infer<typeof skiTechniqueSchema>;
|
||||
type SkiIssue = { path: string[]; message: string };
|
||||
|
||||
/**
|
||||
* What a ski track names, and what nothing else may: a ski track its
|
||||
* discipline, a cross-country track its technique as well; a technique on any
|
||||
* other discipline, and either field on another type, is refused.
|
||||
* What a ski tour names, and what nothing else may: a ski tour its
|
||||
* discipline, a cross-country tour its technique as well; a technique on any
|
||||
* other discipline, and either field on another type, is refused. Planned and
|
||||
* imported ski tours are held to the same rule.
|
||||
*/
|
||||
export function skiIssues(
|
||||
isSki: boolean,
|
||||
@@ -34,19 +38,19 @@ export function skiIssues(
|
||||
if (!isSki) {
|
||||
return (['ski_discipline', 'ski_technique'] as const)
|
||||
.filter((field) => value[field] != null)
|
||||
.map((field) => ({ path: [field], message: `${field} belongs to a ski track` }));
|
||||
.map((field) => ({ path: [field], message: `${field} belongs to a ski tour` }));
|
||||
}
|
||||
if (value.ski_discipline == null) return [{ path: ['ski_discipline'], message: 'a ski track needs a discipline' }];
|
||||
if (value.ski_discipline == null) return [{ path: ['ski_discipline'], message: 'a ski tour needs a discipline' }];
|
||||
const crossCountry = value.ski_discipline === 'cross_country';
|
||||
if (crossCountry && value.ski_technique == null)
|
||||
return [{ path: ['ski_technique'], message: 'a cross-country track needs a technique' }];
|
||||
return [{ path: ['ski_technique'], message: 'a cross-country tour needs a technique' }];
|
||||
if (!crossCountry && value.ski_technique != null)
|
||||
return [{ path: ['ski_technique'], message: 'only a cross-country track has a technique' }];
|
||||
return [{ path: ['ski_technique'], message: 'only a cross-country tour has a technique' }];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* A ski track classified again after its import: the discipline and the
|
||||
* A ski tour classified again after its import: the discipline and the
|
||||
* technique, nothing of the track. Refused with a 409 once the tour moved on
|
||||
* past the revision the choice was made at.
|
||||
*/
|
||||
|
||||
@@ -20,6 +20,8 @@ describe('tour POI search contracts (PLN-02)', () => {
|
||||
expect(TOUR_POI_CATEGORIES_BY_TYPE.hike).toContain('viewpoint');
|
||||
expect(TOUR_POI_CATEGORIES_BY_TYPE.kayak).toContain('slipway');
|
||||
expect(TOUR_POI_CATEGORIES_BY_TYPE.hike).not.toContain('slipway');
|
||||
expect(TOUR_POI_CATEGORIES_BY_TYPE.ski).toContain('shelter');
|
||||
expect(TOUR_POI_CATEGORIES_BY_TYPE.ski).not.toContain('slipway');
|
||||
for (const type of Object.values(TOUR_POI_CATEGORIES_BY_TYPE))
|
||||
for (const category of type) expect(TOUR_POI_CATEGORIES).toContain(category);
|
||||
});
|
||||
|
||||
@@ -29,6 +29,8 @@ export const TOUR_POI_CATEGORIES_BY_TYPE: Record<PlannableTourType, readonly Tou
|
||||
run: ['drinking_water', 'toilets', 'park', 'viewpoint', 'cafe', 'food', 'shelter', 'sights'],
|
||||
// HOR-01: water for horse and rider first; a ride stops where a walk would.
|
||||
horse: ['drinking_water', 'shelter', 'viewpoint', 'food', 'picnic', 'toilets', 'campsite', 'sights'],
|
||||
// SKI-02: huts and somewhere warm to eat first; a winter day out stops where the snow lets it.
|
||||
ski: ['shelter', 'food', 'cafe', 'restaurant', 'toilets', 'viewpoint', 'peak', 'drinking_water'],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
tourMaxHikingDifficultySchema,
|
||||
tourNameSchema,
|
||||
tourWebsiteSchema,
|
||||
WINTER_ACTIVITIES,
|
||||
} from './tours.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
@@ -537,58 +536,27 @@ describe('city walks (CW-01 to CW-03, CW-10)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('winter activity of a hike (HIK-01)', () => {
|
||||
const listedHike = {
|
||||
place_id: 1,
|
||||
tour_type: 'hike' as const,
|
||||
distance: 8,
|
||||
elevation_gain: 400,
|
||||
elevation_loss: 400,
|
||||
duration: 180,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
name: 'Snowy ridge',
|
||||
planned: false,
|
||||
caution: false,
|
||||
};
|
||||
|
||||
it('offers exactly winter hiking and snowshoeing, and a hike needs neither', () => {
|
||||
expect(WINTER_ACTIVITIES).toEqual(['winter_hike', 'snowshoe']);
|
||||
expect(tourCreateRequestSchema.parse(baseRequest).winter_activity).toBeUndefined();
|
||||
expect(tourCreateRequestSchema.parse({ ...baseRequest, winter_activity: null }).winter_activity).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the winter label a hike is saved with', () => {
|
||||
for (const activity of WINTER_ACTIVITIES) {
|
||||
expect(tourCreateRequestSchema.parse({ ...baseRequest, winter_activity: activity })).toMatchObject({
|
||||
tour_type: 'hike',
|
||||
winter_activity: activity,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses a label it does not know, and any label on another type', () => {
|
||||
expect(tourCreateRequestSchema.safeParse({ ...baseRequest, winter_activity: 'ski_tour' }).success).toBe(false);
|
||||
const bike = { ...baseRequest, tour_type: 'bike', bike_profile: 'mtb', winter_activity: 'snowshoe' };
|
||||
expect(() => tourCreateRequestSchema.parse(bike)).toThrow(/a bike tour has no winter activity/);
|
||||
const walk = { ...baseRequest, tour_type: 'walk', winter_activity: 'winter_hike' };
|
||||
expect(() => tourCreateRequestSchema.parse(walk)).toThrow(/a city walk has no winter activity/);
|
||||
const paddle = {
|
||||
...baseRequest,
|
||||
tour_type: 'kayak',
|
||||
boat_type: 'sup',
|
||||
water_context: 'lake',
|
||||
winter_activity: 'snowshoe',
|
||||
};
|
||||
expect(() => tourCreateRequestSchema.parse(paddle)).toThrow(/a paddle tour has no winter activity/);
|
||||
});
|
||||
|
||||
it('lists the label, and reads a list cached before it existed', () => {
|
||||
expect(tourListItemSchema.parse({ ...listedHike, winter_activity: 'snowshoe' }).winter_activity).toBe('snowshoe');
|
||||
expect(tourListItemSchema.parse(listedHike).winter_activity).toBeUndefined();
|
||||
expect(tourListItemSchema.safeParse({ ...listedHike, winter_activity: 'ski_tour' }).success).toBe(false);
|
||||
describe('a hike is summer only (HIK-01 withdrawn)', () => {
|
||||
it('keeps no winter label on a hike: the field is not part of the contract and is dropped', () => {
|
||||
const parsed = tourCreateRequestSchema.parse({ ...baseRequest, winter_activity: 'snowshoe' });
|
||||
expect(parsed).not.toHaveProperty('winter_activity');
|
||||
const listed = tourListItemSchema.parse({
|
||||
place_id: 1,
|
||||
tour_type: 'hike',
|
||||
distance: 8,
|
||||
elevation_gain: 400,
|
||||
elevation_loss: 400,
|
||||
duration: 180,
|
||||
difficulty: null,
|
||||
wanderer_ref: null,
|
||||
match_confidence: 1,
|
||||
max_hiking_difficulty: 2,
|
||||
name: 'Snowy ridge',
|
||||
planned: false,
|
||||
caution: false,
|
||||
winter_activity: 'winter_hike',
|
||||
});
|
||||
expect(listed).not.toHaveProperty('winter_activity');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -641,7 +609,7 @@ describe('runs (RUN-01)', () => {
|
||||
expect(() => tourCreateRequestSchema.parse({ ...run, run_profile: null })).toThrow(/a run needs a run profile/);
|
||||
expect(tourCreateRequestSchema.safeParse({ ...run, run_profile: 'sprint' }).success).toBe(false);
|
||||
expect(() => tourCreateRequestSchema.parse({ ...run, max_hiking_difficulty: 2 })).toThrow(/a run has no hiking/);
|
||||
expect(() => tourCreateRequestSchema.parse({ ...run, winter_activity: 'snowshoe' })).toThrow(/a run has no winter/);
|
||||
expect(() => tourCreateRequestSchema.parse({ ...run, ski_discipline: 'alpine' })).toThrow(/belongs to a ski tour/);
|
||||
expect(() => tourCreateRequestSchema.parse({ ...run, bike_profile: 'road' })).toThrow(/bike tour/);
|
||||
expect(() => tourCreateRequestSchema.parse({ ...run, boat_type: 'sup' })).toThrow(/paddle tour/);
|
||||
const overFerrata = { ...run, waypoints: [{ ...run.waypoints[0], fixed_leg: ferrata }, run.waypoints[1]] };
|
||||
|
||||
@@ -7,7 +7,13 @@ import {
|
||||
trackMarksSchema,
|
||||
} from './paddle-plan.schema';
|
||||
import { runPaceSecondsPerKmSchema } from './run-pace';
|
||||
import { skiDisciplineSchema, skiTechniqueSchema } from './ski.schema';
|
||||
import {
|
||||
skiDisciplineSchema,
|
||||
skiIssues,
|
||||
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';
|
||||
@@ -40,15 +46,17 @@ export type TourTypeKey = z.infer<typeof tourTypeKeySchema>;
|
||||
* 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.
|
||||
* draws by hand, never routed. `ski` is a ski and winter tour (SKI-02): a
|
||||
* discipline from alpine skiing to winter hiking, every leg drawn between
|
||||
* its points until the piste router plans them, no ski time worked out.
|
||||
*/
|
||||
export const plannableTourTypeSchema = z.enum(['hike', 'walk', 'run', 'bike', 'kayak', 'horse']);
|
||||
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,
|
||||
* and ski (SKI-02), which TREK does not route. A ski track comes in as it was
|
||||
* recorded and stays so; the planner never opens it as a draft.
|
||||
* 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>;
|
||||
@@ -87,16 +95,6 @@ export type PaddleWaterContext = z.infer<typeof paddleWaterContextSchema>;
|
||||
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);
|
||||
@@ -214,15 +212,13 @@ export const tourSchema = z.object({
|
||||
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(),
|
||||
/** 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 track's discipline; null for every other type. Absent in a list cached before it existed. */
|
||||
/** 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 track is skied; null for every other discipline and type. */
|
||||
/** SKI-02: how a cross-country tour is skied; null for every other discipline and type. */
|
||||
ski_technique: skiTechniqueSchema.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(),
|
||||
@@ -509,12 +505,13 @@ export const tourCreateRequestSchema = z
|
||||
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(),
|
||||
/** 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(),
|
||||
boat_type: paddleBoatTypeSchema.nullable().optional(),
|
||||
water_context: paddleWaterContextSchema.nullable().optional(),
|
||||
paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(),
|
||||
@@ -599,6 +596,7 @@ const TYPE_NAMED: Record<PlannableTourType, string> = {
|
||||
run: 'a run',
|
||||
hike: 'a hike',
|
||||
horse: 'a horse tour',
|
||||
ski: 'a ski tour',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -624,7 +622,9 @@ const PADDLE_FIELDS = [
|
||||
* 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.
|
||||
* 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), no via
|
||||
* ferrata, and no ski time worked out for it.
|
||||
*/
|
||||
function tourTypeIssues(value: {
|
||||
tour_type: PlannableTourType;
|
||||
@@ -632,9 +632,10 @@ function tourTypeIssues(value: {
|
||||
max_hiking_difficulty?: number | null;
|
||||
hiking_preferences?: HikingPreferences | null;
|
||||
bike_profile?: BikeProfile | null;
|
||||
winter_activity?: WinterActivity | null;
|
||||
run_profile?: RunProfile | null;
|
||||
run_pace_seconds_per_km?: number | null;
|
||||
ski_discipline?: SkiDiscipline | null;
|
||||
ski_technique?: SkiTechnique | null;
|
||||
boat_type?: PaddleBoatType | null;
|
||||
water_context?: PaddleWaterContext | null;
|
||||
paddle_speed_kmh?: number | null;
|
||||
@@ -668,17 +669,19 @@ function tourTypeIssues(value: {
|
||||
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' });
|
||||
issues.push(...runIssues(value));
|
||||
issues.push(...skiIssues(type === 'ski', value));
|
||||
// No router plans a ride, so no time is worked out for it; a planned total the rider enters is theirs.
|
||||
if (type === 'horse' && value.duration_seconds != null)
|
||||
issues.push({ path: ['duration_seconds'], message: 'a horse tour has no riding time worked out' });
|
||||
// SKI-02: no ski time is worked out until the piste router plans the legs; a planned total stays the traveller's.
|
||||
if (type === 'ski' && value.duration_seconds != null)
|
||||
issues.push({ path: ['duration_seconds'], message: 'a ski tour has no ski 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) => {
|
||||
@@ -687,7 +690,10 @@ function tourTypeIssues(value: {
|
||||
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') && kind === 'ferrata')
|
||||
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` });
|
||||
if (type !== 'horse' && kind === 'horse_route')
|
||||
issues.push({
|
||||
|
||||
Reference in New Issue
Block a user