mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
fix(tours): open a saved ski tour as saved and keep what its router planned
A saved ski tour now opens with the line, heights, time, build and planned legs it was saved with, read from the place this device keeps, and nothing is asked of the piste router until a point, an option or the discipline changes. Opened offline, while TREK Places is down or with piste routing switched off, it no longer turns every leg straight, and a rename saves the planned tour as it was. A kept line whose length is not the tour's is another save's, and the tour is planned afresh. - the save names the legs the router planned (ski_planned_legs, checked against the tour's own legs); get_tour_plan reports each leg routed or drawn from them instead of from the build alone, a tour that names none counting as planned only with the router's time - copies keep ski_options in every path: in the trip, taken over, kept in a collection and made from one (collection_tours gets the column in the same unshipped migration) - the route summary shows the data build, the km of runs, the height skied down on them and the lift rides, and the credit the server sends in place of a fixed one; a tour opened as saved says so - answers refused while piste routing is off, or planned on an older kept copy, are not remembered, so they are asked again later - terrain heights between known ones are filled in one walk along the line - i18n: five summary keys in all 27 locales
This commit is contained in:
@@ -72,6 +72,7 @@ const answer = (legs: SkiLegAnswer[], extra: Partial<SkiPlanAnswer> = {}): SkiPl
|
||||
stale: false,
|
||||
areaNames: ['Gletscherwelt'],
|
||||
colourScheme: 'europe',
|
||||
attribution: '© OpenStreetMap contributors. Terrain: Mapzen Terrarium',
|
||||
...extra,
|
||||
});
|
||||
|
||||
@@ -115,6 +116,7 @@ describe('skiRouting (SKI-02)', () => {
|
||||
expect(route?.durationSeconds).toBeNull();
|
||||
expect(route?.parts.every((part) => part.free)).toBe(true);
|
||||
expect(route?.legs).toEqual([null, null]);
|
||||
expect(route).toMatchObject({ plannedLegs: [], saved: false, attribution: null });
|
||||
// Every 25 m a point, so the heights follow the ground between the two ends of each leg.
|
||||
expect(route?.coordinates.length ?? 0).toBeGreaterThan(40);
|
||||
const straight = lineLengthM([
|
||||
@@ -167,6 +169,8 @@ describe('skiRouting (SKI-02)', () => {
|
||||
expect(route?.elevations?.[0]).toEqual([47, 11, 1500]);
|
||||
expect(route?.build).toBe('2026-11-02');
|
||||
expect(route?.areaNames).toEqual(['Gletscherwelt']);
|
||||
expect(route).toMatchObject({ plannedLegs: [0, 1], saved: false });
|
||||
expect(route?.attribution).toBe('© OpenStreetMap contributors. Terrain: Mapzen Terrarium');
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-ROUTE-006: a leg it cannot plan stays straight and unrouted, with its reason, hint and name', async () => {
|
||||
@@ -174,6 +178,7 @@ describe('skiRouting (SKI-02)', () => {
|
||||
const route = await routeSkiTour([start, col, hut], alpine, 7);
|
||||
expect(route?.durationSeconds).toBeNull();
|
||||
expect(route?.elevations).toBeNull();
|
||||
expect(route?.plannedLegs).toEqual([0]);
|
||||
expect(route?.problems).toEqual([
|
||||
{
|
||||
index: 1,
|
||||
@@ -235,6 +240,8 @@ describe('skiRouting (SKI-02)', () => {
|
||||
{ index: 0, code: 'places_unavailable', hint: { free_draw: true }, name: null, summer: false },
|
||||
]);
|
||||
expect(route?.parts.some((part) => part.free)).toBe(true);
|
||||
// The drawn leg is no leg the router planned, and nothing was answered to credit.
|
||||
expect(route).toMatchObject({ plannedLegs: [], attribution: null });
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-ROUTE-010: a leg takes its time on the move and waiting together', () => {
|
||||
|
||||
@@ -46,6 +46,12 @@ export interface SkiTourRoute extends TourRouteResult {
|
||||
stale: boolean;
|
||||
areaNames: string[];
|
||||
colourScheme: SkiColourScheme;
|
||||
/** The credit of the piste and terrain data, as the server words it; null where nothing was asked. */
|
||||
attribution: string | null;
|
||||
/** The legs the piste router planned, by the index of the point each starts at. */
|
||||
plannedLegs: number[];
|
||||
/** The line as the tour was saved, opened without asking the router again; false for one planned now. */
|
||||
saved: boolean;
|
||||
}
|
||||
|
||||
/** How long a leg waits for its ski area to download, asked again every two seconds. */
|
||||
@@ -216,6 +222,7 @@ async function plannedAnswers(
|
||||
stale: false,
|
||||
areaNames: [],
|
||||
colourScheme: 'europe',
|
||||
attribution: null,
|
||||
});
|
||||
if (legs.length === 0) return { asked, plan: none([]) };
|
||||
try {
|
||||
@@ -273,6 +280,9 @@ export async function routeSkiTour(
|
||||
stale: plan.stale,
|
||||
areaNames: plan.areaNames,
|
||||
colourScheme: plan.colourScheme,
|
||||
attribution: plan.attribution,
|
||||
plannedLegs: legs.flatMap((leg, index) => (leg?.status === 'ok' ? [index] : [])),
|
||||
saved: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -295,5 +305,8 @@ function drawnSkiTour(points: readonly SkiLegPoint[]): SkiTourRoute | null {
|
||||
stale: false,
|
||||
areaNames: [],
|
||||
colourScheme: 'europe',
|
||||
attribution: null,
|
||||
plannedLegs: [],
|
||||
saved: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface TourPlannerFactsInput {
|
||||
ski?: SkiChoice;
|
||||
/** The piste data build a ski tour's legs were planned on; null where the piste router planned none. */
|
||||
skiRouteBuild?: string | null;
|
||||
/** The ski tour's legs the piste router planned, by the index of the point each starts at; null if unknown. */
|
||||
skiPlannedLegs?: number[] | null;
|
||||
/** RUN-01: the profile a run is routed for, and the pace it is planned at (null while none is entered). */
|
||||
runProfile: RunProfile;
|
||||
runPaceSecondsPerKm: number | null;
|
||||
@@ -171,6 +173,7 @@ function typeFieldsOf(input: TourPlannerFactsInput) {
|
||||
// What the piste router was asked for and the data it planned on, so the tour reopens as it was planned.
|
||||
ski_options: ski ? skiOptionsForDiscipline(ski.ski_discipline, input.ski?.options ?? {}) : null,
|
||||
ski_route_build: ski ? (input.skiRouteBuild ?? null) : null,
|
||||
ski_planned_legs: ski ? (input.skiPlannedLegs ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -81,18 +81,23 @@ describe('tourPlannerFacts', () => {
|
||||
expect(snowshoe.walkingDurationMinutes).toBeNull();
|
||||
expect(snowshoe.plannedTotalMinutes).toBeNull();
|
||||
expect(snowshoe.savedDurationSeconds).toBeNull();
|
||||
expect(snowshoe.typeFields).toMatchObject({ ski_options: DEFAULT_SKI_ROUTE_OPTIONS, ski_route_build: null });
|
||||
expect(snowshoe.typeFields).toMatchObject({
|
||||
ski_options: DEFAULT_SKI_ROUTE_OPTIONS,
|
||||
ski_route_build: null,
|
||||
ski_planned_legs: null,
|
||||
});
|
||||
const routed = tourPlannerFacts(
|
||||
hike({
|
||||
tourType: 'ski',
|
||||
ski: { discipline: 'alpine', technique: null },
|
||||
durationSeconds: 3_600,
|
||||
skiRouteBuild: '2026-11-02',
|
||||
skiPlannedLegs: [0],
|
||||
})
|
||||
);
|
||||
expect(routed.walkingDurationMinutes).toBe(60);
|
||||
expect(routed.savedDurationSeconds).toBe(3_600);
|
||||
expect(routed.typeFields).toMatchObject({ ski_route_build: '2026-11-02' });
|
||||
expect(routed.typeFields).toMatchObject({ ski_route_build: '2026-11-02', ski_planned_legs: [0] });
|
||||
|
||||
expect(tourPlannerFacts(hike({ tourType: 'ski' })).skiMissing).toBe('discipline');
|
||||
const crossCountry = hike({ tourType: 'ski', ski: { discipline: 'cross_country', technique: null } });
|
||||
@@ -102,7 +107,9 @@ describe('tourPlannerFacts', () => {
|
||||
for (const tourType of ['hike', 'bike', 'walk', 'kayak'] as const) {
|
||||
const other = tourPlannerFacts(hike({ tourType, ski: { discipline: 'alpine', technique: null } }));
|
||||
expect(other.typeFields).toMatchObject({ ski_discipline: null, ski_technique: null });
|
||||
if (tourType !== 'kayak') expect(other.typeFields).toMatchObject({ ski_options: null, ski_route_build: null });
|
||||
if (tourType !== 'kayak') {
|
||||
expect(other.typeFields).toMatchObject({ ski_options: null, ski_route_build: null, ski_planned_legs: null });
|
||||
}
|
||||
expect(other.skiMissing).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { DEFAULT_SKI_ROUTE_OPTIONS } from '@trek/shared';
|
||||
import { computeTourMetrics, DEFAULT_SKI_ROUTE_OPTIONS } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { routeWalkingTour, enrichTourElevations, createTour, detailTour, updateTour, planSki } = vi.hoisted(() => ({
|
||||
@@ -17,13 +17,20 @@ vi.mock('./tourRouting', async (importOriginal) => ({
|
||||
routeWalkingTour,
|
||||
enrichTourElevations,
|
||||
}));
|
||||
vi.mock('../../../repo/tourRepo', () => ({ tourRepo: { create: createTour, detail: detailTour, update: updateTour } }));
|
||||
const { savedLine, offline } = vi.hoisted(() => ({ savedLine: vi.fn(), offline: vi.fn(() => false) }));
|
||||
vi.mock('../../../sync/networkMode', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../sync/networkMode')>()),
|
||||
isEffectivelyOffline: offline,
|
||||
}));
|
||||
vi.mock('../../../repo/tourRepo', () => ({
|
||||
tourRepo: { create: createTour, detail: detailTour, update: updateTour, savedLine },
|
||||
}));
|
||||
vi.mock('../../../repo/skiRouteRepo', () => ({ skiRouteRepo: { plan: planSki } }));
|
||||
vi.mock('../useTourPermissions', () => ({ useTourPermissions: () => ({ canEdit: true, canAssign: true }) }));
|
||||
|
||||
import { useTourPlanner } from './useTourPlanner';
|
||||
|
||||
// FE-TOUR-SKI-PLAN-001 to FE-TOUR-SKI-PLAN-007
|
||||
// FE-TOUR-SKI-PLAN-001 to FE-TOUR-SKI-PLAN-009
|
||||
|
||||
const savedSki = {
|
||||
place_id: 95,
|
||||
@@ -125,6 +132,8 @@ describe('useTourPlanner, a ski and winter tour (SKI-02)', () => {
|
||||
createTour.mockReset();
|
||||
detailTour.mockReset();
|
||||
updateTour.mockReset();
|
||||
savedLine.mockReset().mockResolvedValue(null);
|
||||
offline.mockReset().mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
@@ -216,6 +225,7 @@ describe('useTourPlanner, a ski and winter tour (SKI-02)', () => {
|
||||
duration_seconds: 600,
|
||||
ski_options: DEFAULT_SKI_ROUTE_OPTIONS,
|
||||
ski_route_build: '2026-11-02',
|
||||
ski_planned_legs: [0],
|
||||
})
|
||||
);
|
||||
expect(result.current.status).toBe('saved');
|
||||
@@ -264,4 +274,77 @@ describe('useTourPlanner, a ski and winter tour (SKI-02)', () => {
|
||||
act(() => again.result.current.returnToNeutral());
|
||||
expect(again.result.current.ski.choice).toEqual({ discipline: null, technique: null });
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-PLAN-008: a saved ski tour opens as saved, offline too: nothing asked, its line, time and build kept on a save', async () => {
|
||||
const line: [number, number, number][] = [
|
||||
[47, 11, 1600],
|
||||
[47.005, 11.01, 1500],
|
||||
[47.01, 11.02, 1400],
|
||||
];
|
||||
const planned = {
|
||||
...savedSki,
|
||||
ski_discipline: 'alpine' as const,
|
||||
ski_technique: null,
|
||||
distance: computeTourMetrics(line).distanceKm,
|
||||
duration: 25,
|
||||
ski_options: DEFAULT_SKI_ROUTE_OPTIONS,
|
||||
ski_route_build: '2026-11-02',
|
||||
ski_planned_legs: [0],
|
||||
};
|
||||
detailTour.mockResolvedValue({ tour: planned, waypoints: savedPoints });
|
||||
savedLine.mockResolvedValue(JSON.stringify(line));
|
||||
offline.mockReturnValue(true);
|
||||
updateTour.mockResolvedValue({ tour: { ...planned, name: 'Down the col', revision: 3 }, waypoints: savedPoints });
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 88 }));
|
||||
await act(async () => {
|
||||
await result.current.openTour({ place_id: 95 });
|
||||
});
|
||||
await settle();
|
||||
expect(savedLine).toHaveBeenCalledWith(88, 95);
|
||||
expect(planSki).not.toHaveBeenCalled();
|
||||
expect(enrichTourElevations).not.toHaveBeenCalled();
|
||||
expect(result.current.status).toBe('ready');
|
||||
expect(result.current.route).toEqual(line.map(([lat, lng]) => [lat, lng]));
|
||||
expect(result.current.durationSeconds).toBe(1500);
|
||||
expect(result.current.skiRoute).toMatchObject({ saved: true, build: '2026-11-02', plannedLegs: [0] });
|
||||
|
||||
// A new name changes nothing of the route: it is saved as it was planned.
|
||||
act(() => result.current.setName('Down the col'));
|
||||
expect(result.current.canSave).toBe(true);
|
||||
await act(async () => {
|
||||
await result.current.save();
|
||||
});
|
||||
expect(planSki).not.toHaveBeenCalled();
|
||||
expect(updateTour).toHaveBeenCalledWith(
|
||||
88,
|
||||
95,
|
||||
expect.objectContaining({
|
||||
route_geometry: line,
|
||||
duration_seconds: 1500,
|
||||
ski_route_build: '2026-11-02',
|
||||
ski_planned_legs: [0],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-PLAN-009: a saved ski tour whose kept line is not the one saved is planned afresh', async () => {
|
||||
detailTour.mockResolvedValue({
|
||||
tour: { ...savedSki, ski_discipline: 'alpine', ski_technique: null, distance: 9.5, duration: 25 },
|
||||
waypoints: savedPoints,
|
||||
});
|
||||
savedLine.mockResolvedValue(
|
||||
JSON.stringify([
|
||||
[47, 11, 1600],
|
||||
[47.01, 11.02, 1400],
|
||||
])
|
||||
);
|
||||
const { result } = renderHook(() => useTourPlanner({ tripId: 89 }));
|
||||
await act(async () => {
|
||||
await result.current.openTour({ place_id: 95 });
|
||||
});
|
||||
await settle();
|
||||
expect(planSki).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.skiRoute?.saved).toBe(false);
|
||||
expect(result.current.durationSeconds).toBe(600);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import { NO_PADDLE_FIELDS, paddleFieldsOf, usePaddleFields, type PaddleFieldValu
|
||||
import { useTourPermissions, type TourPermissionProps } from '../useTourPermissions'
|
||||
import { useRunPace } from '../run/useRunPace'
|
||||
import { NO_SKI_CHOICE, type SkiChoice } from '../ski/skiChoice'
|
||||
import { savedSkiSeed } from '../ski/savedSkiRoute'
|
||||
import { useSkiFields } from '../ski/useSkiFields'
|
||||
import { lostStageEnds } from '../stages/tourStages'
|
||||
import { useTourStages } from '../stages/useTourStages'
|
||||
@@ -254,7 +255,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
error, setStatus, setError, onCleared: forgetFocus, patchWaypoints,
|
||||
})
|
||||
const { route, routeParts, paddleRoute, skiRoute, enrichedGeometry, routeCurrent, distanceMeters, durationSeconds,
|
||||
unreachable, interruptRouting, clearRoute } = routing
|
||||
unreachable, interruptRouting, clearRoute, openSavedSkiRoute } = routing
|
||||
|
||||
const commitWaypoints = useCallback((nextValue: TourPlannerWaypoint[] | ((current: TourPlannerWaypoint[]) => TourPlannerWaypoint[])) => {
|
||||
if (!editPermissionRef.current) return
|
||||
@@ -535,7 +536,7 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
const stageCount = Math.max(1, stages.stages.length)
|
||||
const facts = useMemo(() => tourPlannerFacts({
|
||||
tourType, maxHikingDifficulty, hikingPreferences, bikeProfile, ski: ski.choice, runProfile, runPaceSecondsPerKm, website,
|
||||
skiRouteBuild: skiRoute?.build ?? null, plannedDurationMinutes, breakAdditionalMinutes,
|
||||
skiRouteBuild: skiRoute?.build ?? null, skiPlannedLegs: skiRoute?.plannedLegs ?? null, plannedDurationMinutes, breakAdditionalMinutes,
|
||||
paddle: { boatType, waterContext, speedKmh, portageSpeedKmh, lakeStyle },
|
||||
paddleSpeedInvalid, paddleEstimate, routeAnalysis, durationSeconds, stays, stageCount,
|
||||
}), [bikeProfile, boatType, breakAdditionalMinutes, durationSeconds, hikingPreferences, lakeStyle, maxHikingDifficulty, paddleEstimate, paddleSpeedInvalid,
|
||||
@@ -713,6 +714,9 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setError('open')
|
||||
return false
|
||||
}
|
||||
// SKI-02: a saved ski tour opens with the line it was saved with, read from this device.
|
||||
const skiLine = result.tour.tour_type === 'ski' ? await tourRepo.savedLine(tripId, result.tour.place_id) : null
|
||||
if (!requestIsCurrent() || saveInFlightRef.current) return false
|
||||
|
||||
interruptRouting()
|
||||
const opened = openedTourFields(result.tour)
|
||||
@@ -739,6 +743,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setStageLoss(0)
|
||||
setStatus('dirty')
|
||||
clearRoute()
|
||||
const savedSki = savedSkiSeed(result.tour, skiLine, nextWaypoints, opened.ski)
|
||||
if (savedSki) openSavedSkiRoute(savedSki)
|
||||
// CW-11: a walk without its way yet is planned on opening and can be saved with it at once.
|
||||
const pending = result.tour.route_pending === true
|
||||
setRoutePending(pending)
|
||||
@@ -762,7 +768,8 @@ export function useTourPlanner({ tripId, active = true, onSaved, canEdit: editPe
|
||||
setOpeningTourId(null)
|
||||
}
|
||||
}
|
||||
}, [adoptRevision, clearHistory, clearRoute, interruptRouting, loadChoices, loadPaddle, loadRunPace, loadSki, forgetDraft, tripId])
|
||||
}, [adoptRevision, clearHistory, clearRoute, interruptRouting, loadChoices, loadPaddle, loadRunPace, loadSki, forgetDraft, openSavedSkiRoute,
|
||||
tripId])
|
||||
|
||||
const retry = useCallback(() => {
|
||||
if (waypointsRef.current.length < 2) return
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { interpolatedHeights } from './useTourRoute';
|
||||
|
||||
// FE-TOUR-ROUTE-HEIGHTS-001 to FE-TOUR-ROUTE-HEIGHTS-002
|
||||
// FE-TOUR-ROUTE-HEIGHTS-001 to FE-TOUR-ROUTE-HEIGHTS-003
|
||||
|
||||
describe('interpolatedHeights (SKI-02)', () => {
|
||||
it('FE-TOUR-ROUTE-HEIGHTS-001: fills the points the elevation source missed straight between the known ones', () => {
|
||||
@@ -16,6 +16,24 @@ describe('interpolatedHeights (SKI-02)', () => {
|
||||
expect(interpolatedHeights(line)?.map((point) => point[2])).toEqual([1000, 1000, 1100, 1200, 1300, 1300]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-ROUTE-HEIGHTS-003: walks a long line once, gap after gap, each filled from its own two ends', () => {
|
||||
const at = (index: number): [number, number] | [number, number, number] =>
|
||||
index % 1000 === 0 ? [47 + index / 1e6, 11, index] : [47 + index / 1e6, 11];
|
||||
const line = Array.from({ length: 20_001 }, (_, index) => at(index));
|
||||
const heights = interpolatedHeights(line)?.map((point) => point[2]) ?? [];
|
||||
expect(heights).toHaveLength(20_001);
|
||||
expect(heights.every((height, index) => Math.abs(height - index) < 1e-6)).toBe(true);
|
||||
const ends: Array<[number, number] | [number, number, number]> = [
|
||||
[47, 11],
|
||||
[47.001, 11, 500],
|
||||
[47.002, 11],
|
||||
[47.003, 11, 700],
|
||||
[47.004, 11, 600],
|
||||
[47.005, 11],
|
||||
];
|
||||
expect(interpolatedHeights(ends)?.map((point) => point[2])).toEqual([500, 500, 600, 700, 600, 600]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-ROUTE-HEIGHTS-002: a line without one known height has none to estimate from', () => {
|
||||
expect(
|
||||
interpolatedHeights([
|
||||
|
||||
@@ -94,13 +94,24 @@ const NO_ROUTE: PlannedRoute = {
|
||||
forRun: null,
|
||||
};
|
||||
|
||||
/** A route planned whole: a paddle route without a failed leg, a ski tour too, a route on land with its heights. */
|
||||
/**
|
||||
* A route planned whole: a paddle route without a failed leg, a ski tour too,
|
||||
* a ski tour opened as it was saved, a route on land with its heights.
|
||||
*/
|
||||
function routeComplete(planned: PlannedRoute): boolean {
|
||||
if (planned.paddleRoute) return planned.paddleRoute.failures.length === 0;
|
||||
if (planned.skiRoute?.saved) return true;
|
||||
if (planned.skiRoute && planned.skiRoute.problems.length > 0) return false;
|
||||
return hasCompleteElevation(planned.enrichedGeometry);
|
||||
}
|
||||
|
||||
/** SKI-02: a saved ski tour's route, for the points and the ski choice it was saved with. */
|
||||
export interface SavedSkiSeed {
|
||||
route: SkiTourRoute;
|
||||
waypointKey: string;
|
||||
skiKey: string;
|
||||
}
|
||||
|
||||
/** One planning run: what it plans from, whether it went stale, and where it writes. */
|
||||
interface RouteRun {
|
||||
inputs: Omit<TourRouteInputs, 'retryToken' | 'error' | 'onCleared'>;
|
||||
@@ -189,14 +200,21 @@ async function routeOnLand({ inputs, plannedKey, runKey, signal, stale, setPlann
|
||||
setError(null);
|
||||
}
|
||||
|
||||
/** Heights straight between the known ones along the line, where the elevation source has none: an estimate. */
|
||||
/**
|
||||
* Heights straight between the known ones along the line, where the elevation
|
||||
* source has none: an estimate. Before the first known height and after the
|
||||
* last, the nearest one holds. One walk along the line, however long it is.
|
||||
*/
|
||||
export function interpolatedHeights(points: readonly TourElevationPoint[]): [number, number, number][] | null {
|
||||
const known = points.flatMap((point, index) => (point.length === 3 && Number.isFinite(point[2]) ? [index] : []));
|
||||
if (known.length === 0) return null;
|
||||
const height = (k: number) => (points[k] as [number, number, number])[2];
|
||||
let next = 0;
|
||||
return points.map((point, index): [number, number, number] => {
|
||||
const after = known.find((k) => k >= index) ?? (known[known.length - 1] as number);
|
||||
const before = [...known].reverse().find((k) => k <= index) ?? after;
|
||||
// The first known height at or after this point; past the last one, the last.
|
||||
while (next < known.length - 1 && (known[next] as number) < index) next += 1;
|
||||
const after = known[next] as number;
|
||||
const before = after <= index || next === 0 ? after : (known[next - 1] as number);
|
||||
const share = after === before ? 0 : (index - before) / (after - before);
|
||||
return [point[0], point[1], height(before) + (height(after) - height(before)) * share];
|
||||
});
|
||||
@@ -313,6 +331,8 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
const [unreachable, setUnreachable] = useState<TourRouteUnreachable | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const generationRef = useRef(0);
|
||||
// SKI-02: a saved ski tour's route waiting for the run that plans its points.
|
||||
const savedSkiRef = useRef<SavedSkiSeed | null>(null);
|
||||
|
||||
/** Any answer still on its way is stale from here on. */
|
||||
const interruptRouting = useCallback(() => {
|
||||
@@ -324,6 +344,11 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
/** No route at all, as for an empty draft. */
|
||||
const clearRoute = useCallback(() => setPlanned(NO_ROUTE), []);
|
||||
|
||||
/** SKI-02: the next run for these points and this ski choice takes the saved route instead of asking the router. */
|
||||
const openSavedSkiRoute = useCallback((seed: SavedSkiSeed) => {
|
||||
savedSkiRef.current = seed;
|
||||
}, []);
|
||||
|
||||
const waypointKey = useMemo(() => tourWaypointKey(waypoints), [waypoints]);
|
||||
const plannedKey = plannedKeyOf(waypointKey, inputs);
|
||||
// CW-02, CW-03: a station's own fields and the keys a save answered are no part of what a route is planned from,
|
||||
@@ -334,6 +359,26 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
const currentPoints = useEffectEvent(() => waypoints);
|
||||
// The route shown was planned whole for exactly this run, so a change that leaves it as it is plans nothing again.
|
||||
const routeStands = useEffectEvent(() => planned.forRun === runKey && routeComplete(planned));
|
||||
// SKI-02: a saved ski tour opened as it was saved, its line, time and build shown until something of it changes.
|
||||
const takeSavedSkiRoute = useEffectEvent(() => {
|
||||
const seed = savedSkiRef.current;
|
||||
savedSkiRef.current = null;
|
||||
if (!seed || tourType !== 'ski' || seed.waypointKey !== waypointKey || seed.skiKey !== skiKey) return false;
|
||||
const { route } = seed;
|
||||
setUnreachable(null);
|
||||
setPlanned({
|
||||
route: route.coordinates,
|
||||
routeParts: route.parts,
|
||||
paddleRoute: null,
|
||||
skiRoute: route,
|
||||
enrichedGeometry: route.elevations,
|
||||
distanceMeters: route.distanceMeters,
|
||||
durationSeconds: route.durationSeconds,
|
||||
forKey: plannedKey,
|
||||
forRun: runKey,
|
||||
});
|
||||
return true;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const points = currentPoints();
|
||||
@@ -345,6 +390,11 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (takeSavedSkiRoute()) {
|
||||
setStatus('ready');
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
if (routeStands()) {
|
||||
setStatus((current) => (current === 'dirty' ? 'ready' : current));
|
||||
return;
|
||||
@@ -414,6 +464,7 @@ export function useTourRoute(inputs: TourRouteInputs) {
|
||||
unreachable: error === 'unreachable' ? unreachable : null,
|
||||
interruptRouting,
|
||||
clearRoute,
|
||||
openSavedSkiRoute,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { SkiFlag, SkiRouteOkLeg, SkiWhy } from '@trek/shared';
|
||||
import type { SkiFlag, SkiGeometryPoint, SkiRouteOkLeg, SkiSegmentKind, SkiWhy } from '@trek/shared';
|
||||
import { Info, MountainSnow } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useSettingsStore } from '../../../store/settingsStore';
|
||||
import { formatDistance, formatElevation } from '../../../utils/units';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
import type { SkiTourRoute } from '../planner/skiRouting';
|
||||
import { formatTourDuration } from '../planner/tourDurationInput';
|
||||
@@ -16,6 +19,9 @@ const GUESSED: ReadonlySet<SkiFlag> = new Set([
|
||||
'difficulty_unknown',
|
||||
]);
|
||||
|
||||
/** Stretches ridden on a lift, a funicular or rack railway, or a gondola down. */
|
||||
const RIDES: ReadonlySet<SkiSegmentKind> = new Set(['lift', 'rail', 'lift_down']);
|
||||
|
||||
type Seconds = SkiRouteOkLeg['seconds'];
|
||||
type Avoided = Extract<SkiWhy, { kind: 'avoided_difficulty' }>;
|
||||
|
||||
@@ -32,6 +38,33 @@ export function skiSecondsOf(route: SkiTourRoute): Seconds {
|
||||
return total;
|
||||
}
|
||||
|
||||
/** The height lost along a stretch of a leg's line, counted where its points have a height. */
|
||||
function descentAlong(geometry: readonly SkiGeometryPoint[], from: number, to: number): number {
|
||||
let down = 0;
|
||||
let last: number | null = null;
|
||||
for (const [, , height] of geometry.slice(from, to + 1)) {
|
||||
if (height === null) continue;
|
||||
if (last !== null && height < last) down += last - height;
|
||||
last = height;
|
||||
}
|
||||
return down;
|
||||
}
|
||||
|
||||
/** The metres of runs, the height skied down on them, and the lift rides of every planned leg. */
|
||||
export function skiTallyOf(route: SkiTourRoute): { runsM: number; downM: number; rides: number } {
|
||||
const tally = { runsM: 0, downM: 0, rides: 0 };
|
||||
for (const leg of route.legs) {
|
||||
if (leg?.status !== 'ok') continue;
|
||||
for (const segment of leg.segments) {
|
||||
if (RIDES.has(segment.kind)) tally.rides += 1;
|
||||
if (segment.kind !== 'run') continue;
|
||||
tally.runsM += segment.length_m;
|
||||
tally.downM += descentAlong(leg.geometry, segment.from, segment.to);
|
||||
}
|
||||
}
|
||||
return tally;
|
||||
}
|
||||
|
||||
/** The harder runs the route kept clear of, and whether any stretch rests on a guess. */
|
||||
function skiWhyOf(route: SkiTourRoute): { avoided: Avoided[]; guessed: boolean } {
|
||||
const why = route.legs.flatMap((leg) => (leg?.status === 'ok' ? leg.why : []));
|
||||
@@ -53,17 +86,51 @@ function Note({ children }: { children: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The card the summary sits in, with the piste data build and the credit of the data closing it. */
|
||||
function SummaryCard({ route, children }: { route: SkiTourRoute; children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const credit = route.attribution ?? t('tours.ski.summary.source');
|
||||
return (
|
||||
<RailCard icon={<MountainSnow size={14} strokeWidth={2} />} title={t('tours.ski.summary.title')} foldId="ski-route">
|
||||
<div data-testid="ski-route-summary" className="flex min-w-0 flex-col gap-3">
|
||||
{children}
|
||||
{route.build && (
|
||||
<span className="text-content-faint" style={fs(10.5, 'caption')}>
|
||||
{t('tours.ski.summary.build', { build: route.build })}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-content-faint" style={fs(10.5, 'caption')}>
|
||||
{[route.areaNames.join(', '), credit].filter(Boolean).join('. ')}
|
||||
</span>
|
||||
</div>
|
||||
</RailCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SKI-02: what the piste router made of a planned ski tour: the time on runs,
|
||||
* on lifts, waiting and on foot, the harder runs it kept clear of, and what
|
||||
* on lifts, waiting and on foot, the length of the runs, the height skied
|
||||
* down on them and the lift rides, the harder runs it kept clear of, and what
|
||||
* the reader should weigh: legs it could not plan, stretches that rest on a
|
||||
* guess, an older copy of the piste data. The ski areas and the source of the
|
||||
* data close it.
|
||||
* guess, an older copy of the piste data. The build of the data, the ski
|
||||
* areas and the credit of the data close it. A tour opened as it was saved
|
||||
* says so, and keeps its line until something of it changes.
|
||||
*/
|
||||
export function SkiRouteSummary({ route }: { route: SkiTourRoute | null }) {
|
||||
const { t } = useTranslation();
|
||||
if (!route || route.legs.every((leg) => leg === null)) return null;
|
||||
const unit = useSettingsStore((state) => state.settings.distance_unit) ?? 'metric';
|
||||
if (!route) return null;
|
||||
if (route.saved) {
|
||||
if (route.plannedLegs.length === 0 && !route.build) return null;
|
||||
return (
|
||||
<SummaryCard route={route}>
|
||||
<Note>{t('tours.ski.summary.saved')}</Note>
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
if (route.legs.every((leg) => leg === null)) return null;
|
||||
const seconds = skiSecondsOf(route);
|
||||
const tally = skiTallyOf(route);
|
||||
const { avoided, guessed } = skiWhyOf(route);
|
||||
const level = (entry: Avoided) => t(`tours.ski.difficulty.${entry.level}`);
|
||||
const facts = [
|
||||
@@ -71,36 +138,34 @@ export function SkiRouteSummary({ route }: { route: SkiTourRoute | null }) {
|
||||
{ label: t('tours.ski.summary.lift'), value: minutes(seconds.lift) },
|
||||
{ label: t('tours.ski.summary.wait'), value: minutes(seconds.wait) },
|
||||
{ label: t('tours.ski.summary.walk'), value: minutes(seconds.walk) },
|
||||
{ label: t('tours.ski.summary.runs'), value: formatDistance(tally.runsM / 1000, unit) },
|
||||
{ label: t('tours.ski.summary.descent'), value: formatElevation(tally.downM, unit) },
|
||||
{ label: t('tours.ski.summary.lifts'), value: String(tally.rides) },
|
||||
];
|
||||
return (
|
||||
<RailCard icon={<MountainSnow size={14} strokeWidth={2} />} title={t('tours.ski.summary.title')} foldId="ski-route">
|
||||
<div data-testid="ski-route-summary" className="flex min-w-0 flex-col gap-3">
|
||||
<RailFacts facts={facts} />
|
||||
{avoided.length > 0 && (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.ski.summary.avoided')}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{avoided.map((entry, at) => (
|
||||
<span
|
||||
key={`${entry.level}-${entry.name ?? at}`}
|
||||
className="rounded-full bg-surface-secondary px-2 py-0.5 text-content-secondary"
|
||||
style={fs(10.5, 'caption')}
|
||||
>
|
||||
{entry.name ? `${level(entry)}: ${entry.name}` : level(entry)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{route.problems.length > 0 && <Note>{t('tours.ski.summary.unplanned')}</Note>}
|
||||
{guessed && <Note>{t('tours.ski.summary.guessed')}</Note>}
|
||||
{route.stale && <Note>{t('tours.ski.summary.stale')}</Note>}
|
||||
<span className="text-content-faint" style={fs(10.5, 'caption')}>
|
||||
{[route.areaNames.join(', '), t('tours.ski.summary.source')].filter(Boolean).join('. ')}
|
||||
</span>
|
||||
</div>
|
||||
</RailCard>
|
||||
<SummaryCard route={route}>
|
||||
<RailFacts facts={facts} />
|
||||
{avoided.length > 0 && (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<span className="text-content-faint" style={fs(10.5, 'body')}>
|
||||
{t('tours.ski.summary.avoided')}
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{avoided.map((entry, at) => (
|
||||
<span
|
||||
key={`${entry.level}-${entry.name ?? at}`}
|
||||
className="rounded-full bg-surface-secondary px-2 py-0.5 text-content-secondary"
|
||||
style={fs(10.5, 'caption')}
|
||||
>
|
||||
{entry.name ? `${level(entry)}: ${entry.name}` : level(entry)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{route.problems.length > 0 && <Note>{t('tours.ski.summary.unplanned')}</Note>}
|
||||
{guessed && <Note>{t('tours.ski.summary.guessed')}</Note>}
|
||||
{route.stale && <Note>{t('tours.ski.summary.stale')}</Note>}
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { computeTourMetrics } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { skiRouteKey } from '../planner/skiRouting';
|
||||
import { tourWaypointKey, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import { savedSkiRoute, savedSkiSeed } from './savedSkiRoute';
|
||||
|
||||
// FE-TOUR-SKI-SAVED-001 to FE-TOUR-SKI-SAVED-004
|
||||
|
||||
const line: [number, number, number][] = [
|
||||
[47, 11, 1600],
|
||||
[47.005, 11.01, 1500],
|
||||
[47.01, 11.02, 1400],
|
||||
];
|
||||
|
||||
const tour = {
|
||||
tour_type: 'ski' as const,
|
||||
ski_discipline: 'alpine' as const,
|
||||
distance: computeTourMetrics(line).distanceKm,
|
||||
duration: 25,
|
||||
ski_route_build: '2026-11-02',
|
||||
ski_planned_legs: [1],
|
||||
};
|
||||
|
||||
describe('savedSkiRoute (SKI-02)', () => {
|
||||
it('FE-TOUR-SKI-SAVED-001: opens a saved ski tour with its line, heights, time, build and planned legs', () => {
|
||||
const route = savedSkiRoute(tour, JSON.stringify(line), 2);
|
||||
expect(route).toMatchObject({
|
||||
coordinates: line.map(([lat, lng]) => [lat, lng]),
|
||||
elevations: line,
|
||||
durationSeconds: 1500,
|
||||
build: '2026-11-02',
|
||||
plannedLegs: [1],
|
||||
saved: true,
|
||||
problems: [],
|
||||
attribution: null,
|
||||
});
|
||||
expect(route?.distanceMeters).toBeCloseTo(tour.distance * 1000, 6);
|
||||
expect(route?.parts).toEqual([{ coordinates: route?.coordinates, free: false }]);
|
||||
// The line may come as the place keeps it, or already read.
|
||||
expect(savedSkiRoute(tour, line, 2)?.saved).toBe(true);
|
||||
expect(savedSkiRoute({ ...tour, duration: null }, line, 2)?.durationSeconds).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-SAVED-002: a tour that does not name its planned legs counts them all only with a kept time and build', () => {
|
||||
const unnamed = { ...tour, ski_planned_legs: null };
|
||||
expect(savedSkiRoute(unnamed, line, 2)?.plannedLegs).toEqual([0, 1]);
|
||||
expect(savedSkiRoute({ ...unnamed, duration: null }, line, 2)?.plannedLegs).toEqual([]);
|
||||
expect(savedSkiRoute({ ...unnamed, ski_route_build: null }, line, 2)?.plannedLegs).toEqual([]);
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-SAVED-003: nothing to open with: another type, no discipline or length, no line, a broken one, another save', () => {
|
||||
const json = JSON.stringify(line);
|
||||
expect(savedSkiRoute({ ...tour, tour_type: 'hike' }, json, 2)).toBeNull();
|
||||
expect(savedSkiRoute({ ...tour, ski_discipline: null }, json, 2)).toBeNull();
|
||||
expect(savedSkiRoute({ ...tour, distance: null }, json, 2)).toBeNull();
|
||||
expect(savedSkiRoute(tour, null, 2)).toBeNull();
|
||||
expect(savedSkiRoute(tour, '[[47, 11', 2)).toBeNull();
|
||||
expect(savedSkiRoute(tour, JSON.stringify({ line }), 2)).toBeNull();
|
||||
expect(savedSkiRoute(tour, JSON.stringify([line[0]]), 2)).toBeNull();
|
||||
expect(savedSkiRoute(tour, JSON.stringify([line[0], [47.01, 11.02]]), 2)).toBeNull();
|
||||
expect(savedSkiRoute(tour, JSON.stringify([line[0], 'x']), 2)).toBeNull();
|
||||
expect(savedSkiRoute(tour, JSON.stringify([line[0], [47.01, 11.02, null]]), 2)).toBeNull();
|
||||
// A line of another length is another save's.
|
||||
expect(savedSkiRoute({ ...tour, distance: tour.distance + 0.1 }, json, 2)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-SAVED-004: hands the route on for exactly the points and the ski choice it opens with', () => {
|
||||
const points: TourPlannerWaypoint[] = [
|
||||
{ id: 'a', lat: 47, lng: 11, role: 'start' },
|
||||
{ id: 'b', lat: 47.01, lng: 11.02, role: 'end' },
|
||||
];
|
||||
const choice = { discipline: 'alpine' as const, technique: null };
|
||||
const seed = savedSkiSeed(tour, JSON.stringify(line), points, choice);
|
||||
expect(seed?.waypointKey).toBe(tourWaypointKey(points));
|
||||
expect(seed?.skiKey).toBe(skiRouteKey(choice));
|
||||
expect(seed?.route.plannedLegs).toEqual([1]);
|
||||
expect(savedSkiSeed({ ...tour, ski_planned_legs: null }, line, points, choice)?.route.plannedLegs).toEqual([0]);
|
||||
expect(savedSkiSeed(tour, null, points, choice)).toBeNull();
|
||||
expect(savedSkiSeed({ ...tour, ski_planned_legs: null }, line, [], choice)?.route.plannedLegs).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { computeTourMetrics, type TourListItem } from '@trek/shared';
|
||||
import { skiRouteKey, type SkiTourRoute } from '../planner/skiRouting';
|
||||
import { tourWaypointKey, type TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import type { SavedSkiSeed } from '../planner/useTourRoute';
|
||||
import type { SkiChoice } from './skiChoice';
|
||||
|
||||
/**
|
||||
* How far in km the length of the line read may stray from the length the
|
||||
* tour was saved with. Both are worked out the same way from the same points,
|
||||
* so a line that strays further is another save's.
|
||||
*/
|
||||
const SAME_LINE_KM = 0.005;
|
||||
|
||||
/** A line as its place keeps it: every point with its height, or nothing. */
|
||||
function savedLineOf(routeGeometry: unknown): [number, number, number][] | null {
|
||||
let points: unknown = routeGeometry;
|
||||
if (typeof routeGeometry === 'string') {
|
||||
try {
|
||||
points = JSON.parse(routeGeometry);
|
||||
} catch {
|
||||
// Not a line at all: the tour is planned afresh.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(points) || points.length < 2) return null;
|
||||
const line: [number, number, number][] = [];
|
||||
for (const point of points) {
|
||||
if (!Array.isArray(point) || point.length < 3) return null;
|
||||
const [lat, lng, height] = point as unknown[];
|
||||
if (![lat, lng, height].every((value) => typeof value === 'number' && Number.isFinite(value))) return null;
|
||||
line.push([lat as number, lng as number, height as number]);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
type SavedSkiTour = Pick<
|
||||
TourListItem,
|
||||
'tour_type' | 'ski_discipline' | 'distance' | 'duration' | 'ski_route_build' | 'ski_planned_legs'
|
||||
>;
|
||||
|
||||
/**
|
||||
* The legs the router planned, as the tour names them. A tour that does not
|
||||
* name them was planned throughout only when it kept the router's time.
|
||||
*/
|
||||
function plannedLegsOf(tour: SavedSkiTour, legCount: number): number[] {
|
||||
if (tour.ski_planned_legs) return [...tour.ski_planned_legs];
|
||||
if (!tour.ski_route_build || tour.duration == null) return [];
|
||||
return Array.from({ length: legCount }, (_, index) => index);
|
||||
}
|
||||
|
||||
/**
|
||||
* SKI-02: a saved ski tour as the planner opens it: the line, the time and
|
||||
* the build it was saved with, so nothing is asked of the piste router until
|
||||
* a point or an option changes, and a tour opened offline or while the piste
|
||||
* data cannot be reached shows what was planned instead of straight lines.
|
||||
* Null when there is nothing to open it with: a tour of another type or
|
||||
* without a discipline, no line kept on this device, a line without every
|
||||
* height, or a line of another save than the tour's.
|
||||
*/
|
||||
export function savedSkiRoute(tour: SavedSkiTour, routeGeometry: unknown, legCount: number): SkiTourRoute | null {
|
||||
if (tour.tour_type !== 'ski' || !tour.ski_discipline || tour.distance == null) return null;
|
||||
const line = savedLineOf(routeGeometry);
|
||||
if (!line) return null;
|
||||
const { distanceKm } = computeTourMetrics(line);
|
||||
if (Math.abs(distanceKm - tour.distance) > SAME_LINE_KM) return null;
|
||||
const coordinates = line.map(([lat, lng]): [number, number] => [lat, lng]);
|
||||
return {
|
||||
coordinates,
|
||||
parts: [{ coordinates, free: false }],
|
||||
distanceMeters: distanceKm * 1000,
|
||||
durationSeconds: tour.duration == null ? null : tour.duration * 60,
|
||||
legs: [],
|
||||
problems: [],
|
||||
elevations: line,
|
||||
build: tour.ski_route_build ?? null,
|
||||
stale: false,
|
||||
areaNames: [],
|
||||
colourScheme: 'europe',
|
||||
attribution: null,
|
||||
plannedLegs: plannedLegsOf(tour, legCount),
|
||||
saved: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the planner hands its route when it opens a saved ski tour: the route
|
||||
* as saved, for exactly the points and the ski choice it opens with. Null
|
||||
* when the tour opens to be planned afresh.
|
||||
*/
|
||||
export function savedSkiSeed(
|
||||
tour: SavedSkiTour,
|
||||
routeGeometry: unknown,
|
||||
waypoints: TourPlannerWaypoint[],
|
||||
choice: SkiChoice
|
||||
): SavedSkiSeed | null {
|
||||
const route = savedSkiRoute(tour, routeGeometry, Math.max(0, waypoints.length - 1));
|
||||
return route ? { route, waypointKey: tourWaypointKey(waypoints), skiKey: skiRouteKey(choice) } : null;
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import type { SkiLegProblem, SkiTourRoute } from '../planner/skiRouting';
|
||||
import { SkiLegFailureRow, SkiLegSteps, skiFixesOf } from './SkiLegRows';
|
||||
import { SkiRouteOptions } from './SkiRouteOptions';
|
||||
import { skiPaintOf } from './skiRouteStyle';
|
||||
import { SkiRouteSummary, skiSecondsOf } from './SkiRouteSummary';
|
||||
import { SkiRouteSummary, skiSecondsOf, skiTallyOf } from './SkiRouteSummary';
|
||||
|
||||
// FE-TOUR-SKI-UI-001 to FE-TOUR-SKI-UI-009
|
||||
// FE-TOUR-SKI-UI-001 to FE-TOUR-SKI-UI-012
|
||||
|
||||
function segment(kind: SkiRouteSegment['kind'], extra: Partial<SkiRouteSegment> = {}): SkiRouteSegment {
|
||||
return {
|
||||
@@ -68,6 +68,9 @@ function route(extra: Partial<SkiTourRoute> = {}): SkiTourRoute {
|
||||
stale: false,
|
||||
areaNames: ['Gletscherwelt'],
|
||||
colourScheme: 'europe',
|
||||
attribution: null,
|
||||
plannedLegs: [0],
|
||||
saved: false,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
@@ -211,6 +214,51 @@ describe('SkiRouteSummary (SKI-02)', () => {
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(render(<SkiRouteSummary route={null} />).container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-UI-010: counts the runs, the height skied down on them and the lift rides, and names the build', () => {
|
||||
const leg = okLeg({
|
||||
geometry: [
|
||||
[47, 11, 1500],
|
||||
[47.01, 11, 2000],
|
||||
[47.015, 11, null],
|
||||
[47.02, 11, 1400],
|
||||
[47.021, 11, 1450],
|
||||
[47.03, 11, 1200],
|
||||
],
|
||||
segments: [
|
||||
{ ...segment('lift'), from: 0, to: 1 },
|
||||
{ ...segment('run', { length_m: 1800 }), from: 1, to: 5 },
|
||||
{ ...segment('rail'), from: 5, to: 5 },
|
||||
],
|
||||
});
|
||||
// 2000 down to 1400 past a point without a height, up 50 and down 250: 850 m skied down.
|
||||
expect(skiTallyOf(route({ legs: [leg, null] }))).toEqual({ runsM: 1800, downM: 850, rides: 2 });
|
||||
const credited = route({ legs: [leg], attribution: '© OpenStreetMap contributors. Terrain: Terrarium' });
|
||||
render(<SkiRouteSummary route={credited} />);
|
||||
const card = screen.getByTestId('ski-route-summary');
|
||||
expect(card).toHaveTextContent('tours.ski.summary.runs1.8 km');
|
||||
expect(card).toHaveTextContent('tours.ski.summary.descent850 m');
|
||||
expect(card).toHaveTextContent('tours.ski.summary.lifts2');
|
||||
expect(card).toHaveTextContent('tours.ski.summary.build');
|
||||
expect(card).toHaveTextContent('Gletscherwelt. © OpenStreetMap contributors. Terrain: Terrarium');
|
||||
expect(card).not.toHaveTextContent('tours.ski.summary.source');
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-UI-011: a tour opened as saved says so with its build, and the fixed credit where none was sent', () => {
|
||||
render(<SkiRouteSummary route={route({ legs: [], saved: true, areaNames: [] })} />);
|
||||
const card = screen.getByTestId('ski-route-summary');
|
||||
expect(card).toHaveTextContent('tours.ski.summary.saved');
|
||||
expect(card).toHaveTextContent('tours.ski.summary.build');
|
||||
expect(card).toHaveTextContent('tours.ski.summary.source');
|
||||
expect(card).not.toHaveTextContent('tours.ski.summary.ski');
|
||||
});
|
||||
|
||||
it('FE-TOUR-SKI-UI-012: nothing for a tour saved without a leg the router planned, and no build line without a build', () => {
|
||||
const drawn = route({ legs: [], saved: true, plannedLegs: [], build: null });
|
||||
expect(render(<SkiRouteSummary route={drawn} />).container).toBeEmptyDOMElement();
|
||||
render(<SkiRouteSummary route={route({ saved: true, legs: [], build: null })} />);
|
||||
expect(screen.getByTestId('ski-route-summary')).not.toHaveTextContent('tours.ski.summary.build');
|
||||
});
|
||||
});
|
||||
|
||||
describe('skiPaintOf (SKI-02)', () => {
|
||||
|
||||
Reference in New Issue
Block a user