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
@@ -66,6 +66,7 @@ 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',
];
// The last step the positional runner ever had; planLegacyBaseline refuses a
// schema_version past it.
@@ -105,6 +105,7 @@ describe('ToursRepository reads', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
},
{
place_id: older.id,
@@ -137,6 +138,7 @@ describe('ToursRepository reads', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
},
]);
expect((await repo.listForTrip(trip.id)).map((r) => r.place_id)).not.toContain(plain.id);
@@ -250,6 +252,7 @@ describe('ToursRepository writes', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
});
});
@@ -287,6 +290,22 @@ describe('ToursRepository writes', () => {
});
});
it('TOURREPO-016: a hike keeps its winter label through an edit that leaves it out, and loses it only when told (HIK-01)', 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');
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();
});
it('TOURREPO-008: listRowsForTrip and insertCopy carry every column, created_at included, onto the copied place', async () => {
const { trip, other } = fixture();
const source = createPlace(testDb, trip.id);
@@ -332,6 +351,7 @@ describe('ToursRepository writes', () => {
revision: 1,
hiking_preferences: null,
bike_profile: null,
winter_activity: null,
taken_over_from_trip_title: null,
taken_over_from_tour_name: null,
},
@@ -0,0 +1,16 @@
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());
});
});
@@ -2,9 +2,9 @@ import { gpxActivityOf } from '../../../src/nest/tours/tour-gpx-export.helpers';
import { describe, expect, it } from 'vitest';
// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-003
// TOUR-GPX-ACTIVITY-001 to TOUR-GPX-ACTIVITY-004
describe('gpxActivityOf (PR1-06A, BIK-01)', () => {
describe('gpxActivityOf (PR1-06A, BIK-01, HIK-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');
@@ -21,4 +21,11 @@ describe('gpxActivityOf (PR1-06A, BIK-01)', () => {
expect(gpxActivityOf({ tour_type: 'hike' })).toBe('hiking');
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');
});
});
+16
View File
@@ -7,6 +7,7 @@
import { Places } from '../../../src/db/entities/Places.entity';
import { TourStationVisits } from '../../../src/db/entities/TourStationVisits.entity';
import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
import { Tours } from '../../../src/db/entities/Tours.entity';
import type { McpContext } from '../../../src/nest-mcp';
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
import { PlacesMcp } from '../../../src/nest/places/places.mcp';
@@ -355,4 +356,19 @@ describe('ToursMcp', () => {
);
}
});
it('TOURS-MCP-015: list_tours names the winter label of a hike, and says it is only a label (HIK-01)', async () => {
const { user, trip, ridge, gorge } = fixture();
const orm = await createTestOrm(db);
await updateRows(orm, Tours, { place: ridge.id }, { winter_activity: '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 });
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');
});
});