mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
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:
@@ -109,6 +109,7 @@
|
||||
"TodoCategoryAssignees": "todo",
|
||||
"TodoItems": "todo",
|
||||
"TourObstacleReviews": "tours",
|
||||
"TourStationVisits": "tours",
|
||||
"TourSources": "tours",
|
||||
"TourTypes": "tours",
|
||||
"TourWaypoints": "tours",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { EntityRepositoryType, type Opt, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
|
||||
import { TourStationVisitsRepository } from '../repositories/TourStationVisits.repository';
|
||||
import { DbTimestampType } from '../types';
|
||||
import { DayAssignments } from './DayAssignments.entity';
|
||||
|
||||
export class TourStationVisits {
|
||||
[EntityRepositoryType]?: TourStationVisitsRepository;
|
||||
[PrimaryKeyProp]?: ['assignment', 'station_key'];
|
||||
assignment!: Ref<DayAssignments>;
|
||||
assignment_id!: number;
|
||||
station_key!: string;
|
||||
status!: TTourStationVisitsStatus;
|
||||
updated_at!: string & Opt;
|
||||
}
|
||||
|
||||
export const TourStationVisitsStatus = {
|
||||
VISITED: 'visited',
|
||||
SKIPPED: 'skipped',
|
||||
} as const;
|
||||
|
||||
export type TTourStationVisitsStatus = (typeof TourStationVisitsStatus)[keyof typeof TourStationVisitsStatus];
|
||||
|
||||
export const TourStationVisitsSchema = defineEntity({
|
||||
class: TourStationVisits,
|
||||
repository: () => TourStationVisitsRepository,
|
||||
uniques: [{ properties: ['assignment', 'station_key'] }],
|
||||
properties: {
|
||||
assignment: () => p.manyToOne(DayAssignments).primary().ref().deleteRule('cascade').hidden(),
|
||||
assignment_id: p.integer().persist(false),
|
||||
station_key: p.text().primary(),
|
||||
status: p.enum(() => TourStationVisitsStatus),
|
||||
updated_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
});
|
||||
@@ -17,8 +17,21 @@ export class TourWaypoints {
|
||||
shaping: number & Opt = 0;
|
||||
linkedPlace?: Ref<Places> | null;
|
||||
linked_place_id?: number | null;
|
||||
station_key?: string | null;
|
||||
name?: string | null;
|
||||
stay_minutes?: number | null;
|
||||
importance?: TTourWaypointsImportance | null;
|
||||
protected: number & Opt = 0;
|
||||
opening_hours?: string | null;
|
||||
}
|
||||
|
||||
export const TourWaypointsImportance = {
|
||||
OPTIONAL: 'optional',
|
||||
MUST: 'must',
|
||||
} as const;
|
||||
|
||||
export type TTourWaypointsImportance = (typeof TourWaypointsImportance)[keyof typeof TourWaypointsImportance];
|
||||
|
||||
export const TourWaypointsSchema = defineEntity({
|
||||
class: TourWaypoints,
|
||||
repository: () => TourWaypointsRepository,
|
||||
@@ -31,6 +44,11 @@ export const TourWaypointsSchema = defineEntity({
|
||||
],
|
||||
uniques: [{ properties: ['place', 'sequence'] }],
|
||||
checks: [
|
||||
{ name: 'tour_waypoints_protected_check', expression: 'protected IN (0, 1)' },
|
||||
{
|
||||
name: 'tour_waypoints_stay_minutes_check',
|
||||
expression: 'stay_minutes IS NULL OR (stay_minutes >= 0 AND stay_minutes <= 1440)',
|
||||
},
|
||||
{ name: 'tour_waypoints_shaping_check', expression: 'shaping IN (0, 1)' },
|
||||
],
|
||||
properties: {
|
||||
@@ -46,5 +64,11 @@ export const TourWaypointsSchema = defineEntity({
|
||||
shaping: p.integer().default(0),
|
||||
linkedPlace: () => p.manyToOne(Places).ref().nullable().hidden(),
|
||||
linked_place_id: p.integer().nullable().persist(false),
|
||||
station_key: p.text().nullable(),
|
||||
name: p.text().nullable(),
|
||||
stay_minutes: p.integer().nullable(),
|
||||
importance: p.enum(() => TourWaypointsImportance).nullable(),
|
||||
protected: p.integer().default(0),
|
||||
opening_hours: p.text().nullable(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -109,6 +109,7 @@ import { TodoCategoryAssigneesSchema } from './TodoCategoryAssignees.entity';
|
||||
import { TodoItemsSchema } from './TodoItems.entity';
|
||||
import { TourObstacleReviewsSchema } from './TourObstacleReviews.entity';
|
||||
import { TourSourcesSchema } from './TourSources.entity';
|
||||
import { TourStationVisitsSchema } from './TourStationVisits.entity';
|
||||
import { TourTypesSchema } from './TourTypes.entity';
|
||||
import { TourWaypointsSchema } from './TourWaypoints.entity';
|
||||
import { ToursSchema } from './Tours.entity';
|
||||
@@ -253,6 +254,7 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
|
||||
TodoItemsSchema,
|
||||
TourObstacleReviewsSchema,
|
||||
TourSourcesSchema,
|
||||
TourStationVisitsSchema,
|
||||
TourTypesSchema,
|
||||
TourWaypointsSchema,
|
||||
ToursSchema,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
|
||||
// change the migration, then run `npm run gen:db-types`.
|
||||
import type { InsertOptional } from './columns';
|
||||
|
||||
export interface TourStationVisitsTable {
|
||||
assignment_id: number;
|
||||
station_key: string;
|
||||
status: string;
|
||||
updated_at: InsertOptional<string>;
|
||||
}
|
||||
@@ -13,4 +13,10 @@ export interface TourWaypointsTable {
|
||||
lock: InsertOptional<string | null>;
|
||||
shaping: InsertOptional<number>;
|
||||
linked_place_id: InsertOptional<number | null>;
|
||||
station_key: InsertOptional<string | null>;
|
||||
name: InsertOptional<string | null>;
|
||||
stay_minutes: InsertOptional<number | null>;
|
||||
importance: InsertOptional<string | null>;
|
||||
protected: InsertOptional<number>;
|
||||
opening_hours: InsertOptional<string | null>;
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ import type { TodoCategoryAssigneesTable } from './TodoCategoryAssignees.table';
|
||||
import type { TodoItemsTable } from './TodoItems.table';
|
||||
import type { TourObstacleReviewsTable } from './TourObstacleReviews.table';
|
||||
import type { TourSourcesTable } from './TourSources.table';
|
||||
import type { TourStationVisitsTable } from './TourStationVisits.table';
|
||||
import type { TourTypesTable } from './TourTypes.table';
|
||||
import type { TourWaypointsTable } from './TourWaypoints.table';
|
||||
import type { ToursTable } from './Tours.table';
|
||||
@@ -265,6 +266,7 @@ export interface DB {
|
||||
todo_items: TodoItemsTable;
|
||||
tour_obstacle_reviews: TourObstacleReviewsTable;
|
||||
tour_sources: TourSourcesTable;
|
||||
tour_station_visits: TourStationVisitsTable;
|
||||
tour_types: TourTypesTable;
|
||||
tour_waypoints: TourWaypointsTable;
|
||||
tours: ToursTable;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* CW-01 to CW-04, CW-10: a tour can be a city walk. The `walk` key has been in
|
||||
* `tour_types`, disabled, since every contract type was seeded; it is switched
|
||||
* on here. Its points are stations: each keeps a key across saves, a name,
|
||||
* how long it is stayed at, how much it matters, whether a proposed change
|
||||
* must leave it, and the opening hours it was found with. Every column is
|
||||
* NULL (or off) on the points saved before, so no tour changes its meaning.
|
||||
*
|
||||
* How far a walk got on a day is kept per day assignment and station key, so
|
||||
* the same walk on another day, or a copy, starts with nothing visited, and a
|
||||
* visit goes with its assignment.
|
||||
*/
|
||||
export class Migration20200101045300_a_tour_can_be_a_city_walk extends Migration {
|
||||
override name = 'Migration20200101045300_a_tour_can_be_a_city_walk';
|
||||
|
||||
override up(): void {
|
||||
this.addSql('ALTER TABLE tour_waypoints ADD COLUMN station_key TEXT');
|
||||
this.addSql('ALTER TABLE tour_waypoints ADD COLUMN name TEXT');
|
||||
this.addSql(
|
||||
'ALTER TABLE tour_waypoints ADD COLUMN stay_minutes INTEGER CHECK (stay_minutes IS NULL OR (stay_minutes >= 0 AND stay_minutes <= 1440))',
|
||||
);
|
||||
this.addSql(
|
||||
"ALTER TABLE tour_waypoints ADD COLUMN importance TEXT CHECK (importance IS NULL OR importance IN ('optional', 'must'))",
|
||||
);
|
||||
this.addSql(
|
||||
'ALTER TABLE tour_waypoints ADD COLUMN protected INTEGER NOT NULL DEFAULT 0 CHECK (protected IN (0, 1))',
|
||||
);
|
||||
this.addSql('ALTER TABLE tour_waypoints ADD COLUMN opening_hours TEXT');
|
||||
this.addSql(`
|
||||
CREATE TABLE tour_station_visits (
|
||||
assignment_id INTEGER NOT NULL REFERENCES day_assignments(id) ON DELETE CASCADE,
|
||||
station_key TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('visited', 'skipped')),
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (assignment_id, station_key)
|
||||
)
|
||||
`);
|
||||
this.addSql("UPDATE tour_types SET enabled = 1 WHERE key = 'walk'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { TourStationVisits, TTourStationVisitsStatus } from '../entities/TourStationVisits.entity';
|
||||
import type { DB } from '../kysely/db';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
|
||||
/** `status` narrowed to the two values the column's CHECK constraint allows. */
|
||||
type VisitsDB = {
|
||||
tour_station_visits: Omit<DB['tour_station_visits'], 'status'> & { status: TTourStationVisitsStatus };
|
||||
day_assignments: DB['day_assignments'];
|
||||
days: DB['days'];
|
||||
};
|
||||
|
||||
/** One station of a city walk visited or skipped on one of its days. */
|
||||
export interface TourStationVisitRow {
|
||||
assignment_id: number;
|
||||
station_key: string;
|
||||
status: TTourStationVisitsStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* `tour_station_visits` (CW-03): how far a city walk got on a day, per day
|
||||
* assignment and station key. A station without a row is still planned; a
|
||||
* visit goes with its assignment.
|
||||
*/
|
||||
export class TourStationVisitsRepository extends TrekRepository<TourStationVisits> {
|
||||
private db() {
|
||||
return this.kysely<VisitsDB>();
|
||||
}
|
||||
|
||||
/** A day assignment of this trip: the place it puts on which day; undefined for one of another trip or none. */
|
||||
async assignmentInTrip(
|
||||
assignment_id: number,
|
||||
trip_id: number,
|
||||
): Promise<{ place_id: number; day_id: number } | undefined> {
|
||||
return await this.db()
|
||||
.selectFrom('day_assignments as da')
|
||||
.innerJoin('days as d', 'd.id', 'da.day_id')
|
||||
.select(['da.place_id', 'da.day_id'])
|
||||
.where('da.id', '=', assignment_id)
|
||||
.where('d.trip_id', '=', trip_id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/** The assignment that puts this place on this day of this trip; undefined where there is none. */
|
||||
async assignmentOnDay(trip_id: number, place_id: number, day_id: number): Promise<number | undefined> {
|
||||
const row = await this.db()
|
||||
.selectFrom('day_assignments as da')
|
||||
.innerJoin('days as d', 'd.id', 'da.day_id')
|
||||
.select('da.id')
|
||||
.where('da.place_id', '=', place_id)
|
||||
.where('da.day_id', '=', day_id)
|
||||
.where('d.trip_id', '=', trip_id)
|
||||
.executeTakeFirst();
|
||||
return row?.id;
|
||||
}
|
||||
|
||||
/** Every visit on the days of one trip, for its day plan. */
|
||||
async listForTrip(trip_id: number): Promise<TourStationVisitRow[]> {
|
||||
return await this.db()
|
||||
.selectFrom('tour_station_visits as v')
|
||||
.innerJoin('day_assignments as da', 'da.id', 'v.assignment_id')
|
||||
.innerJoin('days as d', 'd.id', 'da.day_id')
|
||||
.select(['v.assignment_id', 'v.station_key', 'v.status'])
|
||||
.where('d.trip_id', '=', trip_id)
|
||||
.orderBy('v.assignment_id', 'asc')
|
||||
.orderBy('v.station_key', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** The visits of one day assignment. */
|
||||
async listForAssignment(assignment_id: number): Promise<TourStationVisitRow[]> {
|
||||
return await this.db()
|
||||
.selectFrom('tour_station_visits as v')
|
||||
.select(['v.assignment_id', 'v.station_key', 'v.status'])
|
||||
.where('v.assignment_id', '=', assignment_id)
|
||||
.orderBy('v.station_key', 'asc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Marks a station visited or skipped on that day, replacing what it was. */
|
||||
async set(assignment_id: number, station_key: string, status: TTourStationVisitsStatus): Promise<void> {
|
||||
await this.db()
|
||||
.insertInto('tour_station_visits')
|
||||
.values({ assignment_id, station_key, status })
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['assignment_id', 'station_key']).doUpdateSet((eb) => ({
|
||||
status: eb.ref('excluded.status'),
|
||||
updated_at: eb.fn<string>('CURRENT_TIMESTAMP', []),
|
||||
}))
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Takes a station back to planned on that day. */
|
||||
async clear(assignment_id: number, station_key: string): Promise<void> {
|
||||
await this.db()
|
||||
.deleteFrom('tour_station_visits')
|
||||
.where('assignment_id', '=', assignment_id)
|
||||
.where('station_key', '=', station_key)
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Drops the visits of stations a walk no longer has, on every day it is on. */
|
||||
async dropStationsNotIn(place_id: number, keep: readonly string[]): Promise<void> {
|
||||
const assignments = this.db()
|
||||
.selectFrom('day_assignments')
|
||||
.select('id')
|
||||
.where('place_id', '=', place_id);
|
||||
let query = this.db().deleteFrom('tour_station_visits').where('assignment_id', 'in', assignments);
|
||||
if (keep.length > 0) query = query.where('station_key', 'not in', [...keep]);
|
||||
await query.execute();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { decodeJson, encodeJson } from '../../utils/json-column';
|
||||
import type { TourWaypoints } from '../entities/TourWaypoints.entity';
|
||||
import type { TourWaypoints, TTourWaypointsImportance } from '../entities/TourWaypoints.entity';
|
||||
import { TOUR_WAYPOINT_FIXED_LEG, TOUR_WAYPOINT_LOCK } from '../json-columns';
|
||||
import type { DB } from '../kysely/db';
|
||||
import { TrekRepository } from './_shared/trek-repository';
|
||||
@@ -19,6 +19,18 @@ export interface TourWaypointRow {
|
||||
shaping?: boolean;
|
||||
/** PR1-10: the place of the trip this point stands for; absent for a free point. */
|
||||
linked_place_id?: number | null;
|
||||
/** CW-03: the point's key, the same across saves; absent on a point saved before keys. */
|
||||
key?: string;
|
||||
/** The point's own name; absent where it has none. */
|
||||
name?: string | null;
|
||||
/** CW-02: a city walk station's stay; absent where it is not known, 0 for a point passed through. */
|
||||
stay_minutes?: number | null;
|
||||
/** CW-03: how much a station matters; absent for normal. */
|
||||
importance?: TTourWaypointsImportance | null;
|
||||
/** CW-10: a station a proposed change keeps; absent when it is not protected. */
|
||||
protected?: boolean;
|
||||
/** CW-04: the station's opening hours as OpenStreetMap writes them; absent where there are none. */
|
||||
opening_hours?: string | null;
|
||||
}
|
||||
|
||||
/** The row as stored, the fixed leg still JSON text. */
|
||||
@@ -31,18 +43,52 @@ interface StoredWaypoint {
|
||||
lock: string | null;
|
||||
shaping: number;
|
||||
linked_place_id: number | null;
|
||||
station_key: string | null;
|
||||
name: string | null;
|
||||
stay_minutes: number | null;
|
||||
importance: TTourWaypointsImportance | null;
|
||||
protected: number;
|
||||
opening_hours: string | null;
|
||||
}
|
||||
|
||||
/** A stored row as callers read it: the fixed leg, the lock and the shaping flag, each left out entirely where there is none. */
|
||||
function readWaypoint({ fixed_leg, lock, shaping, linked_place_id, ...point }: StoredWaypoint): TourWaypointRow {
|
||||
/** The columns a waypoint is read with, as the stored row names them. */
|
||||
const WAYPOINT_COLUMNS = [
|
||||
'lat',
|
||||
'lng',
|
||||
'role',
|
||||
'sequence',
|
||||
'fixed_leg',
|
||||
'lock',
|
||||
'shaping',
|
||||
'linked_place_id',
|
||||
'station_key',
|
||||
'name',
|
||||
'stay_minutes',
|
||||
'importance',
|
||||
'protected',
|
||||
'opening_hours',
|
||||
] as const;
|
||||
|
||||
/** A stored row as callers read it: every optional part left out entirely where there is none. */
|
||||
function readWaypoint(row: StoredWaypoint): TourWaypointRow {
|
||||
const { fixed_leg, lock, shaping, linked_place_id, station_key, name, stay_minutes, importance, opening_hours } = row;
|
||||
const leg = decodeJson(TOUR_WAYPOINT_FIXED_LEG, fixed_leg, 'tour waypoint');
|
||||
const stop = decodeJson(TOUR_WAYPOINT_LOCK, lock, 'tour waypoint');
|
||||
return {
|
||||
...point,
|
||||
lat: row.lat,
|
||||
lng: row.lng,
|
||||
role: row.role,
|
||||
sequence: row.sequence,
|
||||
...(leg ? { fixed_leg: leg } : {}),
|
||||
...(stop ? { lock: stop } : {}),
|
||||
...(shaping ? { shaping: true } : {}),
|
||||
...(linked_place_id != null ? { linked_place_id } : {}),
|
||||
...(station_key ? { key: station_key } : {}),
|
||||
...(name ? { name } : {}),
|
||||
...(stay_minutes != null ? { stay_minutes } : {}),
|
||||
...(importance ? { importance } : {}),
|
||||
...(row.protected ? { protected: true } : {}),
|
||||
...(opening_hours ? { opening_hours } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,9 +97,12 @@ export interface TourWaypointWithPlaceRow extends TourWaypointRow {
|
||||
place_id: number;
|
||||
}
|
||||
|
||||
/** `role` narrowed to the three values the column's CHECK constraint allows. */
|
||||
/** `role` and `importance` narrowed to the values the columns' CHECK constraints allow. */
|
||||
type TourWaypointsKyselyDB = Pick<DB, 'places' | 'tours'> & {
|
||||
tour_waypoints: Omit<DB['tour_waypoints'], 'role'> & { role: TourWaypointRow['role'] };
|
||||
tour_waypoints: Omit<DB['tour_waypoints'], 'role' | 'importance'> & {
|
||||
role: TourWaypointRow['role'];
|
||||
importance: TTourWaypointsImportance | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -70,7 +119,7 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
|
||||
async listForPlace(place_id: number): Promise<TourWaypointRow[]> {
|
||||
const rows = await this.db()
|
||||
.selectFrom('tour_waypoints')
|
||||
.select(['lat', 'lng', 'role', 'sequence', 'fixed_leg', 'lock', 'shaping', 'linked_place_id'])
|
||||
.select(WAYPOINT_COLUMNS)
|
||||
.where('place_id', '=', place_id)
|
||||
.orderBy('sequence', 'asc')
|
||||
.execute();
|
||||
@@ -82,17 +131,7 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
|
||||
const rows = await this.db()
|
||||
.selectFrom('tour_waypoints as tw')
|
||||
.innerJoin('places as p', 'p.id', 'tw.place_id')
|
||||
.select([
|
||||
'tw.place_id',
|
||||
'tw.lat',
|
||||
'tw.lng',
|
||||
'tw.role',
|
||||
'tw.sequence',
|
||||
'tw.fixed_leg',
|
||||
'tw.lock',
|
||||
'tw.shaping',
|
||||
'tw.linked_place_id',
|
||||
])
|
||||
.select(['tw.place_id', ...WAYPOINT_COLUMNS.map((column) => `tw.${column}` as const)])
|
||||
.where('p.trip_id', '=', trip_id)
|
||||
.orderBy('tw.place_id', 'asc')
|
||||
.orderBy('tw.sequence', 'asc')
|
||||
@@ -126,6 +165,12 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
|
||||
lock: w.lock ? encodeJson(TOUR_WAYPOINT_LOCK, w.lock) : null,
|
||||
shaping: w.shaping ? 1 : 0,
|
||||
linkedPlace: linkOf(w.linked_place_id),
|
||||
station_key: w.key ?? null,
|
||||
name: w.name ?? null,
|
||||
stay_minutes: w.stay_minutes ?? null,
|
||||
importance: w.importance ?? null,
|
||||
protected: w.protected ? 1 : 0,
|
||||
opening_hours: w.opening_hours ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ export interface TourListRow {
|
||||
revision: number;
|
||||
/** MF-08: a hike's routing preferences, still JSON text. */
|
||||
hiking_preferences: string | null;
|
||||
/** CW-03: the points that are no shaping points, the stations of a city walk. */
|
||||
station_count: number;
|
||||
}
|
||||
|
||||
/** LIB-02: a tour of another trip as the take-over list reads it, with the trip it is in. */
|
||||
@@ -255,6 +257,12 @@ export class ToursRepository extends TrekRepository<Tours> {
|
||||
eb
|
||||
.exists(eb.selectFrom('tour_waypoints as tw').select('tw.id').whereRef('tw.place_id', '=', 'p.id'))
|
||||
.as('has_waypoints'),
|
||||
eb
|
||||
.selectFrom('tour_waypoints as sw')
|
||||
.select((sub) => sub.fn.countAll<number>().as('n'))
|
||||
.whereRef('sw.place_id', '=', 'p.id')
|
||||
.where('sw.shaping', '=', 0)
|
||||
.as('station_count'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -780,10 +788,11 @@ function toLibraryRow<R extends Omit<TourLibraryRow, 'has_waypoints'> & { has_wa
|
||||
}
|
||||
|
||||
function toListRow<
|
||||
R extends Omit<TourListRow, 'planned' | 'has_waypoints' | 'has_recorded_track'> & {
|
||||
R extends Omit<TourListRow, 'planned' | 'has_waypoints' | 'has_recorded_track' | 'station_count'> & {
|
||||
planned: unknown;
|
||||
has_waypoints: unknown;
|
||||
has_recorded_track: unknown;
|
||||
station_count: unknown;
|
||||
},
|
||||
>(r: R): TourListRow {
|
||||
return {
|
||||
@@ -791,5 +800,6 @@ function toListRow<
|
||||
planned: Number(r.planned),
|
||||
has_waypoints: Number(r.has_waypoints),
|
||||
has_recorded_track: Number(r.has_recorded_track),
|
||||
station_count: Number(r.station_count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -378,6 +378,8 @@ export const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
|
||||
slipway: ['leisure=slipway', 'canoe=put_in'],
|
||||
boat_rental: ['amenity=boat_rental'],
|
||||
bike_repair: ['amenity=bicycle_repair_station', 'shop=bicycle'],
|
||||
// A city walk's market (CW-05): the square itself, not the shops of a mall as `shopping` has them.
|
||||
market: ['amenity=marketplace'],
|
||||
};
|
||||
|
||||
export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { tourVisitUpdateRequestSchema } from '@trek/shared';
|
||||
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
|
||||
export class TourVisitUpdateDto extends createZodDto(tourVisitUpdateRequestSchema) {}
|
||||
@@ -32,6 +32,11 @@ const MAPS_CATEGORIES: Record<TourPoiCategory, readonly string[]> = {
|
||||
slipway: ['slipway'],
|
||||
boat_rental: ['boat_rental'],
|
||||
bike_repair: ['bike_repair'],
|
||||
museum: ['museum'],
|
||||
park: ['nature'],
|
||||
cafe: ['cafe'],
|
||||
restaurant: ['restaurant'],
|
||||
market: ['market'],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { RequirePermission, TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { TourVisitUpdateDto } from './dto/tour-visit.dto';
|
||||
import { TourVisitsService } from './tour-visits.service';
|
||||
import { BadRequestException, Body, Controller, Get, Headers, Param, ParseIntPipe, Put, UseGuards } from '@nestjs/common';
|
||||
import { stationKeySchema, tourVisitListResponseSchema } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* CW-03: the stations of a city walk visited or skipped on its days.
|
||||
* GET /api/trips/:tripId/tour-visits reads every one of the trip; PUT
|
||||
* /api/trips/:tripId/tour-visits/:assignmentId/:stationKey marks one, as
|
||||
* someone who may edit the trip's days. Nothing here changes a route.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/tour-visits')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
|
||||
export class TourVisitsController {
|
||||
constructor(private readonly visits: TourVisitsService) {}
|
||||
|
||||
@Get()
|
||||
@ResponseContract(tourVisitListResponseSchema)
|
||||
async list(@Param('tripId') tripId: string) {
|
||||
return await this.visits.list(tripId);
|
||||
}
|
||||
|
||||
@RequirePermission('day_edit')
|
||||
@Put(':assignmentId/:stationKey')
|
||||
@ResponseContract(tourVisitListResponseSchema)
|
||||
async update(
|
||||
@Param('tripId') tripId: string,
|
||||
@Param('assignmentId', ParseIntPipe) assignmentId: number,
|
||||
@Param('stationKey') stationKey: string,
|
||||
@Body() body: TourVisitUpdateDto,
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
if (!stationKeySchema.safeParse(stationKey).success) throw new BadRequestException('Invalid station key');
|
||||
return await this.visits.update(tripId, assignmentId, stationKey, body, socketId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { TourStationVisits } from '../../db/entities/TourStationVisits.entity';
|
||||
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import type { TourStationVisitRow, TourStationVisitsRepository } from '../../db/repositories/TourStationVisits.repository';
|
||||
import type { TourWaypointRow, TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository';
|
||||
import type { ToursRepository } from '../../db/repositories/Tours.repository';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type { TourVisit, TourVisitListResponse, TourVisitUpdateRequest } from '@trek/shared';
|
||||
|
||||
/** A city walk on one of its days: the assignment, the walk and its stations in order. */
|
||||
export interface WalkOnDay {
|
||||
assignmentId: number;
|
||||
dayId: number;
|
||||
placeId: number;
|
||||
stations: TourWaypointRow[];
|
||||
visits: TourStationVisitRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-03: how far a city walk got on one of its days. A visit belongs to the
|
||||
* walk's day assignment and the station's key, so the same walk on another
|
||||
* day, or a copy, starts with nothing visited. Marking one changes no route,
|
||||
* place or order.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourVisitsService {
|
||||
constructor(
|
||||
@InjectRepository(TourStationVisits) private readonly visitsRepo: TourStationVisitsRepository,
|
||||
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
|
||||
@InjectRepository(TourWaypoints) private readonly waypointsRepo: TourWaypointsRepository,
|
||||
private readonly places: PlacesService,
|
||||
) {}
|
||||
|
||||
/** Every visit on the days of a trip, for its day plan. */
|
||||
async list(tripId: string): Promise<TourVisitListResponse> {
|
||||
return { visits: (await this.visitsRepo.listForTrip(Number(tripId))).map(toVisit) };
|
||||
}
|
||||
|
||||
/** A city walk on one day of this trip, with its stations and visits; 404 for anything else. */
|
||||
async walkOnDay(tripId: number | string, assignmentId: number): Promise<WalkOnDay> {
|
||||
const assignment = await this.visitsRepo.assignmentInTrip(assignmentId, Number(tripId));
|
||||
if (!assignment) throw new NotFoundException('Assignment not found');
|
||||
const tour = await this.toursRepo.findInTrip(Number(tripId), assignment.place_id);
|
||||
if (tour?.tour_type !== 'walk') throw new NotFoundException('City walk not found');
|
||||
const points = await this.waypointsRepo.listForPlace(assignment.place_id);
|
||||
return {
|
||||
assignmentId,
|
||||
dayId: assignment.day_id,
|
||||
placeId: assignment.place_id,
|
||||
stations: points.filter((point) => !point.shaping),
|
||||
visits: await this.visitsRepo.listForAssignment(assignmentId),
|
||||
};
|
||||
}
|
||||
|
||||
/** CW-09: a walk on one day, read for an assistant; nothing changes. */
|
||||
async progress(tripId: number, placeId: number, dayId: number): Promise<WalkProgress> {
|
||||
const assignmentId = await this.visitsRepo.assignmentOnDay(tripId, placeId, dayId);
|
||||
if (assignmentId === undefined) throw new NotFoundException('City walk not on this day');
|
||||
const walk = await this.walkOnDay(tripId, assignmentId);
|
||||
const tour = await this.toursRepo.findInTrip(tripId, placeId);
|
||||
const statusOf = new Map(walk.visits.map((visit) => [visit.station_key, visit.status]));
|
||||
const stations: WalkProgressStation[] = walk.stations.map((station, index) => ({
|
||||
position: index + 1,
|
||||
name: station.name ?? null,
|
||||
lat: station.lat,
|
||||
lng: station.lng,
|
||||
status: (station.key && statusOf.get(station.key)) || 'planned',
|
||||
importance: station.importance ?? 'normal',
|
||||
protected: station.protected === true,
|
||||
stay_minutes: station.stay_minutes ?? null,
|
||||
linked_place_id: station.linked_place_id ?? null,
|
||||
}));
|
||||
const open = stations.filter((station) => station.status === 'planned');
|
||||
return {
|
||||
place_id: placeId,
|
||||
day_id: walk.dayId,
|
||||
assignment_id: assignmentId,
|
||||
stations,
|
||||
open: {
|
||||
stations: open.length,
|
||||
stay_minutes_known: open.reduce((sum, station) => sum + (station.stay_minutes ?? 0), 0),
|
||||
stays_unknown: open.filter((station) => station.stay_minutes === null).length,
|
||||
},
|
||||
walking_minutes: tour?.duration ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Marks a station of a walk on one of its days, or takes it back to planned. */
|
||||
async update(
|
||||
tripId: string,
|
||||
assignmentId: number,
|
||||
stationKey: string,
|
||||
body: TourVisitUpdateRequest,
|
||||
socketId?: string,
|
||||
): Promise<TourVisitListResponse> {
|
||||
const walk = await this.walkOnDay(tripId, assignmentId);
|
||||
if (!walk.stations.some((station) => station.key === stationKey)) {
|
||||
throw new NotFoundException('Station not found');
|
||||
}
|
||||
if (body.status === 'planned') await this.visitsRepo.clear(assignmentId, stationKey);
|
||||
else await this.visitsRepo.set(assignmentId, stationKey, body.status);
|
||||
const visits = (await this.visitsRepo.listForAssignment(assignmentId)).map(toVisit);
|
||||
this.places.broadcast(tripId, 'tours:visits', { assignmentId, visits }, socketId);
|
||||
return { visits };
|
||||
}
|
||||
}
|
||||
|
||||
/** CW-09: one station of a walk on a day, as an assistant reads it. */
|
||||
export interface WalkProgressStation {
|
||||
position: number;
|
||||
name: string | null;
|
||||
lat: number;
|
||||
lng: number;
|
||||
status: 'planned' | 'visited' | 'skipped';
|
||||
importance: 'normal' | 'optional' | 'must';
|
||||
protected: boolean;
|
||||
/** Null where the stay is not known; 0 is a station passed through. */
|
||||
stay_minutes: number | null;
|
||||
linked_place_id: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-09: how far a walk got on a day, for an assistant: every station with its
|
||||
* status, what is still open, and the times known, an unknown time left
|
||||
* unknown rather than counted as nothing.
|
||||
*/
|
||||
export interface WalkProgress {
|
||||
place_id: number;
|
||||
day_id: number;
|
||||
assignment_id: number;
|
||||
stations: WalkProgressStation[];
|
||||
open: { stations: number; stay_minutes_known: number; stays_unknown: number };
|
||||
walking_minutes: number | null;
|
||||
}
|
||||
|
||||
function toVisit(row: TourStationVisitRow): TourVisit {
|
||||
return { assignment_id: row.assignment_id, station_key: row.station_key, status: row.status };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { AddonsService } from '../addons/addons.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { TourGpxExportService } from './tour-gpx-export.service';
|
||||
import { TourPoiSearchService } from './tour-poi-search.service';
|
||||
import { TourVisitsService } from './tour-visits.service';
|
||||
import { ToursService } from './tours.service';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { idSchema, tourPoiSearchRequestSchema, type TourListItem, type TourPoiSearchRequest } from '@trek/shared';
|
||||
@@ -36,6 +37,7 @@ export class ToursMcp {
|
||||
private readonly access: TripAccessService,
|
||||
private readonly gpxExport: TourGpxExportService,
|
||||
private readonly poiSearch: TourPoiSearchService,
|
||||
private readonly visits: TourVisitsService,
|
||||
// Read by the addon gate of every tool here.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
@@ -92,6 +94,32 @@ export class ToursMcp {
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_city_walk_progress',
|
||||
description:
|
||||
'Read how far a city walk (tour_type walk) got on one day it is on: every station in order with its status that day (planned, visited or skipped, as the travellers checked them off in the day plan), its importance (normal, optional, must), whether it is protected (a station a proposed change must leave where it is, neither removed, replaced nor moved), its stay in minutes (null when not known, 0 for a point passed through) and its linked place. "open" sums what is still planned: the stations, their known stays and how many stays are unknown; walking_minutes is the walking time of the whole walk. Visits belong to that day only: the same walk on another day, or a copy, has none. Anything the traveller tells you besides (for instance "continue from 2 pm") is theirs to say, not stored here. Reading changes nothing.',
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
placeId: idSchema.describe('The place id of the city walk, as list_tours gives it'),
|
||||
dayId: idSchema.describe('The day the walk is on, as find_tours_for_day gives it'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async getCityWalkProgress(
|
||||
{ tripId, placeId, dayId }: { tripId: number; placeId: number; dayId: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
try {
|
||||
return ok(await this.visits.progress(tripId, placeId, dayId));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof NotFoundException) return errorResult('No city walk of this trip is on that day.');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'find_tours_for_day',
|
||||
description: 'List the saved tours put on one day of a trip, with the same facts as list_tours.',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { TourStationVisits } from '../../db/entities/TourStationVisits.entity';
|
||||
import { TourObstacleReviews } from '../../db/entities/TourObstacleReviews.entity';
|
||||
import { TourSources } from '../../db/entities/TourSources.entity';
|
||||
import { TourTypes } from '../../db/entities/TourTypes.entity';
|
||||
@@ -26,6 +27,8 @@ import { TourPoiSearchController } from './tour-poi-search.controller';
|
||||
import { TourPoiSearchService } from './tour-poi-search.service';
|
||||
import { TourTrailGradeController } from './tour-trail-grade.controller';
|
||||
import { TourTrailGradeService } from './tour-trail-grade.service';
|
||||
import { TourVisitsController } from './tour-visits.controller';
|
||||
import { TourVisitsService } from './tour-visits.service';
|
||||
import { ToursImportController } from './tours-import.controller';
|
||||
import { ToursWandererController } from './tours-wanderer.controller';
|
||||
import { ToursWandererService } from './tours-wanderer.service';
|
||||
@@ -59,12 +62,13 @@ import { Module } from '@nestjs/common';
|
||||
WandererModule,
|
||||
// The obstacle data a paddle tour's checklist reads (PAD-06, PAD-09).
|
||||
PaddleObstaclesModule,
|
||||
MikroOrmModule.forFeature([Tours, TourTypes, TourWaypoints, TourSources, TourObstacleReviews, Places]),
|
||||
MikroOrmModule.forFeature([Tours, TourTypes, TourWaypoints, TourSources, TourObstacleReviews, TourStationVisits, Places]),
|
||||
],
|
||||
controllers: [
|
||||
TourTakeOverController,
|
||||
TourPoiSearchController,
|
||||
TourTrailGradeController,
|
||||
TourVisitsController,
|
||||
ToursController,
|
||||
ToursImportController,
|
||||
ToursWandererController,
|
||||
@@ -81,6 +85,7 @@ import { Module } from '@nestjs/common';
|
||||
TourLibraryService,
|
||||
TourPoiSearchService,
|
||||
TourTrailGradeService,
|
||||
TourVisitsService,
|
||||
ToursMcp,
|
||||
TourLibraryMcp,
|
||||
],
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Places } from '../../db/entities/Places.entity';
|
||||
import { TourTypes } from '../../db/entities/TourTypes.entity';
|
||||
import { TourStationVisits } from '../../db/entities/TourStationVisits.entity';
|
||||
import { TourWaypoints } from '../../db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import { TOUR_HIKING_PREFERENCES, TOUR_TRACK_MARKS } from '../../db/json-columns';
|
||||
import type { PlacesRepository } from '../../db/repositories/Places.repository';
|
||||
import type { TourTypesRepository } from '../../db/repositories/TourTypes.repository';
|
||||
import type { TourStationVisitsRepository } from '../../db/repositories/TourStationVisits.repository';
|
||||
import type { TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository';
|
||||
import type {
|
||||
TourDayRow,
|
||||
@@ -75,6 +78,14 @@ function hikingFields(input: TourCreateRequest): string | null {
|
||||
return preferences ? encodeJson(TOUR_HIKING_PREFERENCES, preferences) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* CW-03: every point with its key, kept from the write where it came with one,
|
||||
* so the visits of a city walk's stations outlast a reorder or a new point.
|
||||
*/
|
||||
function keyedWaypoints(waypoints: TourWaypoint[]): TourWaypoint[] {
|
||||
return waypoints.map((point) => (point.key ? point : { ...point, key: randomUUID() }));
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ToursService {
|
||||
private readonly logger = new Logger(ToursService.name);
|
||||
@@ -85,6 +96,7 @@ export class ToursService {
|
||||
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
|
||||
@InjectRepository(TourTypes) private readonly tourTypesRepo: TourTypesRepository,
|
||||
@InjectRepository(TourWaypoints) private readonly waypointsRepo: TourWaypointsRepository,
|
||||
@InjectRepository(TourStationVisits) private readonly visitsRepo: TourStationVisitsRepository,
|
||||
@InjectRepository(Places) private readonly placesRepo: PlacesRepository,
|
||||
private readonly placeImport: PlaceImportService,
|
||||
private readonly paddleChecks: TourPaddleCheckService,
|
||||
@@ -122,6 +134,7 @@ export class ToursService {
|
||||
has_waypoints: Boolean(r.has_waypoints),
|
||||
revision: r.revision,
|
||||
hiking_preferences: decodeJson(TOUR_HIKING_PREFERENCES, r.hiking_preferences, 'tour'),
|
||||
station_count: r.tour_type === 'walk' ? r.station_count : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,7 +324,7 @@ export class ToursService {
|
||||
break_additional_minutes: fields.break_additional_minutes ?? null,
|
||||
derived_from_place_id: derivedFrom,
|
||||
});
|
||||
await this.waypointsRepo.insertForPlace(id, await this.linkedWaypoints(tid, input.waypoints));
|
||||
await this.waypointsRepo.insertForPlace(id, keyedWaypoints(await this.linkedWaypoints(tid, input.waypoints)));
|
||||
return id;
|
||||
});
|
||||
|
||||
@@ -573,7 +586,11 @@ export class ToursService {
|
||||
if (!placeUpdated) throw new NotFoundException('Tour not found');
|
||||
if (!(await this.toursRepo.updateInTrip(tid, pid, fields))) throw new NotFoundException('Tour not found');
|
||||
await this.waypointsRepo.deleteForPlace(pid);
|
||||
await this.waypointsRepo.insertForPlace(pid, await this.linkedWaypoints(tid, input.waypoints));
|
||||
const waypoints = keyedWaypoints(await this.linkedWaypoints(tid, input.waypoints));
|
||||
await this.waypointsRepo.insertForPlace(pid, waypoints);
|
||||
// CW-03: a station that is gone takes its visits with it; a tour that is no city walk keeps none.
|
||||
const stations = input.tour_type === 'walk' ? waypoints.flatMap((point) => (point.key ? [point.key] : [])) : [];
|
||||
await this.visitsRepo.dropStationsNotIn(pid, stations);
|
||||
});
|
||||
|
||||
const result = await this.getTour(tripId, placeId);
|
||||
@@ -710,6 +727,7 @@ export class ToursService {
|
||||
has_waypoints: 0,
|
||||
revision: 1,
|
||||
hiking_preferences: null,
|
||||
station_count: 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ const UNNUMBERED = [
|
||||
'Migration20200101045000_a_hike_keeps_its_routing_preferences',
|
||||
'Migration20200101045100_a_tour_remembers_the_trip_it_was_taken_from',
|
||||
'Migration20200101045200_a_tour_can_be_ridden',
|
||||
'Migration20200101045300_a_tour_can_be_a_city_walk',
|
||||
];
|
||||
// The last step the positional runner ever had; planLegacyBaseline refuses a
|
||||
// schema_version past it.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user