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:
Maurice
2026-10-20 18:27:53 +02:00
parent 204ee2ef86
commit 164a3633ac
116 changed files with 1675 additions and 1576 deletions
@@ -28,7 +28,6 @@ export class CollectionTours {
match_confidence?: number | null;
max_hiking_difficulty?: number | null;
bike_profile?: string | null;
winter_activity?: string | null;
run_profile?: string | null;
run_pace_seconds_per_km?: number | null;
ski_discipline?: string | null;
@@ -76,7 +75,6 @@ export const CollectionToursSchema = defineEntity({
match_confidence: p.double().nullable(),
max_hiking_difficulty: p.integer().nullable(),
bike_profile: p.text().nullable(),
winter_activity: p.text().nullable(),
run_profile: p.text().nullable(),
run_pace_seconds_per_km: p.integer().nullable(),
ski_discipline: p.text().nullable(),
+2 -9
View File
@@ -44,7 +44,6 @@ 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;
run_profile?: TToursRunProfile | null;
run_pace_seconds_per_km?: number | null;
ski_discipline?: TToursSkiDiscipline | null;
@@ -102,13 +101,6 @@ 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 ToursRunProfile = {
RUNNING: 'running',
TRAIL: 'trail',
@@ -121,6 +113,8 @@ export const ToursSkiDiscipline = {
SNOWBOARD: 'snowboard',
CROSS_COUNTRY: 'cross_country',
TOURING: 'touring',
SNOWSHOE: 'snowshoe',
WINTER_HIKE: 'winter_hike',
} as const;
export type TToursSkiDiscipline = (typeof ToursSkiDiscipline)[keyof typeof ToursSkiDiscipline];
@@ -181,7 +175,6 @@ 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(),
run_profile: p.enum(() => ToursRunProfile).nullable(),
run_pace_seconds_per_km: p.integer().nullable(),
ski_discipline: p.enum(() => ToursSkiDiscipline).nullable(),
@@ -23,7 +23,6 @@ export interface CollectionToursTable {
match_confidence: InsertOptional<number | null>;
max_hiking_difficulty: InsertOptional<number | null>;
bike_profile: InsertOptional<string | null>;
winter_activity: InsertOptional<string | null>;
run_profile: InsertOptional<string | null>;
run_pace_seconds_per_km: InsertOptional<number | null>;
ski_discipline: InsertOptional<string | null>;
-1
View File
@@ -31,7 +31,6 @@ 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>;
run_profile: InsertOptional<string | null>;
run_pace_seconds_per_km: InsertOptional<number | null>;
ski_discipline: InsertOptional<string | null>;
@@ -1,16 +0,0 @@
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'))",
);
}
}
@@ -1,19 +1,21 @@
import { Migration } from '@mikro-orm/migrations';
/**
* SKI-02: a tour can be a ski track, imported from a GPX file and kept as it
* was recorded. The `ski` key has been in `tour_types`, disabled and without
* a routing profile, since every contract type was seeded; it is switched on
* here and keeps no routing profile, since TREK routes no ski track. The
* discipline and, for cross-country, the technique are columns of the tour,
* NULL on every other type, so no existing tour changes its meaning.
* SKI-02: a tour can be a ski and winter tour, planned in the planner or
* imported from a GPX file. The `ski` key has been in `tour_types`, disabled
* and without a routing profile, since every contract type was seeded; it is
* switched on here and keeps no routing profile: the planner draws its legs
* itself. The discipline (alpine, snowboard, cross-country, ski touring,
* snowshoe or winter hike) and, for cross-country, the technique are columns
* of the tour, NULL on every other type, so no existing tour changes its
* meaning.
*/
export class Migration20200101045520_a_tour_can_be_a_ski_track extends Migration {
override name = 'Migration20200101045520_a_tour_can_be_a_ski_track';
override up(): void {
this.addSql(
"ALTER TABLE tours ADD COLUMN ski_discipline TEXT CHECK (ski_discipline IS NULL OR ski_discipline IN ('alpine', 'snowboard', 'cross_country', 'touring'))",
"ALTER TABLE tours ADD COLUMN ski_discipline TEXT CHECK (ski_discipline IS NULL OR ski_discipline IN ('alpine', 'snowboard', 'cross_country', 'touring', 'snowshoe', 'winter_hike'))",
);
this.addSql(
"ALTER TABLE tours ADD COLUMN ski_technique TEXT CHECK (ski_technique IS NULL OR ski_technique IN ('classic', 'skating', 'unknown'))",
@@ -36,7 +36,6 @@ export class Migration20200101046100_a_collection_keeps_whole_tours extends Migr
match_confidence REAL,
max_hiking_difficulty INTEGER,
bike_profile TEXT,
winter_activity TEXT,
run_profile TEXT,
run_pace_seconds_per_km INTEGER,
ski_discipline TEXT,
@@ -32,7 +32,6 @@ export interface CollectionTourRow {
match_confidence: number | null;
max_hiking_difficulty: number | null;
bike_profile: string | null;
winter_activity: string | null;
run_profile: string | null;
run_pace_seconds_per_km: number | null;
ski_discipline: string | null;
+2 -11
View File
@@ -11,7 +11,6 @@ import type {
RunProfile,
SkiDiscipline,
SkiTechnique,
WinterActivity,
} from '@trek/shared';
/**
@@ -40,12 +39,10 @@ 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;
/** RUN-01: a run's profile and the pace it is planned at, seconds per km; NULL on every other type. */
run_profile: string | null;
run_pace_seconds_per_km: number | null;
/** SKI-02: a ski track's discipline and, for cross-country, its technique; NULL on every other type. */
/** SKI-02: a ski tour's discipline and, for cross-country, its technique; NULL on every other type. */
ski_discipline: string | null;
ski_technique: string | null;
boat_type: string | null;
@@ -120,7 +117,6 @@ 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;
run_profile: string | null;
run_pace_seconds_per_km: number | null;
ski_discipline: string | null;
@@ -144,12 +140,10 @@ 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;
/** RUN-01: a run's profile and its pace, seconds per km; left out, NULL. */
run_profile?: RunProfile | null;
run_pace_seconds_per_km?: number | null;
/** SKI-02: a ski track's discipline and technique; left out, NULL. */
/** SKI-02: a ski tour's discipline and technique; left out, NULL. */
ski_discipline?: SkiDiscipline | null;
ski_technique?: SkiTechnique | null;
/** A paddle tour's boat, water and chosen speed; left out, a hike's NULLs. */
@@ -307,7 +301,6 @@ 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.run_profile as run_profile',
't.run_pace_seconds_per_km as run_pace_seconds_per_km',
't.ski_discipline as ski_discipline',
@@ -774,7 +767,6 @@ 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,
run_profile: input.run_profile ?? null,
run_pace_seconds_per_km: input.run_pace_seconds_per_km ?? null,
ski_discipline: input.ski_discipline ?? null,
@@ -876,7 +868,6 @@ 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))