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
@@ -66,7 +66,6 @@ const UNNUMBERED = [
'Migration20200101045200_a_tour_can_be_ridden',
'Migration20200101045300_a_tour_can_be_a_city_walk',
'Migration20200101045400_a_tour_waypoint_remembers_the_kind_of_place_it_was_found_as',
'Migration20200101045500_a_hike_can_be_marked_as_a_winter_activity',
'Migration20200101045510_a_tour_can_be_a_run',
'Migration20200101045520_a_tour_can_be_a_ski_track',
'Migration20200101045600_a_tour_can_be_ridden_on_horseback',
@@ -110,7 +110,6 @@ describe('ToursRepository reads', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
@@ -150,7 +149,6 @@ describe('ToursRepository reads', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
@@ -379,7 +377,6 @@ describe('ToursRepository writes', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
@@ -428,20 +425,19 @@ describe('ToursRepository writes', () => {
});
});
it('TOURREPO-022: a hike keeps its winter label through an edit that leaves it out, and loses it only when told (HIK-01)', async () => {
it('TOURREPO-022: a planned ski tour keeps snowshoeing or winter hiking as its discipline, and a hike takes it off (SKI-02)', async () => {
const { trip } = fixture();
const place = createPlace(testDb, trip.id, { name: 'Snowy ridge' });
await repo.insertTour({ place_id: place.id, ...METRICS, winter_activity: 'snowshoe' });
expect((await repo.findInTrip(trip.id, place.id))?.winter_activity).toBe('snowshoe');
const ski = { ...METRICS, tour_type: 'ski', max_hiking_difficulty: null, duration: null };
await repo.insertTour({ place_id: place.id, ...ski, ski_discipline: 'snowshoe' });
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ tour_type: 'ski', ski_discipline: 'snowshoe' });
const winterHike = { ...HIKE_UPDATE, ...ski, ski_discipline: 'winter_hike' as const };
expect(await repo.updateInTrip(trip.id, place.id, winterHike)).toBe(true);
expect((await repo.listForTrip(trip.id))[0]).toMatchObject({ tour_type: 'ski', ski_discipline: 'winter_hike' });
expect(await repo.updateInTrip(trip.id, place.id, HIKE_UPDATE)).toBe(true);
expect((await repo.findInTrip(trip.id, place.id))?.winter_activity).toBe('snowshoe');
expect(await repo.updateInTrip(trip.id, place.id, { ...HIKE_UPDATE, winter_activity: 'winter_hike' })).toBe(true);
expect((await repo.listForTrip(trip.id))[0]?.winter_activity).toBe('winter_hike');
expect(await repo.updateInTrip(trip.id, place.id, { ...HIKE_UPDATE, winter_activity: null })).toBe(true);
expect((await repo.findInTrip(trip.id, place.id))?.winter_activity).toBeNull();
expect(await repo.findInTrip(trip.id, place.id)).toMatchObject({ ski_discipline: null, ski_technique: null });
});
it('TOURREPO-023: a run keeps its profile and pace, an edit replaces them, and another type takes them off (RUN-01)', async () => {
@@ -537,7 +533,6 @@ describe('ToursRepository writes', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
@@ -1,7 +1,8 @@
/**
* SKI-02: `Migration20200101045520_a_tour_can_be_a_ski_track` switches the ski
* type on, without a routing profile, and gives a tour its ski discipline and
* cross-country technique. Both columns hold a CHECK list the entity carries
* type on, without a routing profile, and gives a tour its ski discipline (six
* of them, snowshoeing and winter hiking among them) and cross-country
* technique. Both columns hold a CHECK list the entity carries
* as its enum and the contract holds too: a value one side takes and the
* other refuses would be a ski track the import accepts and the database
* cannot store.
@@ -56,6 +57,9 @@ describe('ski columns match the contract', () => {
orm,
"INSERT INTO tours (place_id, tour_type, ski_discipline, ski_technique) VALUES (101, 'ski', 'cross_country', 'unknown')",
);
// Snowshoeing and winter hiking are disciplines of a ski tour, never labels of a hike.
await rawExec(orm, "UPDATE tours SET ski_discipline = 'snowshoe', ski_technique = NULL WHERE place_id = 101");
await rawExec(orm, "UPDATE tours SET ski_discipline = 'winter_hike' WHERE place_id = 101");
await expect(rawExec(orm, "UPDATE tours SET ski_discipline = 'freeride' WHERE place_id = 101")).rejects.toThrow(
/CHECK/,
);
@@ -1,16 +0,0 @@
import { ToursWinterActivity } from '../../../src/db/entities/Tours.entity';
import { WINTER_ACTIVITIES } from '@trek/shared';
import { describe, expect, it } from 'vitest';
/**
* HIK-01: the winter label column holds a CHECK list (migration 045500) that
* the entity carries as its enum, and the contract holds its own list. A label
* one side knows and the other refuses would be a hike the planner can save
* and the database cannot store.
*/
describe('winter activity column matches the contract', () => {
it('knows the same winter activities as the tours contract', () => {
expect(Object.values(ToursWinterActivity).sort()).toEqual([...WINTER_ACTIVITIES].sort());
});
});
@@ -41,7 +41,6 @@ const row: CollectionTourRow = {
match_confidence: 1,
max_hiking_difficulty: null,
bike_profile: 'gravel',
winter_activity: null,
run_profile: null,
run_pace_seconds_per_km: null,
ski_discipline: null,
@@ -92,8 +91,7 @@ describe('collection tour helpers (LIB-03)', () => {
it('LIB3-HLP-007: a kept tour carries what the trip list shows of its type, and its stages', () => {
expect(keptTourItem(row)).toMatchObject({
bike_profile: 'gravel',
winter_activity: null,
ski_discipline: null,
ski_discipline: null,
run_pace_seconds_per_km: null,
stage_count: null,
});
@@ -107,8 +105,9 @@ describe('collection tour helpers (LIB-03)', () => {
expect(keptTourItem(ski)).toMatchObject({ ski_discipline: 'cross_country', ski_technique: 'skating' });
const run = { ...row, tour_type: 'run', bike_profile: null, run_profile: 'trail', run_pace_seconds_per_km: 360 };
expect(keptTourItem(run)).toMatchObject({ run_profile: 'trail', run_pace_seconds_per_km: 360 });
const snowshoe = { ...row, tour_type: 'hike', bike_profile: null, winter_activity: 'snowshoe' };
expect(keptTourItem(snowshoe).winter_activity).toBe('snowshoe');
const snowshoe = { ...row, tour_type: 'ski', bike_profile: null, ski_discipline: 'snowshoe' };
expect(keptTourItem(snowshoe)).toMatchObject({ tour_type: 'ski', ski_discipline: 'snowshoe', ski_technique: null });
expect(keptTourItem(snowshoe)).not.toHaveProperty('winter_activity');
const hutToHut = {
...row,
waypoints: [
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest';
// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-007
describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01, SKI-02, HOR-01)', () => {
describe('gpxActivityOf (PR1-06A, BIK-01, RUN-01, SKI-02, HOR-01)', () => {
it('TOUR-GPX-ACTIVITY-001: a bike tour names its profile, and the plainest riding without a known one', () => {
expect(gpxActivityOf({ tour_type: 'bike', bike_profile: 'mtb' })).toBe('mountain_biking');
expect(gpxActivityOf({ tour_type: 'bike', bike_profile: null })).toBe('cycling');
@@ -22,11 +22,11 @@ describe('gpxActivityOf (PR1-06A, BIK-01, HIK-01, RUN-01, SKI-02, HOR-01)', () =
expect(gpxActivityOf({ tour_type: 'walk' })).toBe('walk');
});
it('TOUR-GPX-ACTIVITY-004: a hike on snowshoes is snowshoeing, a winter hike stays hiking (HIK-01)', () => {
expect(gpxActivityOf({ tour_type: 'hike', winter_activity: 'snowshoe' })).toBe('snowshoeing');
expect(gpxActivityOf({ tour_type: 'hike', winter_activity: 'winter_hike' })).toBe('hiking');
expect(gpxActivityOf({ tour_type: 'hike', winter_activity: null })).toBe('hiking');
expect(gpxActivityOf({ tour_type: 'walk', winter_activity: 'snowshoe' })).toBe('walk');
it('TOUR-GPX-ACTIVITY-004: snowshoes make a ski tour snowshoeing, a winter hike stays hiking, a hike is hiking (SKI-02)', () => {
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'snowshoe' })).toBe('snowshoeing');
expect(gpxActivityOf({ tour_type: 'ski', ski_discipline: 'winter_hike' })).toBe('hiking');
expect(gpxActivityOf({ tour_type: 'hike', ski_discipline: null })).toBe('hiking');
expect(gpxActivityOf({ tour_type: 'walk' })).toBe('walk');
});
it('TOUR-GPX-ACTIVITY-005: a run is running, and stays running without a profile this build knows (RUN-01)', () => {
@@ -122,4 +122,24 @@ describe('tour plan read (MCP-T01)', () => {
} as unknown as TourDetailResponse;
expect(derivedFields(staged, none)).toEqual(expect.arrayContaining(['tour.stage_count', 'tour.stage_stops']));
});
it('TOUR-PLAN-READ-008: every leg of a planned ski tour is drawn freely with its straight length, a fixed one kept (SKI-02)', () => {
const free = point(1, {
fixed_leg: {
kind: 'free',
geometry: [
[48.01, 11],
[48.02, 11],
],
},
});
expect(planLegs([point(0), free, point(2)], { recorded: false, drawn: true })).toEqual([
expect.objectContaining({ kind: 'free', name: null, length_km: 1.11 }),
expect.objectContaining({ kind: 'free', name: null, length_km: 1.11 }),
]);
// A ski track kept as recorded stays a track.
expect(planLegs([point(0), point(1)], { recorded: true, drawn: true })).toEqual([
expect.objectContaining({ kind: 'track', length_km: null }),
]);
});
});
+38 -9
View File
@@ -561,19 +561,25 @@ describe('ToursMcp', () => {
}
});
it('TOURS-MCP-020: list_tours names the winter label of a hike, and says it is only a label (HIK-01)', async () => {
it('TOURS-MCP-020: list_tours lists snowshoeing as a ski tour, and a hike with no winter label at all (SKI-02)', async () => {
const { user, trip, ridge, gorge } = fixture();
const orm = await createTestOrm(db);
await updateRows(orm, Tours, { place: ridge.id }, { winter_activity: 'snowshoe' });
await updateRows(
orm,
Tours,
{ place: ridge.id },
{ tourTypeRef: 'ski', max_hiking_difficulty: null, ski_discipline: 'snowshoe' },
);
await orm.close();
const { tours } = read(await mcp.listTours({ tripId: trip.id }, ctx(user.id)));
const byId = new Map(tours.map((tour: { place_id: number }) => [tour.place_id, tour]));
expect(byId.get(ridge.id)).toMatchObject({ tour_type: 'hike', winter_activity: 'snowshoe' });
expect(byId.get(gorge.id)).toMatchObject({ winter_activity: null });
expect(byId.get(ridge.id)).toMatchObject({ tour_type: 'ski', ski_discipline: 'snowshoe', ski_technique: null });
expect(byId.get(gorge.id)).toMatchObject({ tour_type: 'hike' });
expect(byId.get(gorge.id)).not.toHaveProperty('winter_activity');
const entry = getEntry(ToursMcp as unknown as ClassRef, 'listTours');
const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : '';
expect(description).toMatch(/winter_activity: winter_hike or snowshoe/);
expect(description).toContain('never a statement that a way can be walked in winter');
expect(description).not.toContain('winter_activity');
expect(description).toContain('a hike is summer only');
});
it('TOURS-MCP-021: list_tours names a run with its profile and pace, and says its time is a plan at that pace only (RUN-01)', async () => {
@@ -620,7 +626,7 @@ describe('ToursMcp', () => {
expect(description).toContain('never a statement that a way can be run');
});
it('TOURS-MCP-023: list_tours names a ski track with its discipline and technique, and says it is kept as recorded (SKI-02)', async () => {
it('TOURS-MCP-023: list_tours names a ski tour with its discipline and technique, and says how its line came to be (SKI-02)', async () => {
const { user, trip, ridge, gorge } = fixture();
const orm = await createTestOrm(db);
await updateRows(
@@ -640,9 +646,32 @@ describe('ToursMcp', () => {
expect(byId.get(gorge.id)).toMatchObject({ ski_discipline: null, ski_technique: null });
const entry = getEntry(ToursMcp as unknown as ClassRef, 'listTours');
const description = entry?.kind === 'tool' ? (entry.options.description ?? '') : '';
expect(description).toMatch(/ski_discipline: alpine, snowboard, cross_country or touring/);
expect(description).toContain('never routed');
expect(description).toContain('ski_discipline: alpine, snowboard, cross_country, touring for ski touring');
expect(description).toContain('snowshoe or winter_hike');
expect(description).toContain('drawn straight between its points, never routed');
expect(description).toContain('an imported one is kept as recorded');
expect(description).toContain('it has no ski time worked out');
expect(description).toContain('nothing in it says anything about snow, avalanches');
});
it('TOURS-MCP-024: get_tour_plan names every leg of a planned ski tour as drawn freely, with its straight length (SKI-02)', async () => {
const { member, trip } = fixture();
const orm = await createTestOrm(db);
const ski = await makePlace(orm, trip.id, { name: 'Over the pass' });
await makeTour(orm, ski.id, { tourTypeRef: 'ski', max_hiking_difficulty: null, ski_discipline: 'touring' });
await addTourWaypoints(orm, ski.id, [
{ lat: 47, lng: 11, station_key: 'sk-start' },
{ lat: 47.01, lng: 11, station_key: 'sk-col' },
{ lat: 47.01, lng: 11.01, station_key: 'sk-end' },
]);
await orm.close();
const plan = read(await mcp.getTourPlan({ tripId: trip.id, placeId: ski.id }, ctx(member.id)));
expect(plan.tour).toMatchObject({ tour_type: 'ski', ski_discipline: 'touring' });
expect(plan.legs).toEqual([
expect.objectContaining({ from_key: 'sk-start', to_key: 'sk-col', kind: 'free', name: null }),
expect.objectContaining({ from_key: 'sk-col', to_key: 'sk-end', kind: 'free', name: null }),
]);
expect(plan.legs[0].length_km).toBeCloseTo(1.11, 1);
expect(plan.legs[1].length_km).toBeCloseTo(0.76, 1);
});
});