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
+114
View File
@@ -0,0 +1,114 @@
import { SKI_DISCIPLINES, SKI_TECHNIQUES, skiIssues, tourSkiClassificationRequestSchema } from './ski.schema';
import { tourGpxImportFieldsSchema } from './tour-gpx-import.schema';
import {
importableTourTypeSchema,
plannableTourTypeSchema,
tourCreateRequestSchema,
tourListItemSchema,
} from './tours.schema';
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']);
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);
const planned = {
name: 'Valley run',
tour_type: 'ski',
route_geometry: [
[47, 11, 1200],
[47.01, 11.01, 1100],
],
waypoints: [
{ lat: 47, lng: 11, role: 'start', sequence: 0 },
{ lat: 47.01, lng: 11.01, role: 'end', sequence: 1 },
],
};
expect(tourCreateRequestSchema.safeParse(planned).success).toBe(false);
});
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, { 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/,
);
expect(skiIssues(false, { ski_discipline: 'alpine', ski_technique: 'classic' }).map((i) => i.path)).toEqual([
['ski_discipline'],
['ski_technique'],
]);
expect(skiIssues(false, { ski_discipline: null, ski_technique: null })).toEqual([]);
});
it('SKI-SCHEMA-004: imports a GPX file as ski only with its discipline, and ski fields only for ski', () => {
expect(tourGpxImportFieldsSchema.parse({ tour_type: 'ski', ski_discipline: 'touring' })).toEqual({
tour_type: 'ski',
ski_discipline: 'touring',
});
expect(
tourGpxImportFieldsSchema.parse({ tour_type: 'ski', ski_discipline: 'cross_country', ski_technique: 'skating' }),
).toMatchObject({ ski_technique: 'skating' });
for (const fields of [
{ tour_type: 'ski' },
{ tour_type: 'ski', ski_discipline: 'cross_country' },
{ tour_type: 'ski', ski_discipline: 'alpine', ski_technique: 'classic' },
{ tour_type: 'ski', ski_discipline: 'slalom' },
{ tour_type: 'ski', ski_discipline: 'alpine', run_profile: 'running' },
{ tour_type: 'hike', ski_discipline: 'alpine' },
{ tour_type: 'run', run_profile: 'trail', ski_technique: 'classic' },
]) {
expect(tourGpxImportFieldsSchema.safeParse(fields).success).toBe(false);
}
});
it('SKI-SCHEMA-005: a classification names a discipline, a technique only for cross-country, and an optional revision', () => {
expect(tourSkiClassificationRequestSchema.parse({ ski_discipline: 'snowboard', expected_revision: 3 })).toEqual({
ski_discipline: 'snowboard',
expected_revision: 3,
});
expect(
tourSkiClassificationRequestSchema.parse({ ski_discipline: 'cross_country', ski_technique: 'classic' }),
).toMatchObject({ ski_technique: 'classic' });
for (const body of [
{},
{ ski_discipline: 'cross_country' },
{ ski_discipline: 'cross_country', ski_technique: null },
{ ski_discipline: 'alpine', ski_technique: 'skating' },
{ ski_discipline: 'alpine', expected_revision: 0 },
]) {
expect(tourSkiClassificationRequestSchema.safeParse(body).success).toBe(false);
}
});
it('SKI-SCHEMA-006: lists the discipline and technique, and reads a list cached before they existed', () => {
const listed = {
place_id: 4,
tour_type: 'ski',
distance: 12.4,
elevation_gain: 980,
elevation_loss: 990,
duration: null,
difficulty: null,
wanderer_ref: null,
match_confidence: 1,
max_hiking_difficulty: null,
name: 'Up to the hut',
planned: false,
caution: false,
};
expect(tourListItemSchema.parse({ ...listed, ski_discipline: 'touring', ski_technique: null })).toMatchObject({
ski_discipline: 'touring',
ski_technique: null,
});
expect(tourListItemSchema.parse(listed).ski_discipline).toBeUndefined();
expect(tourListItemSchema.safeParse({ ...listed, ski_discipline: 'freeride' }).success).toBe(false);
});
});
+63
View File
@@ -0,0 +1,63 @@
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.
*/
export const SKI_DISCIPLINES = ['alpine', 'snowboard', 'cross_country', 'touring'] as const;
export const skiDisciplineSchema = z.enum(SKI_DISCIPLINES);
export type SkiDiscipline = z.infer<typeof skiDisciplineSchema>;
/**
* How a cross-country track is skied. `unknown` is an answer the skier gives
* on purpose (a borrowed track, a mixed day), not a gap TREK fills in; every
* other discipline has no technique at all.
*/
export const SKI_TECHNIQUES = ['classic', 'skating', 'unknown'] as const;
export const skiTechniqueSchema = z.enum(SKI_TECHNIQUES);
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.
*/
export function skiIssues(
isSki: boolean,
value: { ski_discipline?: SkiDiscipline | null; ski_technique?: SkiTechnique | null },
): SkiIssue[] {
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` }));
}
if (value.ski_discipline == null) return [{ path: ['ski_discipline'], message: 'a ski track 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' }];
if (!crossCountry && value.ski_technique != null)
return [{ path: ['ski_technique'], message: 'only a cross-country track has a technique' }];
return [];
}
/**
* A ski track 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.
*/
export const tourSkiClassificationRequestSchema = z
.object({
ski_discipline: skiDisciplineSchema,
ski_technique: skiTechniqueSchema.nullable().optional(),
/** P2C-03: the revision the classification was made at. */
expected_revision: z.number().int().min(1).nullable().optional(),
})
.superRefine((value, ctx) =>
skiIssues(true, value).forEach((issue) => ctx.addIssue({ code: z.ZodIssueCode.custom, ...issue })),
);
export type TourSkiClassificationRequest = z.infer<typeof tourSkiClassificationRequestSchema>;
@@ -18,7 +18,7 @@ describe('Tours GPX import fields', () => {
it('refuses a paddle import without a boat, and a hike that names one', () => {
expect(() => tourGpxImportFieldsSchema.parse({ tour_type: 'kayak', water_context: 'lake' })).toThrow(/boat_type/);
expect(() => tourGpxImportFieldsSchema.parse({ tour_type: 'hike', boat_type: 'sup' })).toThrow(/paddle import/);
expect(() => tourGpxImportFieldsSchema.parse({ tour_type: 'ski' })).toThrow();
expect(() => tourGpxImportFieldsSchema.parse({ tour_type: 'food' })).toThrow();
});
});
+11 -3
View File
@@ -1,8 +1,9 @@
import { skiDisciplineSchema, skiIssues, skiTechniqueSchema } from './ski.schema';
import {
bikeProfileSchema,
importableTourTypeSchema,
paddleBoatTypeSchema,
paddleWaterContextSchema,
plannableTourTypeSchema,
runProfileSchema,
} from './tours.schema';
@@ -41,17 +42,24 @@ export type TourGpxPreviewResponse = z.infer<typeof tourGpxPreviewResponseSchema
* form sends them. Left out, the import is a hike, as it always was; a paddle
* import names its boat and water, and a bike import and a run import (RUN-01)
* their profile, like a planned tour of that type does. A run import has no
* pace: its time stays unknown until a run is planned from it.
* pace: its time stays unknown until a run is planned from it. A ski import
* (SKI-02) names its discipline, a cross-country one its technique too; the
* track is kept as recorded, with no time worked out for it.
*/
export const tourGpxImportFieldsSchema = z
.object({
tour_type: plannableTourTypeSchema.default('hike'),
tour_type: importableTourTypeSchema.default('hike'),
bike_profile: bikeProfileSchema.optional(),
run_profile: runProfileSchema.optional(),
boat_type: paddleBoatTypeSchema.optional(),
water_context: paddleWaterContextSchema.optional(),
ski_discipline: skiDisciplineSchema.optional(),
ski_technique: skiTechniqueSchema.optional(),
})
.superRefine((value, ctx) => {
skiIssues(value.tour_type === 'ski', value).forEach((issue) =>
ctx.addIssue({ code: z.ZodIssueCode.custom, ...issue }),
);
const owners = { bike_profile: 'bike', run_profile: 'run', boat_type: 'kayak', water_context: 'kayak' } as const;
const names = { bike: 'a bike import', run: 'a run import', kayak: 'a paddle import' } as const;
for (const [field, owner] of Object.entries(owners) as Array<[keyof typeof owners, keyof typeof names]>) {
+13
View File
@@ -6,6 +6,7 @@ import {
trackMarksSchema,
} from './paddle-plan.schema';
import { runPaceSecondsPerKmSchema } from './run-pace';
import { skiDisciplineSchema, skiTechniqueSchema } from './ski.schema';
import { tourPoiCategorySchema } from './tour-poi-category';
import { tourOriginSchema, tourProvenanceSchema } from './tour-provenance.schema';
@@ -37,6 +38,14 @@ export type TourTypeKey = z.infer<typeof tourTypeKeySchema>;
export const plannableTourTypeSchema = z.enum(['hike', 'walk', 'run', 'bike', 'kayak']);
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.
*/
export const importableTourTypeSchema = z.enum(['hike', 'walk', 'run', 'bike', 'kayak', 'ski']);
export type ImportableTourType = z.infer<typeof importableTourTypeSchema>;
/**
* BIK-01 to BIK-04: what a bike tour is ridden on, the one profile it is routed
* for. Touring/city keeps to paved ways, cycle paths and quiet roads; road
@@ -194,6 +203,10 @@ export const tourSchema = z.object({
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_discipline: skiDisciplineSchema.nullable().optional(),
/** SKI-02: how a cross-country track 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(),
water_context: paddleWaterContextSchema.nullable().optional(),