Files
TREK/client/src/components/Tours/planner/tourPlannerDraft.ts
T
Maurice cd407cf80c 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.
2026-10-20 15:05:15 +02:00

121 lines
4.5 KiB
TypeScript

import {
bikeProfileSchema,
hikingPreferencesSchema,
MAX_PLANNED_TOUR_DURATION_MINUTES,
paddleLockSchema,
plannableTourTypeSchema,
winterActivitySchema,
type BikeProfile,
type HikingPreferences,
type PlannableTourType,
type TourMaxHikingDifficulty,
type WinterActivity,
} from '@trek/shared';
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
import type { PaddleFieldValues } from './usePaddleFields';
/** CW-07: where a draft's points were read from outside the trip, said in the planner until it is saved. */
export type DraftSource = 'google_maps';
/**
* The tour planner's draft as it is kept in local storage between visits, and
* the check a stored draft has to pass before the planner takes it back: a
* draft from an older version, or one edited by hand, is dropped rather than
* half read.
*/
export interface DraftPayload {
version: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;
name: string;
description?: string;
website?: string;
plannedDurationMinutes?: number | null;
breakAdditionalMinutes?: number | null;
waypoints: TourPlannerWaypoint[];
maxHikingDifficulty?: TourMaxHikingDifficulty;
editingPlaceId?: number | null;
/** The import a new tour is being planned from; carried until it is saved. */
derivedFromPlaceId?: number | null;
/** CW-07: a draft loaded from a Google Maps walk keeps saying so across a reload. */
draftSource?: DraftSource | null;
tourType?: PlannableTourType;
paddle?: PaddleFieldValues;
/** P2C-03: the revision of the tour the draft edits, as it was loaded. */
baseRevision?: number | null;
/** MF-08: what the hike asks the router for besides its limit. */
hikingPreferences?: HikingPreferences | null;
/** BIK-01: the profile a bike tour is routed for. */
bikeProfile?: BikeProfile;
/** HIK-01: the hike's winter label; absent in a draft older than it. */
winterActivity?: WinterActivity | null;
}
const DRAFT_VERSIONS: ReadonlyArray<DraftPayload['version']> = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
function optionalString(value: unknown): boolean {
return value === undefined || typeof value === 'string';
}
function optionalMinutes(value: number | null | undefined): boolean {
return (
value === undefined ||
value === null ||
(Number.isInteger(value) && value >= 0 && value <= MAX_PLANNED_TOUR_DURATION_MINUTES)
);
}
function optionalPlaceId(value: number | null | undefined): boolean {
return value == null || (Number.isInteger(value) && value > 0);
}
function optionalSource(value: unknown): boolean {
return value == null || value === 'google_maps';
}
function optionalDifficulty(value: number | null | undefined): boolean {
return value == null || (Number.isInteger(value) && value >= 1 && value <= 6);
}
function validDraftWaypoint(point: TourPlannerWaypoint): boolean {
return (
!!point &&
typeof point.id === 'string' &&
Number.isFinite(point.lat) &&
Math.abs(point.lat) <= 90 &&
Number.isFinite(point.lng) &&
Math.abs(point.lng) <= 180 &&
(point.lock == null || paddleLockSchema.safeParse(point.lock).success)
);
}
/** The type of a draft, what its route is planned by and its winter label, each one absent in a draft older than it. */
function validRouteChoices(draft: Partial<DraftPayload>): boolean {
return (
(draft.tourType === undefined || plannableTourTypeSchema.safeParse(draft.tourType).success) &&
(draft.bikeProfile === undefined || bikeProfileSchema.safeParse(draft.bikeProfile).success) &&
(draft.hikingPreferences == null || hikingPreferencesSchema.safeParse(draft.hikingPreferences).success) &&
(draft.winterActivity == null || winterActivitySchema.safeParse(draft.winterActivity).success) &&
optionalDifficulty(draft.maxHikingDifficulty)
);
}
export function validDraft(value: unknown): value is DraftPayload {
if (!value || typeof value !== 'object') return false;
const draft = value as Partial<DraftPayload>;
return (
draft.version !== undefined &&
DRAFT_VERSIONS.includes(draft.version) &&
typeof draft.name === 'string' &&
optionalString(draft.description) &&
optionalString(draft.website) &&
optionalMinutes(draft.plannedDurationMinutes) &&
optionalMinutes(draft.breakAdditionalMinutes) &&
optionalPlaceId(draft.editingPlaceId) &&
optionalPlaceId(draft.derivedFromPlaceId) &&
optionalSource(draft.draftSource) &&
optionalPlaceId(draft.baseRevision) &&
validRouteChoices(draft) &&
Array.isArray(draft.waypoints) &&
draft.waypoints.every(validDraftWaypoint)
);
}