feat(tours): import a ski track from GPX and keep it as recorded

SKI-02. Ski joins the types a GPX file can be imported as, offered in the
import preview's type field only: TREK routes no ski track, so the planner
never offers it. The preview asks for the discipline on purpose (alpine,
snowboard, cross-country or ski touring, none chosen at first) and, for
cross-country, the technique (classic, skating or unknown); Import waits
until both are named, and the technique stays in sight but dimmed for the
other disciplines. The track is stored exactly as recorded, with its file
name as provenance, its distance and heights from the file and no time:
no ski time is worked out and no hiking estimate stands in for one.

Opened in the planner, a ski track gets a rail card with its discipline on
a tinted tile, a cross-country technique as a badge beside it, the ski time
said as not worked out, and an info dot on what TREK does not say (snow,
avalanches, open runs, and that the 3D gradient colours are no difficulty
or avalanche rating). Change opens a dialog to classify the track again;
only the classification changes, through the offline core, with the
revision it was made at. Planning over the import is not offered for ski.
The existing 3D terrain view, the elevation profile and its focus work on
the track as for any import, with the standard map, topo and satellite and
no via ferratas. Lists, the day plan and the detail show the discipline as
a frosted corner badge on the tile and as a pill with the technique inside;
day assignment, copies and take-overs work as for any tour. The corner
badge is now one component that the winter label shares.

