mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
feat(tours): a hike can be marked as a winter hike or a snowshoe hike
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.
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
tourMaxHikingDifficultySchema,
|
||||
tourNameSchema,
|
||||
tourWebsiteSchema,
|
||||
WINTER_ACTIVITIES,
|
||||
} from './tours.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
@@ -534,3 +535,58 @@ describe('city walks (CW-01 to CW-03, CW-10)', () => {
|
||||
expect(tourDescriptionSchema.safeParse('x'.repeat(2001)).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,16 @@ 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);
|
||||
@@ -165,6 +175,8 @@ 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(),
|
||||
/** 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(),
|
||||
@@ -401,6 +413,8 @@ 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(),
|
||||
boat_type: paddleBoatTypeSchema.nullable().optional(),
|
||||
water_context: paddleWaterContextSchema.nullable().optional(),
|
||||
paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(),
|
||||
@@ -493,6 +507,7 @@ function tourTypeIssues(value: {
|
||||
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;
|
||||
@@ -526,6 +541,8 @@ 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' });
|
||||
|
||||
Reference in New Issue
Block a user