feat(tours): city walks in the contract and on the server (CW-01, CW-02, CW-03, CW-09, CW-10)

A tour can be a city walk. Its points are stations that keep a key across
saves, a name, a stay (0 for a point passed through, empty when unknown), an
importance and a protection; the visits of a walk's stations are kept per day
assignment, so another day or a copy starts with nothing visited. A station
that is gone takes its visits with it. An assistant reads how far a walk got
on a day, what is open and which times are known.
This commit is contained in:
Maurice
2026-10-19 21:59:47 +02:00
parent 187c4e2fc8
commit 33255b168f
26 changed files with 684 additions and 31 deletions
+1
View File
@@ -59,6 +59,7 @@ export * from './tours/tour-provenance.schema';
export * from './tours/tour-library.schema';
export * from './tours/tour-poi-search.schema';
export * from './tours/tour-trail-grade.schema';
export * from './tours/tour-walk.schema';
export * from './tours/tours.metrics';
export * from './tours/tour-gpx-import.schema';
export * from './tours/paddle-plan.schema';
@@ -20,6 +20,7 @@ const FIXTURES: Record<TrekWsEventName, Record<string, unknown>> = {
'place:updated': { place: { id: 3, name: 'Louvre' } },
'place:deleted': { placeId: 3 },
'tours:changed': { placeIds: [3] },
'tours:visits': { assignmentId: 9, visits: [{ assignment_id: 9, station_key: 'abcdef', status: 'visited' }] },
'assignment:created': { assignment: { id: 9, day_id: 2 } },
'assignment:updated': { assignment: { id: 9, day_id: 2 } },
'assignment:deleted': { assignmentId: 9, dayId: 2 },
+2
View File
@@ -65,6 +65,8 @@ export const TREK_WS_EVENTS = {
'place:deleted': { scope: 'trip', payload: z.object({ placeId: id }) },
// Invalidate the trip's Tours list; affected ids let peers exclude new Tours immediately.
'tours:changed': { scope: 'trip', payload: z.object({ placeIds: idList.optional() }) },
// CW-03: the stations of a city walk visited or skipped on one of its days, all of them as they are now.
'tours:visits': { scope: 'trip', payload: z.object({ assignmentId: id, visits: z.array(entity) }) },
// ── Assignments ──────────────────────────────────────────────────────────
'assignment:created': { scope: 'trip', payload: z.object({ assignment: entity }) },
@@ -22,6 +22,11 @@ export const TOUR_POI_CATEGORIES = [
'slipway',
'boat_rental',
'bike_repair',
'museum',
'park',
'cafe',
'restaurant',
'market',
] as const;
export const tourPoiCategorySchema = z.enum(TOUR_POI_CATEGORIES);
export type TourPoiCategory = z.infer<typeof tourPoiCategorySchema>;
@@ -31,8 +36,23 @@ export const TOUR_POI_CATEGORIES_BY_TYPE: Record<PlannableTourType, readonly Tou
hike: ['viewpoint', 'shelter', 'drinking_water', 'food', 'peak', 'toilets', 'picnic', 'campsite', 'sights'],
bike: ['drinking_water', 'bike_repair', 'food', 'viewpoint', 'toilets', 'shelter', 'picnic', 'campsite', 'sights'],
kayak: ['slipway', 'boat_rental', 'drinking_water', 'food', 'toilets', 'picnic', 'campsite', 'shelter'],
walk: ['sights', 'museum', 'park', 'viewpoint', 'cafe', 'restaurant', 'market', 'toilets', 'drinking_water'],
};
/**
* CW-05: the four themes a city walk can be filled from, each a set of the
* walk's own categories. A suggestion says which theme and category brought
* it; nothing is claimed beyond what the source tags say.
*/
export const WALK_THEMES = {
culture: ['museum', 'sights'],
parks: ['park', 'viewpoint'],
food: ['cafe', 'restaurant', 'market'],
mixed: ['sights', 'museum', 'park', 'viewpoint', 'cafe', 'market'],
} as const satisfies Record<string, readonly TourPoiCategory[]>;
export type WalkTheme = keyof typeof WALK_THEMES;
export const WALK_THEME_KEYS = Object.keys(WALK_THEMES) as WalkTheme[];
/** How far from the line a place may lie, the choices offered, in metres. */
export const TOUR_POI_CORRIDORS_M = [250, 500, 1000, 2000] as const;
/** The most places one answer carries, nearest the start first. */
+53
View File
@@ -0,0 +1,53 @@
import { stationKeySchema } from './tours.schema';
import { z } from 'zod';
/**
* CW-03: how far a city walk got on one of its days. A visit belongs to the
* walk's place on that day (its day assignment), never to the walk itself, so
* the same walk on another day, or a copy, starts with nothing visited. A
* station without a row is still planned.
*/
export const TOUR_VISIT_STATUSES = ['visited', 'skipped'] as const;
export const tourVisitStatusSchema = z.enum(TOUR_VISIT_STATUSES);
export type TourVisitStatus = z.infer<typeof tourVisitStatusSchema>;
export const tourVisitSchema = z.object({
assignment_id: z.number().int().positive(),
station_key: stationKeySchema,
status: tourVisitStatusSchema,
});
export type TourVisit = z.infer<typeof tourVisitSchema>;
export const tourVisitListResponseSchema = z.object({ visits: z.array(tourVisitSchema) });
export type TourVisitListResponse = z.infer<typeof tourVisitListResponseSchema>;
/** Planned takes a station back to where it started: nothing visited, nothing skipped. */
export const tourVisitUpdateRequestSchema = z
.object({ status: z.enum(['planned', ...TOUR_VISIT_STATUSES]) })
.strict();
export type TourVisitUpdateRequest = z.infer<typeof tourVisitUpdateRequestSchema>;
/**
* CW-06, CW-07: a Google Maps walking route shared as a link, read into the
* stations of a city walk. Only links Google marks as on foot are taken; the
* way between the stations is TREK's own, planned afresh.
*/
export const WALK_IMPORT_URL_MAX = 2048;
export const walkImportPreviewRequestSchema = z.object({ url: z.string().trim().min(1).max(WALK_IMPORT_URL_MAX) }).strict();
export type WalkImportPreviewRequest = z.infer<typeof walkImportPreviewRequestSchema>;
/** A stop as the link names it; a stop without coordinates in the link is not guessed at. */
export const walkImportStationSchema = z.object({
name: z.string().nullable(),
lat: z.number().min(-90).max(90).nullable(),
lng: z.number().min(-180).max(180).nullable(),
});
export type WalkImportStation = z.infer<typeof walkImportStationSchema>;
export const walkImportPreviewResponseSchema = z.object({
stations: z.array(walkImportStationSchema),
/** The link as it was read, after its short form was followed. */
resolved_url: z.string(),
});
export type WalkImportPreviewResponse = z.infer<typeof walkImportPreviewResponseSchema>;
+50 -9
View File
@@ -25,12 +25,13 @@ export const tourTypeKeySchema = z.enum(['hike', 'bike', 'run', 'ski', 'kayak',
export type TourTypeKey = z.infer<typeof tourTypeKeySchema>;
/**
* The types a tour can be planned, saved and imported as. `kayak` is the key
* the paddle tour has had in `tour_types` from the start; the app calls it a
* paddle tour, and the boat is a field of its own. A bike tour (BIK-01) names
* its profile the same way.
* The types a tour can be planned, saved and imported as, in the order they
* are offered. `kayak` is the key the paddle tour has had in `tour_types` from
* the start; the app calls it a paddle tour, and the boat is a field of its
* own. A bike tour (BIK-01) names its profile the same way. `walk` is the city
* walk (CW-01): stations joined on foot, without a hiking limit.
*/
export const plannableTourTypeSchema = z.enum(['hike', 'bike', 'kayak']);
export const plannableTourTypeSchema = z.enum(['hike', 'walk', 'bike', 'kayak']);
export type PlannableTourType = z.infer<typeof plannableTourTypeSchema>;
/**
@@ -234,6 +235,8 @@ export const tourListItemSchema = tourSchema.extend({
paddle_check: paddleCheckSchema.nullable().optional().catch(undefined),
/** P2C-03: the revision a write names to be refused once the tour moved on; absent in a list cached before it existed. */
revision: tourRevisionSchema.optional(),
/** CW-03: a city walk's stations, its points that are no shaping points; null for any other type. */
station_count: z.number().int().nonnegative().nullable().optional(),
});
export type TourListItem = z.infer<typeof tourListItemSchema>;
@@ -287,6 +290,19 @@ export const tourFixedLegSchema = z.discriminatedUnion('kind', [
export type TourFixedLeg = z.infer<typeof tourFixedLegSchema>;
export type TourFixedLegKind = TourFixedLeg['kind'];
/** CW-02: the longest stay a station takes, a whole day. */
export const MAX_STATION_STAY_MINUTES = 24 * 60;
/** The longest name a tour point keeps. */
export const TOUR_STATION_NAME_MAX = 200;
/** The longest opening hours a station keeps, as OpenStreetMap writes them. */
export const TOUR_STATION_HOURS_MAX = 500;
/** CW-03: a station's key, the same across saves. */
export const stationKeySchema = z.string().regex(/^[A-Za-z0-9_-]{6,64}$/);
/** CW-03: how much a station matters, besides normal (null). */
export const STATION_IMPORTANCES = ['optional', 'must'] as const;
export const stationImportanceSchema = z.enum(STATION_IMPORTANCES);
export type StationImportance = z.infer<typeof stationImportanceSchema>;
export const tourWaypointSchema = z.object({
lat: z.number().finite().min(-90).max(90),
lng: z.number().finite().min(-180).max(180),
@@ -307,6 +323,22 @@ export const tourWaypointSchema = z.object({
* that is gone (or not of this trip) leaves the point unlinked.
*/
linked_place_id: z.number().int().positive().nullable().optional(),
/**
* CW-03: the point's own key, kept across saves, so the visits of a city
* walk's stations outlast a reorder. The server gives one to a point that
* comes without.
*/
key: stationKeySchema.optional(),
/** The point's name where it has one: a station of a city walk, a place found along the route. */
name: z.string().trim().max(TOUR_STATION_NAME_MAX).nullable().optional(),
/** CW-02: how long a city walk's station is stayed at; 0 is a point passed through, null not known. */
stay_minutes: z.number().int().min(0).max(MAX_STATION_STAY_MINUTES).nullable().optional(),
/** CW-03: how much a station matters; null is normal. */
importance: stationImportanceSchema.nullable().optional(),
/** CW-10: a station a proposed change keeps where it is; the traveller can still edit it. */
protected: z.boolean().optional(),
/** CW-04: the station's opening hours as OpenStreetMap writes them, where it was found there. */
opening_hours: z.string().trim().max(TOUR_STATION_HOURS_MAX).nullable().optional(),
});
export type TourWaypoint = z.infer<typeof tourWaypointSchema>;
@@ -438,11 +470,17 @@ function tourTypeIssues(value: {
paddle_speed_kmh?: number | null;
portage_speed_kmh?: number | null;
paddle_lake_style?: PaddleLakeStyle | null;
waypoints?: ReadonlyArray<{ fixed_leg?: { kind: string } | null; lock?: unknown }>;
waypoints?: ReadonlyArray<{
fixed_leg?: { kind: string } | null;
lock?: unknown;
stay_minutes?: number | null;
importance?: string | null;
protected?: boolean;
}>;
}): Issue[] {
const issues: Issue[] = [];
const type = value.tour_type;
const named = type === 'kayak' ? 'a paddle tour' : type === 'bike' ? 'a bike tour' : 'a hike';
const named = { kayak: 'a paddle tour', bike: 'a bike tour', walk: 'a city walk', hike: 'a hike' }[type];
if (type === 'kayak') {
if (value.boat_type == null) issues.push({ path: ['boat_type'], message: 'a paddle tour needs a boat type' });
if (value.water_context == null)
@@ -471,8 +509,11 @@ function tourTypeIssues(value: {
issues.push({ path: ['waypoints', index, 'fixed_leg'], message: 'a portage belongs to a paddle tour' });
if (type !== 'kayak' && point.lock != null)
issues.push({ path: ['waypoints', index, 'lock'], message: 'a lock belongs to a paddle tour' });
if (type === 'bike' && point.fixed_leg?.kind === 'ferrata')
issues.push({ path: ['waypoints', index, 'fixed_leg'], message: 'a bike tour has no via ferrata' });
if ((type === 'bike' || type === 'walk') && point.fixed_leg?.kind === 'ferrata')
issues.push({ path: ['waypoints', index, 'fixed_leg'], message: `${named} has no via ferrata` });
// A stay, an importance and a protection are a city walk's stations'.
if (type !== 'walk' && (point.stay_minutes != null || point.importance != null || point.protected === true))
issues.push({ path: ['waypoints', index], message: 'stations belong to a city walk' });
});
return issues;
}