The server keeps tours.ski_discipline and tours.ski_technique (CHECK lists,
NULL on every other type), switches the ski type on without a routing
profile, refuses a ski import without its discipline, a technique outside
cross-country and ski fields on any other type, and takes a planned save's
classification off. PUT /api/trips/:tripId/tours/:placeId/ski classifies a
ski track again (place_edit, 409 on a stale revision, 400 for a tour that
is no ski track) and broadcasts tours:changed. GPX export names the
discipline (alpine_skiing, snowboarding, cross_country_skiing,
backcountry_skiing), and list_tours describes both fields with what a ski
track does not say. Ski is labelled plainly as Ski in every language, so
it no longer reads as ski touring.
This commit is contained in:
Maurice
2026-10-20 13:45:56 +02:00
parent 68907ec811
commit 0169be3d79
93 changed files with 2853 additions and 183 deletions
+21
View File
@@ -42,6 +42,8 @@ export class Tours {
winter_activity?: TToursWinterActivity | null;
run_profile?: TToursRunProfile | null;
run_pace_seconds_per_km?: number | null;
ski_discipline?: TToursSkiDiscipline | null;
ski_technique?: TToursSkiTechnique | null;
tourTypeRef!: Ref<TourTypes>;
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
tour_sources: Ref<TourSources> | null = null;
@@ -102,6 +104,23 @@ export const ToursRunProfile = {
export type TToursRunProfile = (typeof ToursRunProfile)[keyof typeof ToursRunProfile];
export const ToursSkiDiscipline = {
ALPINE: 'alpine',
SNOWBOARD: 'snowboard',
CROSS_COUNTRY: 'cross_country',
TOURING: 'touring',
} as const;
export type TToursSkiDiscipline = (typeof ToursSkiDiscipline)[keyof typeof ToursSkiDiscipline];
export const ToursSkiTechnique = {
CLASSIC: 'classic',
SKATING: 'skating',
UNKNOWN: 'unknown',
} as const;
export type TToursSkiTechnique = (typeof ToursSkiTechnique)[keyof typeof ToursSkiTechnique];
export const ToursSchema = defineEntity({
class: Tours,
repository: () => ToursRepository,
@@ -153,6 +172,8 @@ export const ToursSchema = defineEntity({
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(),
ski_technique: p.enum(() => ToursSkiTechnique).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(),
+2
View File
@@ -34,4 +34,6 @@ export interface ToursTable {
winter_activity: InsertOptional<string | null>;
run_profile: InsertOptional<string | null>;
run_pace_seconds_per_km: InsertOptional<number | null>;
ski_discipline: InsertOptional<string | null>;
ski_technique: InsertOptional<string | null>;
}
@@ -0,0 +1,23 @@
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.
*/
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'))",
);
this.addSql(
"ALTER TABLE tours ADD COLUMN ski_technique TEXT CHECK (ski_technique IS NULL OR ski_technique IN ('classic', 'skating', 'unknown'))",
);
this.addSql("UPDATE tour_types SET enabled = 1 WHERE key = 'ski'");
}
}
@@ -8,6 +8,8 @@ import type {
PaddleLakeStyle,
PaddleWaterContext,
RunProfile,
SkiDiscipline,
SkiTechnique,
WinterActivity,
} from '@trek/shared';
@@ -42,6 +44,9 @@ export interface TourListRow {
/** 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_discipline: string | null;
ski_technique: string | null;
boat_type: string | null;
water_context: string | null;
paddle_speed_kmh: number | null;
@@ -112,6 +117,8 @@ export interface TourRow {
winter_activity: string | null;
run_profile: string | null;
run_pace_seconds_per_km: number | null;
ski_discipline: string | null;
ski_technique: string | null;
}
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
@@ -134,6 +141,9 @@ export interface TourInsert {
/** 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_discipline?: SkiDiscipline | null;
ski_technique?: SkiTechnique | null;
/** A paddle tour's boat, water and chosen speed; left out, a hike's NULLs. */
boat_type?: PaddleBoatType | null;
water_context?: PaddleWaterContext | null;
@@ -153,6 +163,8 @@ export type TourUpdate = Omit<
| 'bike_profile'
| 'run_profile'
| 'run_pace_seconds_per_km'
| 'ski_discipline'
| 'ski_technique'
| 'boat_type'
| 'water_context'
| 'paddle_speed_kmh'
@@ -164,6 +176,8 @@ export type TourUpdate = Omit<
bike_profile: BikeProfile | 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;
@@ -171,6 +185,12 @@ export type TourUpdate = Omit<
paddle_lake_style: PaddleLakeStyle | null;
};
/** SKI-02: a ski track classified again: its discipline and, for cross-country, its technique. */
export interface TourSkiUpdate {
ski_discipline: SkiDiscipline;
ski_technique: SkiTechnique | null;
}
/** A recorded track's paddle plan (PAD-03, PAD-07): its speeds, its marks and the time they make. */
export interface TourPaddlePlanUpdate {
paddle_speed_kmh: number | null;
@@ -269,6 +289,8 @@ export class ToursRepository extends TrekRepository<Tours> {
'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',
't.ski_technique as ski_technique',
't.boat_type as boat_type',
't.water_context as water_context',
't.paddle_speed_kmh as paddle_speed_kmh',
@@ -637,6 +659,8 @@ export class ToursRepository extends TrekRepository<Tours> {
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,
ski_technique: input.ski_technique ?? null,
boat_type: input.boat_type ?? null,
water_context: input.water_context ?? null,
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
@@ -724,6 +748,8 @@ export class ToursRepository extends TrekRepository<Tours> {
bike_profile: input.bike_profile,
run_profile: input.run_profile,
run_pace_seconds_per_km: input.run_pace_seconds_per_km,
ski_discipline: input.ski_discipline,
ski_technique: input.ski_technique,
boat_type: input.boat_type,
water_context: input.water_context,
paddle_speed_kmh: input.paddle_speed_kmh,
@@ -758,6 +784,21 @@ export class ToursRepository extends TrekRepository<Tours> {
return Number(result.numUpdatedRows ?? 0) > 0;
}
/**
* SKI-02: a ski track of the trip classified again, its discipline and
* technique only; the track and everything else stay as they are. Returns
* whether a row of that trip was updated.
*/
async updateSkiInTrip(trip_id: number, place_id: number, input: TourSkiUpdate): Promise<boolean> {
const result = await this.db()
.updateTable('tours')
.set({ ski_discipline: input.ski_discipline, ski_technique: input.ski_technique })
.where('place_id', '=', place_id)
.where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id))
.executeTakeFirst();
return Number(result.numUpdatedRows ?? 0) > 0;
}
/**
* Keeps an import's recorded line before it is planned over for the first
* time: copies the place's line into the tour while the tour has no