feat(tours): route paddle tours on the water, shaping points and auto-loaded regions

Paddle tours are planned on the water by TREK's own router: a water network
per paddle region, built from the same Geofabrik extract as the obstacles and
searched in a worker thread of the server (no sidecar). Rivers, canals and
lakes (direct or along the shore), portages round weirs, locks by policy,
whitewater by boat, gaps marked. Found portages and locks become points of
their own; an unplannable leg says why and offers a way out.

- Shaping points: a click on the line sets a point that steers the route
  without being a stop (hikes and paddle tours); recompute a single leg.
- Lake style direct/shore, suggested from the wind forecast.
- Regions load by themselves where a paddler plans (admin setting: automatic,
  on request, off; size cap and a daily count), with requests for the admins,
  a notification, and the load followed on the leg.
- Portage from the last point, portages follow footways via the walking
  router (or stay straight), "put in here" on slipways and put-ins.
- MCP plan_paddle_route with the REST route's rules.
This commit is contained in:
Maurice
2026-10-18 01:13:24 +02:00
parent b76a298bb3
commit 1bc82f97fd
321 changed files with 14675 additions and 471 deletions
@@ -0,0 +1,37 @@
import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core';
import { PaddleRegionRequestsRepository } from '../repositories/PaddleRegionRequests.repository';
import { DbTimestampType } from '../types';
export class PaddleRegionRequests {
[EntityRepositoryType]?: PaddleRegionRequestsRepository;
[PrimaryKeyProp]?: 'region_id';
region_id!: string;
name!: string;
reason!: TPaddleRegionRequestsReason;
bytes?: number | null;
requested_by?: string | null;
requested_at!: string & Opt;
declined_at?: string | null;
}
export const PaddleRegionRequestsReason = {
ASK: 'ask',
TOO_LARGE: 'too_large',
LIMIT: 'limit',
} as const;
export type TPaddleRegionRequestsReason = (typeof PaddleRegionRequestsReason)[keyof typeof PaddleRegionRequestsReason];
export const PaddleRegionRequestsSchema = defineEntity({
class: PaddleRegionRequests,
repository: () => PaddleRegionRequestsRepository,
properties: {
region_id: p.text().primary(),
name: p.text(),
reason: p.enum(() => PaddleRegionRequestsReason),
bytes: p.integer().nullable(),
requested_by: p.text().nullable(),
requested_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
declined_at: p.type(DbTimestampType).nullable(),
},
});
+13 -1
View File
@@ -1,7 +1,8 @@
import { Collection, EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core';
import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
import { PaddleRegionsRepository } from '../repositories/PaddleRegions.repository';
import { DbTimestampType } from '../types';
import { PaddleFeatures } from './PaddleFeatures.entity';
import { PaddleWaterGraphs } from './PaddleWaterGraphs.entity';
export class PaddleRegions {
[EntityRepositoryType]?: PaddleRegionsRepository;
@@ -18,7 +19,9 @@ export class PaddleRegions {
feature_count: number & Opt = 0;
error?: string | null;
created_at?: string | null;
origin: TPaddleRegionsOrigin & Opt = PaddleRegionsOrigin.ADMIN;
paddle_features_collection = new Collection<PaddleFeatures>(this);
paddle_water_graphs: Ref<PaddleWaterGraphs> | null = null;
}
export const PaddleRegionsStatus = {
@@ -30,6 +33,13 @@ export const PaddleRegionsStatus = {
export type TPaddleRegionsStatus = (typeof PaddleRegionsStatus)[keyof typeof PaddleRegionsStatus];
export const PaddleRegionsOrigin = {
ADMIN: 'admin',
AUTO: 'auto',
} as const;
export type TPaddleRegionsOrigin = (typeof PaddleRegionsOrigin)[keyof typeof PaddleRegionsOrigin];
export const PaddleRegionsSchema = defineEntity({
class: PaddleRegions,
repository: () => PaddleRegionsRepository,
@@ -47,6 +57,8 @@ export const PaddleRegionsSchema = defineEntity({
feature_count: p.integer().default(0),
error: p.text().nullable(),
created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`),
origin: p.enum(() => PaddleRegionsOrigin).default('admin'),
paddle_features_collection: () => p.oneToMany(PaddleFeatures).mappedBy('region').hidden(),
paddle_water_graphs: () => p.oneToOne(PaddleWaterGraphs).ref().mappedBy('region').hidden(),
},
});
@@ -0,0 +1,45 @@
import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { PaddleWaterGraphsRepository } from '../repositories/PaddleWaterGraphs.repository';
import { DbTimestampType } from '../types';
import { PaddleRegions } from './PaddleRegions.entity';
export class PaddleWaterGraphs {
[EntityRepositoryType]?: PaddleWaterGraphsRepository;
[PrimaryKeyProp]?: 'region';
region?: Ref<PaddleRegions> | null;
region_id?: string | null;
file!: string;
rev!: string;
bytes!: number;
built_at!: string;
stats!: string;
min_lat!: number;
min_lng!: number;
max_lat!: number;
max_lng!: number;
}
export const PaddleWaterGraphsSchema = defineEntity({
class: PaddleWaterGraphs,
repository: () => PaddleWaterGraphsRepository,
indexes: [
{
name: 'idx_paddle_water_graphs_box',
properties: ['min_lat', 'max_lat', 'min_lng', 'max_lng'],
},
],
uniques: [{ properties: ['region'] }],
properties: {
region: () => p.oneToOne(PaddleRegions).primary().ref().deleteRule('cascade').nullable().hidden(),
region_id: p.text().nullable().persist(false),
file: p.text(),
rev: p.text(),
bytes: p.integer(),
built_at: p.type(DbTimestampType),
stats: p.text(),
min_lat: p.double(),
min_lng: p.double(),
max_lat: p.double(),
max_lng: p.double(),
},
});
@@ -13,12 +13,16 @@ export class TourWaypoints {
sequence!: number;
fixed_leg?: string | null;
lock?: string | null;
shaping: number & Opt = 0;
}
export const TourWaypointsSchema = defineEntity({
class: TourWaypoints,
repository: () => TourWaypointsRepository,
uniques: [{ properties: ['place', 'sequence'] }],
checks: [
{ name: 'tour_waypoints_shaping_check', expression: 'shaping IN (0, 1)' },
],
properties: {
id: p.integer().primary(),
place: () => p.manyToOne(Tours).ref().name('place_id').deleteRule('cascade').hidden(),
@@ -29,5 +33,6 @@ export const TourWaypointsSchema = defineEntity({
sequence: p.integer(),
fixed_leg: p.text().nullable(),
lock: p.text().nullable(),
shaping: p.integer().default(0),
},
});
+9
View File
@@ -33,6 +33,7 @@ export class Tours {
paddle_speed_kmh?: number | null;
portage_speed_kmh?: number | null;
track_marks?: string | null;
paddle_lake_style?: TToursPaddleLakeStyle | null;
tourTypeRef!: Ref<TourTypes>;
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
tour_sources: Ref<TourSources> | null = null;
@@ -63,6 +64,13 @@ export const ToursWaterContext = {
export type TToursWaterContext = (typeof ToursWaterContext)[keyof typeof ToursWaterContext];
export const ToursPaddleLakeStyle = {
DIRECT: 'direct',
SHORE: 'shore',
} as const;
export type TToursPaddleLakeStyle = (typeof ToursPaddleLakeStyle)[keyof typeof ToursPaddleLakeStyle];
export const ToursSchema = defineEntity({
class: Tours,
repository: () => ToursRepository,
@@ -98,6 +106,7 @@ export const ToursSchema = defineEntity({
paddle_speed_kmh: p.double().nullable(),
portage_speed_kmh: p.double().nullable(),
track_marks: p.text().nullable(),
paddle_lake_style: p.enum(() => ToursPaddleLakeStyle).nullable(),
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(),
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
+4
View File
@@ -62,7 +62,9 @@ import { PackingTemplateCategoriesSchema } from './PackingTemplateCategories.ent
import { PackingTemplateItemsSchema } from './PackingTemplateItems.entity';
import { PackingTemplatesSchema } from './PackingTemplates.entity';
import { PaddleFeaturesSchema } from './PaddleFeatures.entity';
import { PaddleRegionRequestsSchema } from './PaddleRegionRequests.entity';
import { PaddleRegionsSchema } from './PaddleRegions.entity';
import { PaddleWaterGraphsSchema } from './PaddleWaterGraphs.entity';
import { PasswordResetTokensSchema } from './PasswordResetTokens.entity';
import { PhotoProviderFieldsSchema } from './PhotoProviderFields.entity';
import { PhotoProvidersSchema } from './PhotoProviders.entity';
@@ -204,7 +206,9 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
PackingTemplateItemsSchema,
PackingTemplatesSchema,
PaddleFeaturesSchema,
PaddleRegionRequestsSchema,
PaddleRegionsSchema,
PaddleWaterGraphsSchema,
PasswordResetTokensSchema,
PhotoProviderFieldsSchema,
PhotoProvidersSchema,
+9
View File
@@ -6,6 +6,7 @@ import {
paddleGeometrySchema,
paddleLockSchema,
paddleRegionBoundarySchema,
paddleRegionGraphStatsSchema,
tourFixedLegSchema,
trackMarksSchema,
type Ferrata,
@@ -13,6 +14,7 @@ import {
type PaddleGeometry,
type PaddleLock,
type PaddleRegionBoundary,
type PaddleRegionGraphStats,
type TourFixedLeg,
type TrackMarks,
} from '@trek/shared';
@@ -194,6 +196,13 @@ export const PADDLE_REGION_BOUNDARY: JsonColumn<PaddleRegionBoundary | null> = {
fallback: () => null,
};
/** What a paddle region's water network holds (PAD-11). A broken row has none, and the region shows no network figures. */
export const PADDLE_REGION_GRAPH_STATS: JsonColumn<PaddleRegionGraphStats | null> = {
column: 'paddle_water_graphs.stats',
schema: paddleRegionGraphStatsSchema.nullable(),
fallback: () => null,
};
/** What a paddle tour's obstacle decision keeps of its obstacle (PAD-06). A broken row has none, and the decision is left out. */
export const TOUR_OBSTACLE_SNAPSHOT: JsonColumn<ObstacleSnapshot | null> = {
column: 'tour_obstacle_reviews.obstacle_snapshot',
@@ -0,0 +1,13 @@
// 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 PaddleRegionRequestsTable {
region_id: string;
name: string;
reason: string;
bytes: InsertOptional<number | null>;
requested_by: InsertOptional<string | null>;
requested_at: InsertOptional<string>;
declined_at: InsertOptional<string | null>;
}
@@ -16,4 +16,5 @@ export interface PaddleRegionsTable {
feature_count: InsertOptional<number>;
error: InsertOptional<string | null>;
created_at: InsertOptional<string | null>;
origin: InsertOptional<string>;
}
@@ -0,0 +1,15 @@
// 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`.
export interface PaddleWaterGraphsTable {
region_id: string;
file: string;
rev: string;
bytes: number;
built_at: string;
stats: string;
min_lat: number;
min_lng: number;
max_lat: number;
max_lng: number;
}
@@ -11,4 +11,5 @@ export interface TourWaypointsTable {
sequence: number;
fixed_leg: InsertOptional<string | null>;
lock: InsertOptional<string | null>;
shaping: InsertOptional<number>;
}
+1
View File
@@ -25,4 +25,5 @@ export interface ToursTable {
paddle_speed_kmh: InsertOptional<number | null>;
portage_speed_kmh: InsertOptional<number | null>;
track_marks: InsertOptional<string | null>;
paddle_lake_style: InsertOptional<string | null>;
}
+4
View File
@@ -67,7 +67,9 @@ import type { PackingTemplateCategoriesTable } from './PackingTemplateCategories
import type { PackingTemplateItemsTable } from './PackingTemplateItems.table';
import type { PackingTemplatesTable } from './PackingTemplates.table';
import type { PaddleFeaturesTable } from './PaddleFeatures.table';
import type { PaddleRegionRequestsTable } from './PaddleRegionRequests.table';
import type { PaddleRegionsTable } from './PaddleRegions.table';
import type { PaddleWaterGraphsTable } from './PaddleWaterGraphs.table';
import type { PasswordResetTokensTable } from './PasswordResetTokens.table';
import type { PhotoProviderFieldsTable } from './PhotoProviderFields.table';
import type { PhotoProvidersTable } from './PhotoProviders.table';
@@ -215,7 +217,9 @@ export interface DB {
packing_template_items: PackingTemplateItemsTable;
packing_templates: PackingTemplatesTable;
paddle_features: PaddleFeaturesTable;
paddle_region_requests: PaddleRegionRequestsTable;
paddle_regions: PaddleRegionsTable;
paddle_water_graphs: PaddleWaterGraphsTable;
password_reset_tokens: PasswordResetTokensTable;
photo_provider_fields: PhotoProviderFieldsTable;
photo_providers: PhotoProvidersTable;
@@ -0,0 +1,34 @@
import { Migration } from '@mikro-orm/migrations';
/**
* PAD-11: the water network a paddle region builds for routing, from the same
* read as its obstacles. The network itself is a file in the data folder
* (`file`, named after its revision `rev`, the first hex digits of its
* hash); the row says where it is, how big it came out, when it was built,
* what it holds (`stats`, JSON: kilometres of water, lakes, portages, gaps,
* locks) and the box it covers, so a point finds its network without reading
* the region. One row per region, gone with the region.
*/
export class Migration20200101044400_a_paddle_region_builds_its_water_network extends Migration {
override name = 'Migration20200101044400_a_paddle_region_builds_its_water_network';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS paddle_water_graphs (
region_id TEXT PRIMARY KEY REFERENCES paddle_regions(id) ON DELETE CASCADE,
file TEXT NOT NULL,
rev TEXT NOT NULL,
bytes INTEGER NOT NULL,
built_at DATETIME NOT NULL,
stats TEXT NOT NULL,
min_lat REAL NOT NULL,
min_lng REAL NOT NULL,
max_lat REAL NOT NULL,
max_lng REAL NOT NULL
)
`);
this.addSql(
`CREATE INDEX IF NOT EXISTS idx_paddle_water_graphs_box ON paddle_water_graphs (min_lat, max_lat, min_lng, max_lng)`,
);
}
}
@@ -0,0 +1,14 @@
import { Migration } from '@mikro-orm/migrations';
/**
* P2A-03: a waypoint can be a shaping point, there only to steer the route:
* no stop, no place to get in or out, nothing a day plan shows. 0 or 1;
* every waypoint saved before is a stop, as it was.
*/
export class Migration20200101044500_a_tour_waypoint_can_shape_the_route extends Migration {
override name = 'Migration20200101044500_a_tour_waypoint_can_shape_the_route';
override up(): void {
this.addSql(`ALTER TABLE tour_waypoints ADD COLUMN shaping INTEGER NOT NULL DEFAULT 0 CHECK (shaping IN (0, 1))`);
}
}
@@ -0,0 +1,16 @@
import { Migration } from '@mikro-orm/migrations';
/**
* PAD-11: how a paddle tour crosses its lakes, as the router planned it:
* `direct` (straight over, also what NULL means) or `shore` (along the
* shore, for wind). Kept with the tour so it routes the same when opened again.
*/
export class Migration20200101044600_a_paddle_tour_remembers_how_it_crosses_lakes extends Migration {
override name = 'Migration20200101044600_a_paddle_tour_remembers_how_it_crosses_lakes';
override up(): void {
this.addSql(
`ALTER TABLE tours ADD COLUMN paddle_lake_style TEXT CHECK (paddle_lake_style IS NULL OR paddle_lake_style IN ('direct', 'shore'))`,
);
}
}
@@ -0,0 +1,35 @@
import { Migration } from '@mikro-orm/migrations';
/**
* PAD-11C: TREK fetches the water data of a place a paddler plans in by
* itself. `paddle_regions.origin` says who added a region: an admin by hand
* (`admin`, every region before this) or TREK for a paddler (`auto`), which
* also counts against the few a day it may add.
*
* `paddle_region_requests` holds a region that waits for an admin instead:
* the mode asks for it, it is larger than allowed or the day's few are used
* up (`reason`). It keeps the size Geofabrik announced, the name of whoever
* asked (as it read then; no link to the account, a request outlives it) and
* when, and whether an admin declined it (then it is not asked for again). A
* request goes once its region is added.
*/
export class Migration20200101044700_paddle_water_data_loads_where_a_paddler_plans extends Migration {
override name = 'Migration20200101044700_paddle_water_data_loads_where_a_paddler_plans';
override up(): void {
this.addSql(
`ALTER TABLE paddle_regions ADD COLUMN origin TEXT NOT NULL DEFAULT 'admin' CHECK (origin IN ('admin', 'auto'))`,
);
this.addSql(`
CREATE TABLE IF NOT EXISTS paddle_region_requests (
region_id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
reason TEXT NOT NULL CHECK (reason IN ('ask', 'too_large', 'limit')),
bytes INTEGER,
requested_by TEXT,
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
declined_at DATETIME
)
`);
}
}
@@ -0,0 +1,80 @@
import type { PaddleRegionRequests, TPaddleRegionRequestsReason } from '../entities/PaddleRegionRequests.entity';
import type { DB } from '../kysely/db';
import { dbNow } from '../types';
import { TrekRepository } from './_shared/trek-repository';
/** A region a paddler's planning asked for, as stored. */
export interface PaddleRegionRequestRow {
region_id: string;
name: string;
reason: TPaddleRegionRequestsReason;
bytes: number | null;
requested_by: string | null;
requested_at: string;
declined_at: string | null;
}
/** `reason` narrowed to the three values the column's CHECK constraint allows. */
type RequestsDB = {
paddle_region_requests: Omit<DB['paddle_region_requests'], 'reason'> & { reason: TPaddleRegionRequestsReason };
};
const COLUMNS = ['region_id', 'name', 'reason', 'bytes', 'requested_by', 'requested_at', 'declined_at'] as const;
/** `paddle_region_requests`: regions that wait for an admin before TREK loads them (PAD-11C). */
export class PaddleRegionRequestsRepository extends TrekRepository<PaddleRegionRequests> {
private db() {
return this.kysely<RequestsDB>();
}
/** Every request, the newest first. */
async list(): Promise<PaddleRegionRequestRow[]> {
return await this.db()
.selectFrom('paddle_region_requests')
.select(COLUMNS)
.orderBy('requested_at', 'desc')
.orderBy('region_id', 'asc')
.execute();
}
async findByRegion(regionId: string): Promise<PaddleRegionRequestRow | undefined> {
return await this.db()
.selectFrom('paddle_region_requests')
.select(COLUMNS)
.where('region_id', '=', regionId)
.executeTakeFirst();
}
/** Files a request; one already there, declined or not, stays as it is. */
async file(row: {
region_id: string;
name: string;
reason: TPaddleRegionRequestsReason;
bytes: number | null;
requested_by: string;
}): Promise<boolean> {
const result = await this.db()
.insertInto('paddle_region_requests')
.values({ ...row, requested_at: dbNow() })
.onConflict((oc) => oc.column('region_id').doNothing())
.executeTakeFirst();
return Number(result.numInsertedOrUpdatedRows ?? 0) > 0;
}
async decline(regionId: string): Promise<boolean> {
const result = await this.db()
.updateTable('paddle_region_requests')
.set({ declined_at: dbNow() })
.where('region_id', '=', regionId)
.executeTakeFirst();
return Number(result.numUpdatedRows ?? 0) > 0;
}
async remove(regionId: string): Promise<boolean> {
const result = await this.db()
.deleteFrom('paddle_region_requests')
.where('region_id', '=', regionId)
.executeTakeFirst();
return Number(result.numDeletedRows ?? 0) > 0;
}
}
@@ -1,4 +1,10 @@
import { type PaddleRegions, PaddleRegionsStatus, type TPaddleRegionsStatus } from '../entities/PaddleRegions.entity';
import {
type PaddleRegions,
PaddleRegionsOrigin,
PaddleRegionsStatus,
type TPaddleRegionsOrigin,
type TPaddleRegionsStatus,
} from '../entities/PaddleRegions.entity';
import type { DB } from '../kysely/db';
import type { Box } from './PaddleFeatures.repository';
import { TrekRepository } from './_shared/trek-repository';
@@ -17,11 +23,15 @@ export interface PaddleRegionRow {
imported_at: string | null;
feature_count: number;
error: string | null;
origin: TPaddleRegionsOrigin;
}
/** `status` narrowed to the four values the column's CHECK constraint allows. */
/** `status` and `origin` narrowed to the values the columns' CHECK constraints allow. */
type RegionsDB = {
paddle_regions: Omit<DB['paddle_regions'], 'status'> & { status: TPaddleRegionsStatus };
paddle_regions: Omit<DB['paddle_regions'], 'status' | 'origin'> & {
status: TPaddleRegionsStatus;
origin: TPaddleRegionsOrigin;
};
};
const COLUMNS = [
@@ -37,6 +47,7 @@ const COLUMNS = [
'imported_at',
'feature_count',
'error',
'origin',
] as const;
/** `paddle_regions`: the regional extracts an admin chose for paddle data (PAD-05A). */
@@ -76,6 +87,40 @@ export class PaddleRegionsRepository extends TrekRepository<PaddleRegions> {
.execute();
}
/** Every region whose box holds the point, whatever its state; the caller checks the boundary. */
async aroundPoint(lat: number, lng: number): Promise<PaddleRegionRow[]> {
return await this.db()
.selectFrom('paddle_regions')
.select(COLUMNS)
.where('min_lat', '<=', lat)
.where('max_lat', '>=', lat)
.where('min_lng', '<=', lng)
.where('max_lng', '>=', lng)
.orderBy('id', 'asc')
.execute();
}
/** The regions waiting for their read, oldest first: the order they are read in. */
async queued(): Promise<Array<Pick<PaddleRegionRow, 'id'>>> {
return await this.db()
.selectFrom('paddle_regions')
.select('id')
.where('status', '=', PaddleRegionsStatus.QUEUED)
.orderBy('created_at', 'asc')
.execute();
}
/** How many regions TREK added by itself since `since` (a canonical UTC timestamp). */
async countAddedAutomaticallySince(since: string): Promise<number> {
const row = await this.db()
.selectFrom('paddle_regions')
.select((eb) => eb.fn.countAll<number>().as('count'))
.where('origin', '=', PaddleRegionsOrigin.AUTO)
.where('created_at', '>=', since)
.executeTakeFirst();
return Number(row?.count ?? 0);
}
/** The first region waiting for its read, oldest first. */
async nextQueued(): Promise<PaddleRegionRow | undefined> {
return await this.db()
@@ -88,10 +133,11 @@ export class PaddleRegionsRepository extends TrekRepository<PaddleRegions> {
async insertRegion(
row: Pick<PaddleRegionRow, 'id' | 'name' | 'boundary' | 'min_lat' | 'min_lng' | 'max_lat' | 'max_lng'>,
origin: TPaddleRegionsOrigin = PaddleRegionsOrigin.ADMIN,
): Promise<void> {
await this.db()
.insertInto('paddle_regions')
.values({ ...row, status: PaddleRegionsStatus.QUEUED, feature_count: 0 })
.values({ ...row, status: PaddleRegionsStatus.QUEUED, feature_count: 0, origin })
.onConflict((oc) => oc.column('id').doNothing())
.execute();
}
@@ -0,0 +1,86 @@
import type { PaddleWaterGraphs } from '../entities/PaddleWaterGraphs.entity';
import type { DB } from '../kysely/db';
import type { DbTimestamp } from '../types';
import { TrekRepository } from './_shared/trek-repository';
/** A region's water network as stored: where its file is, what it holds (`stats` still JSON text), the box it covers. */
export interface PaddleWaterGraphRow {
region_id: string;
file: string;
rev: string;
bytes: number;
built_at: string;
stats: string;
min_lat: number;
min_lng: number;
max_lat: number;
max_lng: number;
}
const COLUMNS = [
'region_id',
'file',
'rev',
'bytes',
'built_at',
'stats',
'min_lat',
'min_lng',
'max_lat',
'max_lng',
] as const;
/** `paddle_water_graphs`: the water network each paddle region built for routing (PAD-11). */
export class PaddleWaterGraphsRepository extends TrekRepository<PaddleWaterGraphs> {
private db() {
return this.kysely<Pick<DB, 'paddle_water_graphs'>>();
}
async list(): Promise<PaddleWaterGraphRow[]> {
return await this.db().selectFrom('paddle_water_graphs').select(COLUMNS).orderBy('region_id', 'asc').execute();
}
async findByRegion(regionId: string): Promise<PaddleWaterGraphRow | undefined> {
return await this.db()
.selectFrom('paddle_water_graphs')
.select(COLUMNS)
.where('region_id', '=', regionId)
.executeTakeFirst();
}
/** The networks whose box holds the point, smallest box first: the most local data plans the leg. */
async coveringPoint(lat: number, lng: number): Promise<PaddleWaterGraphRow[]> {
const rows = await this.db()
.selectFrom('paddle_water_graphs')
.select(COLUMNS)
.where('min_lat', '<=', lat)
.where('max_lat', '>=', lat)
.where('min_lng', '<=', lng)
.where('max_lng', '>=', lng)
.orderBy('region_id', 'asc')
.execute();
const area = (row: PaddleWaterGraphRow) => (row.max_lat - row.min_lat) * (row.max_lng - row.min_lng);
return rows.sort((a, b) => area(a) - area(b));
}
/** Records a region's new network, replacing the one it had. */
async saveGraph(row: Omit<PaddleWaterGraphRow, 'built_at'> & { built_at: DbTimestamp }): Promise<void> {
await this.db()
.insertInto('paddle_water_graphs')
.values(row)
.onConflict((oc) =>
oc.column('region_id').doUpdateSet({
file: row.file,
rev: row.rev,
bytes: row.bytes,
built_at: row.built_at,
stats: row.stats,
min_lat: row.min_lat,
min_lng: row.min_lng,
max_lat: row.max_lat,
max_lng: row.max_lng,
}),
)
.execute();
}
}
@@ -15,6 +15,8 @@ export interface TourWaypointRow {
fixed_leg?: TourFixedLeg | null;
/** A lock at this point of a paddle tour; absent where there is none. */
lock?: PaddleLock | null;
/** P2A-03: a shaping point, steering the route only; absent for a stop. */
shaping?: boolean;
}
/** The row as stored, the fixed leg still JSON text. */
@@ -25,13 +27,19 @@ interface StoredWaypoint {
sequence: number;
fixed_leg: string | null;
lock: string | null;
shaping: number;
}
/** A stored row as callers read it: the fixed leg and the lock decoded, each left out entirely where there is none. */
function readWaypoint({ fixed_leg, lock, ...point }: StoredWaypoint): TourWaypointRow {
/** 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, ...point }: StoredWaypoint): TourWaypointRow {
const leg = decodeJson(TOUR_WAYPOINT_FIXED_LEG, fixed_leg, 'tour waypoint');
const stop = decodeJson(TOUR_WAYPOINT_LOCK, lock, 'tour waypoint');
return { ...point, ...(leg ? { fixed_leg: leg } : {}), ...(stop ? { lock: stop } : {}) };
return {
...point,
...(leg ? { fixed_leg: leg } : {}),
...(stop ? { lock: stop } : {}),
...(shaping ? { shaping: true } : {}),
};
}
/** {@link TourWaypointRow} plus the tour it belongs to, as the trip copy reads it. */
@@ -54,11 +62,11 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
return this.kysely<TourWaypointsKyselyDB>();
}
/** TW1 — `SELECT lat, lng, role, sequence, fixed_leg, lock FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */
/** TW1 — `SELECT lat, lng, role, sequence, fixed_leg, lock, shaping FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */
async listForPlace(place_id: number): Promise<TourWaypointRow[]> {
const rows = await this.db()
.selectFrom('tour_waypoints')
.select(['lat', 'lng', 'role', 'sequence', 'fixed_leg', 'lock'])
.select(['lat', 'lng', 'role', 'sequence', 'fixed_leg', 'lock', 'shaping'])
.where('place_id', '=', place_id)
.orderBy('sequence', 'asc')
.execute();
@@ -70,7 +78,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'])
.select(['tw.place_id', 'tw.lat', 'tw.lng', 'tw.role', 'tw.sequence', 'tw.fixed_leg', 'tw.lock', 'tw.shaping'])
.where('p.trip_id', '=', trip_id)
.orderBy('tw.place_id', 'asc')
.orderBy('tw.sequence', 'asc')
@@ -79,7 +87,7 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
}
/**
* TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence, fixed_leg, lock)
* TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence, fixed_leg, lock, shaping)
* VALUES (...)` for each point. The caller owns the transaction: a create
* writes the tour first, an edit clears the old points first.
*/
@@ -94,6 +102,7 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
sequence: w.sequence,
fixed_leg: w.fixed_leg ? encodeJson(TOUR_WAYPOINT_FIXED_LEG, w.fixed_leg) : null,
lock: w.lock ? encodeJson(TOUR_WAYPOINT_LOCK, w.lock) : null,
shaping: w.shaping ? 1 : 0,
})),
);
}
@@ -2,7 +2,7 @@ import { type Tours, type TToursOrigin, ToursOrigin } from '../entities/Tours.en
import type { DB } from '../kysely/db';
import type { AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { PaddleBoatType, PaddleWaterContext } from '@trek/shared';
import type { PaddleBoatType, PaddleLakeStyle, PaddleWaterContext } from '@trek/shared';
/**
* A tour as the Tours list and detail read it: the `tours` facet joined with
@@ -32,6 +32,7 @@ export interface TourListRow {
water_context: string | null;
paddle_speed_kmh: number | null;
portage_speed_kmh: number | null;
paddle_lake_style: string | null;
/** The marks laid onto a recorded track, still JSON text. */
track_marks: string | null;
has_recorded_track: number;
@@ -62,6 +63,7 @@ export interface TourRow {
water_context: string | null;
paddle_speed_kmh: number | null;
portage_speed_kmh: number | null;
paddle_lake_style: string | null;
track_marks: string | null;
}
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
@@ -83,6 +85,7 @@ export interface TourInsert {
water_context?: PaddleWaterContext | null;
paddle_speed_kmh?: number | null;
portage_speed_kmh?: number | null;
paddle_lake_style?: PaddleLakeStyle | null;
}
/** What a route edit replaces on an existing tour: the type and everything that belongs to it. */
@@ -95,6 +98,7 @@ export type TourUpdate = Omit<
| 'water_context'
| 'paddle_speed_kmh'
| 'portage_speed_kmh'
| 'paddle_lake_style'
> & {
planned_duration_minutes?: number | null;
break_additional_minutes?: number | null;
@@ -102,6 +106,7 @@ export type TourUpdate = Omit<
water_context: PaddleWaterContext | null;
paddle_speed_kmh: number | null;
portage_speed_kmh: number | null;
paddle_lake_style: PaddleLakeStyle | null;
};
/** A recorded track's paddle plan (PAD-03, PAD-07): its speeds, its marks and the time they make. */
@@ -193,6 +198,7 @@ export class ToursRepository extends TrekRepository<Tours> {
't.water_context as water_context',
't.paddle_speed_kmh as paddle_speed_kmh',
't.portage_speed_kmh as portage_speed_kmh',
't.paddle_lake_style as paddle_lake_style',
't.track_marks as track_marks',
eb('t.recorded_route_geometry', 'is not', null).as('has_recorded_track'),
eb
@@ -449,6 +455,7 @@ export class ToursRepository extends TrekRepository<Tours> {
water_context: input.water_context ?? null,
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
portage_speed_kmh: input.portage_speed_kmh ?? null,
paddle_lake_style: input.paddle_lake_style ?? null,
track_marks: input.track_marks ?? null,
});
}
@@ -501,6 +508,7 @@ export class ToursRepository extends TrekRepository<Tours> {
water_context: input.water_context,
paddle_speed_kmh: input.paddle_speed_kmh,
portage_speed_kmh: input.portage_speed_kmh,
paddle_lake_style: input.paddle_lake_style,
})
.where('place_id', '=', place_id)
.where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id))