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:
Maurice
2026-10-20 12:20:33 +02:00
parent 71fe69c1f1
commit cd407cf80c
61 changed files with 1266 additions and 24 deletions
+9
View File
@@ -39,6 +39,7 @@ export class Tours {
taken_over_from_trip_title?: string | null;
taken_over_from_tour_name?: string | null;
bike_profile?: TToursBikeProfile | null;
winter_activity?: TToursWinterActivity | null;
tourTypeRef!: Ref<TourTypes>;
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
tour_sources: Ref<TourSources> | null = null;
@@ -85,6 +86,13 @@ export const ToursBikeProfile = {
export type TToursBikeProfile = (typeof ToursBikeProfile)[keyof typeof ToursBikeProfile];
export const ToursWinterActivity = {
WINTER_HIKE: 'winter_hike',
SNOWSHOE: 'snowshoe',
} as const;
export type TToursWinterActivity = (typeof ToursWinterActivity)[keyof typeof ToursWinterActivity];
export const ToursSchema = defineEntity({
class: Tours,
repository: () => ToursRepository,
@@ -127,6 +135,7 @@ export const ToursSchema = defineEntity({
taken_over_from_trip_title: p.text().nullable(),
taken_over_from_tour_name: p.text().nullable(),
bike_profile: p.enum(() => ToursBikeProfile).nullable(),
winter_activity: p.enum(() => ToursWinterActivity).nullable(),
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(),
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
+1
View File
@@ -31,4 +31,5 @@ export interface ToursTable {
taken_over_from_trip_title: InsertOptional<string | null>;
taken_over_from_tour_name: InsertOptional<string | null>;
bike_profile: InsertOptional<string | null>;
winter_activity: InsertOptional<string | null>;
}
@@ -0,0 +1,16 @@
import { Migration } from '@mikro-orm/migrations';
/**
* HIK-01: a hike can carry a winter label, winter hiking or snowshoeing. It
* is a column of the tour, NULL on every tour saved before and on every other
* type, so no existing tour changes, and nothing routes or times by it.
*/
export class Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity extends Migration {
override name = 'Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity';
override up(): void {
this.addSql(
"ALTER TABLE tours ADD COLUMN winter_activity TEXT CHECK (winter_activity IS NULL OR winter_activity IN ('winter_hike', 'snowshoe'))",
);
}
}
@@ -2,7 +2,7 @@ import { type Tours, type TToursOrigin, ToursOrigin } from '../entities/Tours.en
import type { DB } from '../kysely/db';
import type { AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { BikeProfile, PaddleBoatType, PaddleLakeStyle, PaddleWaterContext } from '@trek/shared';
import type { BikeProfile, PaddleBoatType, PaddleLakeStyle, PaddleWaterContext, WinterActivity } from '@trek/shared';
/**
* A tour as the Tours list and detail read it: the `tours` facet joined with
@@ -30,6 +30,8 @@ export interface TourListRow {
origin: string;
/** BIK-01: a bike tour's profile; NULL on every other type. */
bike_profile: string | null;
/** HIK-01: a hike's winter label; NULL for none and on every other type. */
winter_activity: string | null;
boat_type: string | null;
water_context: string | null;
paddle_speed_kmh: number | null;
@@ -97,6 +99,7 @@ export interface TourRow {
taken_over_from_trip_title: string | null;
taken_over_from_tour_name: string | null;
bike_profile: string | null;
winter_activity: string | null;
}
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
@@ -114,6 +117,8 @@ export interface TourInsert {
max_hiking_difficulty: number | null;
/** BIK-01: a bike tour's profile; left out, NULL. */
bike_profile?: BikeProfile | null;
/** HIK-01: a hike's winter label; left out, NULL on a new tour and kept as it is by an edit. */
winter_activity?: WinterActivity | null;
/** A paddle tour's boat, water and chosen speed; left out, a hike's NULLs. */
boat_type?: PaddleBoatType | null;
water_context?: PaddleWaterContext | null;
@@ -242,6 +247,7 @@ export class ToursRepository extends TrekRepository<Tours> {
't.derived_from_place_id as derived_from_place_id',
't.origin as origin',
't.bike_profile as bike_profile',
't.winter_activity as winter_activity',
't.boat_type as boat_type',
't.water_context as water_context',
't.paddle_speed_kmh as paddle_speed_kmh',
@@ -607,6 +613,7 @@ export class ToursRepository extends TrekRepository<Tours> {
match_confidence: input.match_confidence,
max_hiking_difficulty: input.max_hiking_difficulty,
bike_profile: input.bike_profile ?? null,
winter_activity: input.winter_activity ?? null,
boat_type: input.boat_type ?? null,
water_context: input.water_context ?? null,
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
@@ -698,6 +705,7 @@ export class ToursRepository extends TrekRepository<Tours> {
portage_speed_kmh: input.portage_speed_kmh,
paddle_lake_style: input.paddle_lake_style,
...(input.hiking_preferences !== undefined ? { hiking_preferences: input.hiking_preferences } : {}),
...(input.winter_activity !== undefined ? { winter_activity: input.winter_activity } : {}),
})
.where('place_id', '=', place_id)
.where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id))