Alpha stage commit

This commit is contained in:
2026-07-02 21:04:05 +02:00
parent c03b183dfb
commit abed21be21
136 changed files with 15531 additions and 15 deletions

34
.env.example Normal file
View File

@@ -0,0 +1,34 @@
# Provider selection
ROUTING_PROVIDER=mock
POI_PROVIDER=mock
ELEVATION_PROVIDER=mock
WEATHER_PROVIDER=mock
RULES_PROVIDER=mock
PORT=8000
OSRM_BASE_URL=https://router.project-osrm.org
OSRM_PROFILE=bike
BROUTER_BASE_URL=https://brouter.de/brouter
BROUTER_PROFILE=
OVERPASS_BASE_URL=https://overpass-api.de/api/interpreter
# Web app
VITE_API_BASE_URL=http://localhost:8000
# External providers; keep blank for mock mode
OPENROUTESERVICE_API_KEY=
GRAPHHOPPER_API_KEY=
BOOKING_API_TOKEN=
GARMIN_CLIENT_ID=
GARMIN_CLIENT_SECRET=
WAHOO_CLIENT_ID=
WAHOO_CLIENT_SECRET=
# Database
DATABASE_URL=postgresql://bikepackpilot:bikepackpilot@localhost:5432/bikepackpilot
REDIS_URL=redis://localhost:6379/0
# Storage
OBJECT_STORAGE_BUCKET=
OBJECT_STORAGE_ENDPOINT=
OBJECT_STORAGE_ACCESS_KEY=
OBJECT_STORAGE_SECRET_KEY=

32
.gitignore vendored
View File

@@ -1,14 +1,18 @@
# ---> VisualStudioCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
node_modules/
.env
.env.*
!.env.example
__pycache__/
.pytest_cache/
.venv/
dist/
build/
coverage/
*.tsbuildinfo
.DS_Store
*.sqlite
*.pbf
*.pmtiles
*.mbtiles
*.gpkg
*.zip

34
AGENTS.md Normal file
View File

@@ -0,0 +1,34 @@
# Agent Instructions
This repository is a product and implementation blueprint for a bikepacking app. Treat the docs as the source of truth.
## Product constraints
- The app is for loaded bikepacking, not generic cycling.
- Optimize for multi-day trips, offline use, resupply, water, sleeping, repair, local rules, and bailout options.
- Routing quality should come from OSM/elevation/routing engines plus a proprietary bikepacking suitability layer.
- Never hardcode API keys or commercial credentials.
- Keep data-source adapters interchangeable.
- Treat legal access, protected areas, wild camping, and safety warnings as confidence-scored advisory information, not guaranteed legal advice.
## Engineering constraints
- Prefer a monorepo with `apps/web`, `apps/mobile`, `services/api`, `services/data-pipeline`, and `packages/shared`.
- Use TypeScript for user-facing apps and shared domain types.
- Use a backend service with geospatial database support. The recommended default is FastAPI + PostgreSQL/PostGIS, but an equivalent stack is acceptable if it preserves the API contract.
- Use MapLibre-compatible map rendering.
- Keep routing behind provider interfaces: `RoutingProvider`, `PoiProvider`, `ElevationProvider`, `WeatherProvider`, `RulesProvider`, `DeviceExportProvider`.
- Implement testable pure functions for scoring, staging, POI filtering, and warning generation.
- Add unit tests for scoring and staging before UI refinements.
- Add fixtures from `examples/` and keep sample outputs deterministic.
## Acceptance standard
A task is complete only when:
- code compiles,
- tests pass,
- API contracts are updated when behavior changes,
- environment variables are documented,
- user-facing errors are actionable,
- OSM and third-party attribution requirements are preserved in the UI and docs.

5
LICENSE_NOT_SELECTED.md Normal file
View File

@@ -0,0 +1,5 @@
# License Not Selected
This blueprint does not assign an application license. Choose a license before publishing code.
Important: using OpenStreetMap-derived data can create Open Database License obligations for derived databases. Keep legal review separate from code licensing.

139
README.md
View File

@@ -1,2 +1,139 @@
# pikebacker
# Bikepacking App Blueprint
This repository is a **build brief for a specialized bikepacking application**. It is written so a coding agent such as Codex can turn the specification into a working monorepo.
Working name: **BikepackPilot**.
Core product idea:
> Plan and ride multi-day bikepacking routes with confidence: where you can ride, sleep, refill, repair, reroute, and bail out — even offline.
The app should not be a generic route planner. It should combine route planning, daily staging, sleep/water/food logistics, offline use, local rules, local reports, and device export.
## Repository purpose
This repository contains:
- product summary and PRD,
- MVP scope,
- technical architecture,
- data-source matrix,
- routing/scoring model,
- offline strategy,
- API and database schemas,
- UX flows,
- risk/legal notes,
- Codex-ready implementation prompts and task breakdown.
## Recommended build strategy
Build in this order:
1. **Planning web app**: map, trip request form, route card, stage cards, route-corridor POIs.
2. **Backend API**: route planning adapter, stage planner, POI corridor, GPX export.
3. **Data foundation**: OSM-derived POI import, elevation enrichment, protected-area/rule model.
4. **Mobile riding app**: offline packs, next-critical-thing panel, rerouting, reports.
5. **Advanced intelligence**: bikepacking suitability score, local trust, weather-aware rerouting.
## Suggested monorepo shape
```text
apps/
web/ # desktop/mobile web planner
mobile/ # riding/offline app
services/
api/ # backend API
data-pipeline/ # OSM, POI, elevation, rule ingestion
packages/
shared/ # shared domain types, scoring helpers
schemas/ # OpenAPI, SQL, JSON schemas
codex/ # coding-agent prompts and task plan
docs/ # product and architecture documentation
examples/ # sample requests, responses, scoring configs
```
## MVP definition
The first useful version should allow a rider to:
1. enter a start/end point, trip dates, bike/load type, daily distance/climb tolerance, sleep preference, and water/resupply constraints;
2. receive a bikepacking-oriented route with daily stages;
3. see route-corridor water/food/sleep/repair POIs;
4. view warnings for long no-water/no-food gaps, uncertain access, steep loaded-bike sections, and missing bailout options;
5. export GPX/TCX/FIT-ready route files;
6. cache the route, POIs, stage plan, and rule cards offline.
## Key differentiator
The app wins by optimizing the **whole trip**, not only the route geometry:
```text
route + stages + sleep + water + food + repair + rules + weather + bailout + offline
```
## Start here for Codex
Use [`codex/CODEX_MASTER_PROMPT.md`](codex/CODEX_MASTER_PROMPT.md), then execute the tasks in [`codex/IMPLEMENTATION_TASKS.md`](codex/IMPLEMENTATION_TASKS.md).
## Current MVP implementation
This repo now includes a runnable TypeScript monorepo MVP:
- `packages/shared`: domain types, deterministic mock fixtures, scoring, stage planning, POI corridor filtering, critical gap detection, GPX export, and offline manifest helpers.
- `services/api`: Express API implementing the MVP OpenAPI endpoints with mock providers by default, BRouter bikepacking route candidate selection, optional OSRM fallback, and optional Overpass POIs.
- `apps/web`: Vite/React/MapLibre planner UI with OSM raster tiles, route overlay, linked route/elevation profile hover, collapsible planning/export dock, layer controls, bottom route-details drawer, GPX download, offline manifest creation, structured local reports, and offline report queue/sync.
- `apps/mobile`: compiled placeholder for offline pack indexing and queued report storage.
- `services/data-pipeline`: OSM-like POI fixture normalizer preserving source and attribution fields.
Mock mode requires no external API keys. Non-mock providers intentionally return actionable setup errors until real adapters are configured.
## Setup
```bash
npm install
npm test
npm run build
```
Run the API and web planner in separate terminals:
```bash
npm run dev:api
npm run dev:web
```
Default URLs:
- API: `http://localhost:8000`
- Web: `http://localhost:5173`
Useful commands:
```bash
npm run typecheck
npm run fixtures:pois
```
## Environment
Copy `.env.example` if local overrides are needed. The default MVP uses:
- `ROUTING_PROVIDER=mock`
- `POI_PROVIDER=mock`
- `RULES_PROVIDER=mock`
- `VITE_API_BASE_URL=http://localhost:8000`
- `OSRM_BASE_URL=https://router.project-osrm.org`
- `OSRM_PROFILE=bike`
- `BROUTER_BASE_URL=https://brouter.de/brouter`
- `BROUTER_PROFILE=`
- `OVERPASS_BASE_URL=https://overpass-api.de/api/interpreter`
Keep commercial provider keys blank in mock mode. Do not commit populated credentials.
The web planner defaults to `provider: "brouter"`. The API evaluates multiple BRouter bicycle profiles for the selected Pikebacker profile and chooses the highest-scored bikepacking candidate. OSRM remains available as an explicit road-biased demo fallback. Public BRouter, OSRM, Overpass, and OSM tile endpoints are development conveniences only; production should use compliant tile hosting and configured or self-hosted routing/POI services.
The first routing editor supports ordered points: `start`, optional `viaPoints`, and `end`. The map can add points from right-click actions, waypoint pins are draggable, and dragging the route inserts a new via point between the nearest existing routing points. BRouter and mock routes expose optional per-coordinate `elevationM` samples for the web elevation profile; providers without elevation show aggregate ascent/descent only.
## Attribution and uncertainty
The web UI, API responses, GPX metadata, and data-pipeline output preserve OSM attribution hooks. Local rules, protected-area warnings, access status, and safety warnings are exposed as confidence-scored advisory information, not guaranteed legal advice.

29
REPO_INDEX.md Normal file
View File

@@ -0,0 +1,29 @@
# Repo Index
- `README.md`: concept and build strategy.
- `AGENTS.md`: coding-agent instructions.
- `docs/00_executive_summary.md`: high-level concept.
- `docs/01_product_requirements.md`: requirements.
- `docs/02_mvp_scope.md`: MVP boundaries.
- `docs/03_system_architecture.md`: stack and service design.
- `docs/04_data_sources.md`: data-source matrix.
- `docs/05_routing_and_scoring.md`: route/stage scoring model.
- `docs/06_offline_strategy.md`: offline pack strategy.
- `docs/07_ux_flows.md`: UX flows.
- `docs/08_data_model.md`: domain model.
- `docs/09_api_contract.md`: endpoint explanations.
- `docs/10_integrations.md`: providers and device/accommodation integrations.
- `docs/11_local_rules_and_liability.md`: rules and legal risk model.
- `docs/12_community_intel.md`: local reports/trust model.
- `docs/13_security_privacy.md`: privacy/security requirements.
- `docs/14_testing_plan.md`: QA plan.
- `docs/15_roadmap.md`: phased roadmap.
- `docs/source_references.md`: official links verified 2026-06-30.
- `codex/CODEX_MASTER_PROMPT.md`: prompt to start implementation.
- `codex/IMPLEMENTATION_TASKS.md`: task breakdown.
- `codex/BACKLOG.csv`: backlog import file.
- `schemas/openapi.yaml`: API draft.
- `schemas/database.sql`: PostGIS schema draft.
- `schemas/domain_types.ts`: shared TypeScript types.
- `examples/`: fixtures and prototype queries.
- `design/`: text wireframes and map layers.

31
apps/mobile/README.md Normal file
View File

@@ -0,0 +1,31 @@
# Mobile App
Purpose: riding/offline companion.
MVP mobile scope can be a placeholder until the web planner and backend are stable.
Primary future screens:
- Trip list.
- Offline pack status.
- Riding mode: next water/food/sleep/repair/bailout.
- Map view.
- Report issue.
- Export/share route.
Offline-critical data:
- route geometry,
- stages,
- POIs,
- rules,
- exports,
- report queue.
## Implemented placeholder
The current package is not a full React Native shell. It provides compiled TypeScript contracts for the future mobile app:
- `OfflineReportQueue` for dated, location-bound reports captured without network access;
- `OfflinePackIndex` for downloaded route/stage/POI/rule/export manifests;
- storage abstraction that can later be backed by SQLite, AsyncStorage, or a native key-value store.

15
apps/mobile/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "@pikebacker/mobile",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/offlineStore.js",
"types": "./dist/offlineStore.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@pikebacker/shared": "0.1.0"
}
}

View File

@@ -0,0 +1,80 @@
import type { LocalReportCreate, OfflinePackManifest } from '@pikebacker/shared';
export type OfflineReportQueueItem = LocalReportCreate & {
offlineClientId: string;
queuedAt: string;
syncAttempts: number;
lastSyncError?: string;
};
export type OfflinePackRecord = {
manifest: OfflinePackManifest;
downloadedAt: string;
routeJsonPath?: string;
poiJsonPath?: string;
gpxPath?: string;
tilePackPath?: string;
};
export interface OfflineKeyValueStore {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}
const REPORT_QUEUE_KEY = 'pikebacker.mobile.offlineReportQueue';
const PACK_INDEX_KEY = 'pikebacker.mobile.offlinePackIndex';
export class OfflineReportQueue {
constructor(private readonly store: OfflineKeyValueStore) {}
async enqueue(report: LocalReportCreate, now = new Date().toISOString()): Promise<OfflineReportQueueItem> {
const item: OfflineReportQueueItem = {
...report,
offlineClientId: report.offlineClientId ?? crypto.randomUUID(),
queuedAt: now,
syncAttempts: 0
};
const queue = await this.list();
await this.store.setItem(REPORT_QUEUE_KEY, JSON.stringify([...queue, item]));
return item;
}
async list(): Promise<OfflineReportQueueItem[]> {
const raw = await this.store.getItem(REPORT_QUEUE_KEY);
return raw ? (JSON.parse(raw) as OfflineReportQueueItem[]) : [];
}
async recordSyncFailure(offlineClientId: string, error: string): Promise<void> {
const queue = await this.list();
const next = queue.map((item) =>
item.offlineClientId === offlineClientId
? { ...item, syncAttempts: item.syncAttempts + 1, lastSyncError: error }
: item
);
await this.store.setItem(REPORT_QUEUE_KEY, JSON.stringify(next));
}
async removeSynced(offlineClientId: string): Promise<void> {
const queue = await this.list();
await this.store.setItem(REPORT_QUEUE_KEY, JSON.stringify(queue.filter((item) => item.offlineClientId !== offlineClientId)));
}
}
export class OfflinePackIndex {
constructor(private readonly store: OfflineKeyValueStore) {}
async upsert(record: OfflinePackRecord): Promise<void> {
const records = await this.list();
const next = [...records.filter((item) => item.manifest.packId !== record.manifest.packId), record];
await this.store.setItem(PACK_INDEX_KEY, JSON.stringify(next));
}
async list(): Promise<OfflinePackRecord[]> {
const raw = await this.store.getItem(PACK_INDEX_KEY);
return raw ? (JSON.parse(raw) as OfflinePackRecord[]) : [];
}
async get(packId: string): Promise<OfflinePackRecord | undefined> {
return (await this.list()).find((record) => record.manifest.packId === packId);
}
}

11
apps/mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src"]
}

1
apps/web/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8000

53
apps/web/README.md Normal file
View File

@@ -0,0 +1,53 @@
# Web App
Purpose: planning interface for BikepackPilot.
MVP screens:
- Trip setup form.
- Map view.
- Stage timeline.
- POI corridor list.
- Warnings panel.
- Export/offline panel.
Implementation guidance:
- Use shared domain types from `packages/shared`.
- Use API client generated from or aligned to `schemas/openapi.yaml`.
- Use MapLibre-compatible rendering when map implementation begins.
- Start with fixture route rendering if map provider is not configured.
## Implemented MVP
The current web planner is a Vite/React/MapLibre app. It calls the API provider flow and renders:
- trip request controls;
- full-screen OSM raster tile map;
- route, stage endpoint, and POI map layers;
- collapsible left planning/export/stats dock;
- ordered routing point editor with reorder/delete controls;
- right-click map actions for `from here`, `via here`, and `to here`;
- draggable waypoint pins and route-drag insertion of via points;
- compact floating layer controls;
- bottom drawer for stages, warnings, gaps, rule cards, POIs, and linked route/elevation profile hover;
- GPX download and offline manifest actions;
- structured local report submission with report type, observed time, route-km attachment, confidence payload, localStorage queue fallback, and queued-report sync.
Run locally after starting the API:
```bash
npm -w @pikebacker/web run dev
```
Configure the API base with `VITE_API_BASE_URL`, defaulting to `http://localhost:8000`.
The route provider selector includes:
- `Bikepacking gravel`: calls the API with `provider: "brouter"` and lets the API compare BRouter bike profiles against Pikebacker scoring.
- `OSRM road demo`: calls the API with `provider: "osrm"` and uses the configured OSRM endpoint.
- `Mock fixture`: deterministic route and POI fixtures for repeatable tests.
The profile tab renders per-coordinate `elevationM` samples when the selected routing provider supplies them. BRouter and mock routes currently do; OSRM demo routes expose only aggregate ascent/descent warnings.
The OSM tile layer uses the public OSM raster URL for MVP development and keeps visible OpenStreetMap attribution in the map control.

12
apps/web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pikebacker Planner</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

25
apps/web/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "@pikebacker/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -p tsconfig.json --noEmit && vite build",
"typecheck": "tsc -p tsconfig.json --noEmit",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@pikebacker/shared": "0.1.0",
"lucide-react": "^0.468.0",
"maplibre-gl": "^5.13.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^6.0.3",
"vite": "^8.1.3"
}
}

2155
apps/web/src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

92
apps/web/src/api.ts Normal file
View File

@@ -0,0 +1,92 @@
import type {
LocalReport,
LocalReportCreate,
OfflinePackManifest,
Poi,
RoutePlan,
RuleCard,
ServiceGap,
StagePlan,
Trip,
TripRequest
} from '@pikebacker/shared';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000';
export type PlannerBundle = {
trip: Trip;
route: RoutePlan;
stages: StagePlan[];
pois: Poi[];
gaps: ServiceGap[];
rules: RuleCard[];
};
export async function createPlannerBundle(tripRequest: TripRequest, provider = 'mock'): Promise<PlannerBundle> {
const trip = await postJson<Trip>('/v1/trips', tripRequest);
const route = await postJson<RoutePlan>('/v1/routes/plan', { tripRequest, provider });
const stageResponse = await postJson<{ stages: StagePlan[] }>('/v1/stages/plan', {
routeId: route.id,
tripRequest
});
const poiBufferKm = route.provider === 'mock' ? 5 : 20;
const poiResponse = await getJson<{ pois: Poi[] }>(`/v1/routes/${route.id}/pois?buffer_km=${poiBufferKm}`);
const gapResponse = await getJson<{ gaps: ServiceGap[] }>(`/v1/routes/${route.id}/gaps`);
const ruleResponse = await getJson<{ rules: RuleCard[] }>(`/v1/rules?route_id=${encodeURIComponent(route.id)}`);
return {
trip,
route,
stages: stageResponse.stages,
pois: poiResponse.pois,
gaps: gapResponse.gaps,
rules: ruleResponse.rules
};
}
export async function createOfflinePack(tripId: string, routeId: string): Promise<OfflinePackManifest> {
return postJson<OfflinePackManifest>('/v1/offline-packs', { tripId, routeId, routeBufferKm: 5 });
}
export async function downloadGpx(routeId: string): Promise<Blob> {
const response = await fetch(`${API_BASE_URL}/v1/routes/${routeId}/export?format=gpx`);
if (!response.ok) {
throw new Error(await errorMessage(response));
}
return response.blob();
}
export async function submitReport(report: LocalReportCreate): Promise<LocalReport> {
return postJson<LocalReport>('/v1/reports', report);
}
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`);
if (!response.ok) {
throw new Error(await errorMessage(response));
}
return response.json() as Promise<T>;
}
async function postJson<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(await errorMessage(response));
}
return response.json() as Promise<T>;
}
async function errorMessage(response: Response): Promise<string> {
try {
const json = (await response.json()) as { message?: string; error?: string };
return json.message ?? json.error ?? response.statusText;
} catch {
return response.statusText;
}
}

6
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,6 @@
import { createRoot } from 'react-dom/client';
import 'maplibre-gl/dist/maplibre-gl.css';
import App from './App.js';
import './styles.css';
createRoot(document.getElementById('root') as HTMLElement).render(<App />);

992
apps/web/src/styles.css Normal file
View File

@@ -0,0 +1,992 @@
:root {
background: #111814;
color: #19231f;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 16px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
align-items: center;
background: #f6f7f3;
border: 1px solid #c9d1c8;
border-radius: 6px;
color: #17231e;
cursor: pointer;
display: inline-flex;
font-weight: 750;
gap: 0.45rem;
min-height: 2.25rem;
padding: 0.5rem 0.72rem;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
label {
color: #52625a;
display: grid;
font-size: 0.72rem;
font-weight: 850;
gap: 0.35rem;
letter-spacing: 0;
text-transform: uppercase;
}
input,
select,
textarea {
background: #ffffff;
border: 1px solid #c7d1c8;
border-radius: 6px;
color: #18251f;
min-height: 2.25rem;
padding: 0.48rem 0.58rem;
width: 100%;
}
textarea {
min-height: 4.5rem;
resize: vertical;
text-transform: none;
}
.plannerShell {
height: 100vh;
overflow: hidden;
position: relative;
width: 100vw;
}
.mapSurface {
background: #e9eee7;
height: 100%;
position: absolute;
inset: 0;
width: 100%;
}
.maplibregl-ctrl-attrib {
font-size: 0.72rem;
}
.emptyMapOverlay {
align-items: center;
background: rgba(245, 248, 243, 0.9);
border: 1px solid rgba(42, 62, 53, 0.22);
border-radius: 8px;
color: #495950;
display: grid;
gap: 0.55rem;
justify-items: center;
left: 50%;
max-width: 24rem;
padding: 1rem 1.2rem;
position: absolute;
top: 42%;
transform: translate(-50%, -50%);
z-index: 2;
}
.brandPlate,
.statusPlate,
.leftDock,
.layerPanel,
.bottomDrawer {
backdrop-filter: blur(16px);
background: rgba(250, 251, 247, 0.94);
border: 1px solid rgba(73, 91, 79, 0.22);
box-shadow: 0 18px 60px rgba(15, 24, 20, 0.2);
position: absolute;
z-index: 5;
}
.brandPlate {
border-radius: 8px;
display: grid;
left: var(--left-panel-space, 386px);
min-width: 13rem;
padding: 0.7rem 0.9rem;
top: 1rem;
transition: left 180ms ease;
}
.leftCollapsed .brandPlate {
left: 5.5rem;
}
.brandPlate strong {
font-size: 1.05rem;
}
.eyebrow {
color: #617369;
font-size: 0.68rem;
font-weight: 850;
letter-spacing: 0;
text-transform: uppercase;
}
.statusPlate {
border-radius: 999px;
color: #23342d;
font-size: 0.84rem;
font-weight: 750;
left: 50%;
max-width: min(46rem, calc(100vw - 43rem));
overflow: hidden;
padding: 0.55rem 0.85rem;
text-overflow: ellipsis;
top: 1rem;
transform: translateX(-50%);
white-space: nowrap;
}
.leftDock {
border-radius: 0 12px 12px 0;
display: flex;
gap: 0;
left: 0;
max-height: calc(100vh - 2rem);
top: 1rem;
transition: width 180ms ease;
width: 23rem;
}
.leftCollapsed .leftDock {
width: 3.75rem;
}
.dockToggle {
align-self: stretch;
background: #244a3b;
border: 0;
border-radius: 0;
color: #ffffff;
min-height: 100%;
width: 2.4rem;
}
.dockContent {
display: grid;
gap: 0.9rem;
max-height: calc(100vh - 2rem);
overflow: auto;
padding: 1rem;
width: calc(23rem - 2.4rem);
}
.planningForm,
.dockSection,
.reportForm {
display: grid;
gap: 0.75rem;
}
.dockSection {
border-top: 1px solid #d8dfd8;
padding-top: 0.85rem;
}
.sectionTitle {
align-items: center;
display: flex;
gap: 0.45rem;
}
.sectionTitle h2 {
font-size: 0.98rem;
margin: 0;
}
.fieldGrid {
display: grid;
gap: 0.55rem;
}
.fieldGrid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.fieldGrid.three {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.reportRange {
accent-color: #245741;
min-height: 1.5rem;
padding: 0;
}
.reportLocation {
align-items: center;
background: #f5f7f1;
border: 1px solid #d8dfd8;
border-radius: 8px;
color: #4f5f57;
display: flex;
font-size: 0.78rem;
font-weight: 750;
gap: 0.4rem;
padding: 0.45rem 0.55rem;
}
.reportActions {
display: grid;
gap: 0.45rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.reportActions button {
justify-content: center;
padding-inline: 0.45rem;
}
.reportQueue {
background: #ffffff;
border: 1px solid #d8dfd8;
border-radius: 8px;
color: #5d6b63;
display: grid;
font-size: 0.78rem;
gap: 0.22rem;
padding: 0.5rem 0.6rem;
}
.reportQueue span,
.reportQueue small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.primaryButton {
background: #245741;
border-color: #245741;
color: #ffffff;
justify-content: center;
}
.metricGrid {
display: grid;
gap: 0.55rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric {
background: #eef3eb;
border: 1px solid #d6ded5;
border-radius: 8px;
display: grid;
min-width: 0;
padding: 0.5rem 0.58rem;
}
.metric span,
.muted,
.emptyText {
color: #66746c;
font-size: 0.82rem;
}
.metric strong {
font-size: 0.98rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actionRow,
.filterRow,
.tabRow {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.manifestBox,
.rule {
background: #f1f5ef;
border: 1px solid #d9e0d8;
border-radius: 8px;
display: grid;
gap: 0.3rem;
padding: 0.65rem;
}
.routingPoints {
display: grid;
gap: 0.55rem;
}
.routePointHeader {
align-items: center;
display: flex;
justify-content: space-between;
}
.routePointHeader span {
color: #52625a;
font-size: 0.72rem;
font-weight: 850;
letter-spacing: 0;
text-transform: uppercase;
}
.routePointHeader button {
min-height: 1.9rem;
padding: 0.3rem 0.5rem;
}
.routePointList {
display: grid;
gap: 0.5rem;
}
.routePointRow {
align-items: center;
background: #ffffff;
border: 1px solid #d8e0d8;
border-radius: 8px;
display: grid;
gap: 0.45rem;
grid-template-columns: auto auto minmax(0, 1fr) auto;
padding: 0.45rem;
}
.routePointRow:active {
cursor: grabbing;
}
.routePointHandle {
align-items: center;
color: #829187;
cursor: grab;
display: flex;
justify-content: center;
}
.routePointBadge {
align-items: center;
border: 2px solid #ffffff;
border-radius: 999px;
color: #ffffff;
display: inline-flex;
font-size: 0.78rem;
font-weight: 850;
height: 1.6rem;
justify-content: center;
width: 1.6rem;
}
.routePointBadge.start {
background: #245741;
}
.routePointBadge.via {
background: #6b5aa7;
}
.routePointBadge.end {
background: #b75f31;
}
.routePointFields {
display: grid;
gap: 0.35rem;
min-width: 0;
}
.routePointRole {
color: #24352d;
font-size: 0.82rem;
font-weight: 850;
}
.coordinateGrid {
display: grid;
gap: 0.4rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.coordinateGrid label {
font-size: 0.64rem;
}
.coordinateGrid input {
min-height: 2rem;
padding: 0.35rem 0.45rem;
}
.deletePointButton {
background: transparent;
border: 0;
color: #8d3d34;
justify-content: center;
min-height: 1.9rem;
padding: 0.25rem;
width: 1.9rem;
}
.deletePointButton:disabled {
color: #bdc6bf;
}
.mapContextMenu {
background: rgba(250, 251, 247, 0.97);
border: 1px solid rgba(73, 91, 79, 0.25);
border-radius: 8px;
box-shadow: 0 16px 50px rgba(15, 24, 20, 0.24);
display: grid;
gap: 0.3rem;
padding: 0.4rem;
position: absolute;
width: 10rem;
z-index: 8;
}
.mapContextMenu button {
background: transparent;
border: 0;
justify-content: flex-start;
min-height: 2rem;
padding: 0.4rem 0.5rem;
}
.manifestBox span,
.rule span,
.rule small {
color: #5e6c64;
font-size: 0.82rem;
}
.layerPanel {
border-radius: 8px;
display: grid;
gap: 0.55rem;
padding: 0.75rem;
right: 1rem;
top: 1rem;
width: 10.5rem;
}
.layerTitle,
.switchRow,
.poiStrip span,
.poiRow {
align-items: center;
display: flex;
gap: 0.42rem;
}
.layerTitle strong {
font-size: 0.92rem;
}
.switchRow {
color: #34463d;
display: grid;
font-size: 0.82rem;
font-weight: 750;
grid-template-columns: auto 1fr;
text-transform: capitalize;
}
.switchRow input {
accent-color: #245741;
min-height: auto;
width: auto;
}
.bottomDrawer {
border-radius: 12px 12px 0 0;
bottom: 0;
display: grid;
gap: 0;
grid-template-rows: auto minmax(0, 1fr);
height: 18rem;
left: 24.2rem;
right: 1rem;
transition:
height 180ms ease,
left 180ms ease;
}
.leftCollapsed .bottomDrawer {
left: 4.8rem;
}
.bottomDrawer.collapsed {
height: 3.25rem;
}
.bottomDrawer.profileMode {
height: 22rem;
}
.bottomHeader {
align-items: center;
border-bottom: 1px solid #d8dfd8;
display: flex;
justify-content: space-between;
padding: 0.55rem 0.7rem;
}
.bottomDrawer.collapsed .bottomHeader {
border-bottom: 0;
}
.bottomContent {
min-height: 0;
overflow: auto;
padding: 0.75rem;
}
.profileMode .bottomContent {
overflow: hidden;
}
.tabButton.active,
.filter.active {
background: #234638;
border-color: #234638;
color: #ffffff;
}
.iconButton {
justify-content: center;
min-width: 2.35rem;
padding: 0.5rem;
}
.stageStrip {
display: grid;
gap: 0.75rem;
grid-auto-columns: minmax(18rem, 22rem);
grid-auto-flow: column;
overflow-x: auto;
padding-bottom: 0.25rem;
}
.stageCard {
background: #ffffff;
border: 1px solid #d8e0d8;
border-radius: 8px;
display: grid;
gap: 0.6rem;
padding: 0.75rem;
}
.stageMain {
align-items: center;
display: grid;
gap: 0.35rem;
grid-template-columns: 1fr auto;
}
.stageMain span {
color: #55645c;
font-size: 0.82rem;
}
.poiStrip {
color: #52625a;
display: flex;
flex-wrap: wrap;
font-size: 0.82rem;
gap: 0.6rem;
}
.profilePanel {
display: grid;
gap: 0.7rem;
grid-template-rows: minmax(8rem, 1fr) auto auto;
height: 100%;
min-height: 0;
padding-block: 0.15rem 0.25rem;
}
.profileChart {
background: linear-gradient(180deg, #f7f8f2 0%, #eef3ec 100%);
border: 1px solid #d8e0d8;
border-radius: 8px;
cursor: crosshair;
min-height: 0;
overflow: hidden;
position: relative;
}
.profileChart svg {
display: block;
height: 100%;
min-height: 8rem;
width: 100%;
}
.elevationArea {
fill: rgba(106, 112, 65, 0.22);
}
.elevationLine {
fill: none;
stroke: #245741;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 5;
vector-effect: non-scaling-stroke;
}
.stageTick line {
stroke: rgba(74, 88, 78, 0.38);
stroke-dasharray: 5 5;
stroke-width: 2;
vector-effect: non-scaling-stroke;
}
.stageLabelLayer {
inset: 0.45rem 0.5rem auto 0.5rem;
pointer-events: none;
position: absolute;
}
.stageLabel {
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(93, 107, 99, 0.24);
border-radius: 999px;
color: #24352d;
font-size: 0.72rem;
font-weight: 850;
padding: 0.12rem 0.38rem;
position: absolute;
top: 0;
transform: translateX(0);
white-space: nowrap;
}
.profileHoverOverlay {
bottom: 0;
pointer-events: none;
position: absolute;
top: 0;
transform: translateX(-50%);
width: 0;
}
.profileHoverLine {
border-left: 2px dashed #9a5f14;
bottom: 0;
display: block;
left: 0;
position: absolute;
top: 0;
}
.profileHoverDot {
background: #c9851a;
border: 3px solid #ffffff;
border-radius: 999px;
box-shadow: 0 4px 14px rgba(25, 35, 31, 0.25);
height: 0.85rem;
left: 0;
position: absolute;
transform: translate(-50%, -50%);
width: 0.85rem;
}
.profileAxis {
bottom: 0.5rem;
color: #5d6b63;
display: flex;
font-size: 0.75rem;
font-weight: 750;
justify-content: space-between;
left: 0.75rem;
pointer-events: none;
position: absolute;
right: 0.75rem;
}
.profileHoverReadout {
align-items: center;
background: #ffffff;
border: 1px solid #d8e0d8;
border-radius: 8px;
color: #52625a;
display: grid;
font-size: 0.84rem;
gap: 0.55rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
min-height: 2.35rem;
padding: 0.45rem 0.6rem;
}
.profileHoverReadout span,
.profileHoverReadout strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profileHoverReadout strong {
color: #24352d;
text-align: right;
}
.mutedReadout {
color: #7b8980;
}
.profileStats {
display: grid;
gap: 0.55rem;
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.profileStat {
background: #ffffff;
border: 1px solid #d8e0d8;
border-radius: 8px;
display: grid;
gap: 0.15rem;
min-width: 0;
padding: 0.42rem 0.55rem;
}
.profileStat span {
color: #607169;
font-size: 0.72rem;
font-weight: 750;
text-transform: uppercase;
}
.profileStat strong {
color: #24352d;
font-size: 0.92rem;
}
.emptyProfile .profileStats {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.infoGrid {
display: grid;
gap: 0.75rem;
grid-template-columns: 1.2fr 1fr 1.1fr;
}
.infoColumn {
display: grid;
gap: 0.6rem;
min-width: 0;
}
.warningList,
.gapList,
.ruleList,
.poiList {
display: grid;
gap: 0.55rem;
}
.warning {
align-items: start;
background: #f7f8f4;
border-left: 4px solid #aab7ad;
display: flex;
gap: 0.5rem;
padding: 0.5rem 0.55rem;
}
.warning strong,
.warning span {
display: block;
}
.warning span {
color: #52625a;
font-size: 0.82rem;
margin-top: 0.12rem;
}
.warning.critical {
background: #fff0ee;
border-color: #b93d37;
}
.warning.warning {
background: #fff7e4;
border-color: #c9851a;
}
.warning.notice {
background: #eef6fc;
border-color: #5f86ae;
}
.warningList.compact {
gap: 0.35rem;
}
.warningList.compact .warning {
padding: 0.35rem 0.45rem;
}
.gap {
background: #ffffff;
border: 1px solid #d8e0d8;
border-radius: 8px;
display: grid;
gap: 0.2rem;
padding: 0.55rem;
}
.gap span {
color: #5d6b63;
font-size: 0.82rem;
}
.gap.critical {
border-color: #d59a92;
}
.gap.warning {
border-color: #e1bd72;
}
.poiPanel {
display: grid;
gap: 0.65rem;
}
.poiList {
grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
}
.poiRow {
background: #ffffff;
border: 1px solid #d8e0d8;
border-radius: 8px;
padding: 0.55rem 0.65rem;
}
.poiRow div {
display: grid;
}
.poiRow span {
color: #65756d;
font-size: 0.82rem;
}
.poiDot {
border-radius: 999px;
flex: 0 0 auto;
height: 0.72rem;
width: 0.72rem;
}
.poiDot.water {
background: #2379b7;
}
.poiDot.food {
background: #b75f31;
}
.poiDot.sleep {
background: #6750a4;
}
.poiDot.repair {
background: #6a7041;
}
.poiDot.bailout {
background: #1f7774;
}
@media (max-width: 1050px) {
.statusPlate {
left: auto;
max-width: calc(100vw - 13rem);
right: 12.3rem;
transform: none;
}
.bottomDrawer,
.leftCollapsed .bottomDrawer {
left: 1rem;
}
.infoGrid {
grid-template-columns: 1fr;
}
.profileStats {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.profileHoverReadout {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.brandPlate,
.statusPlate,
.layerPanel {
display: none;
}
.leftDock,
.leftCollapsed .leftDock {
border-radius: 0 0 12px 0;
left: 0;
max-height: 68vh;
top: 0;
width: min(23rem, calc(100vw - 2rem));
}
.leftCollapsed .leftDock {
width: 3.75rem;
}
.bottomDrawer,
.leftCollapsed .bottomDrawer {
height: 17rem;
left: 0.5rem;
right: 0.5rem;
}
.bottomDrawer.profileMode,
.leftCollapsed .bottomDrawer.profileMode {
height: 20rem;
}
.fieldGrid.two,
.fieldGrid.three,
.metricGrid,
.reportActions,
.profileHoverReadout,
.profileStats,
.emptyProfile .profileStats {
grid-template-columns: 1fr;
}
}

9
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

18
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
const here = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@pikebacker/shared': resolve(here, '../../packages/shared/src/index.ts')
}
},
server: {
port: 5173
}
});

13
codex/BACKLOG.csv Normal file
View File

@@ -0,0 +1,13 @@
id,epic,story,priority,acceptance
BP-001,Planning,As a rider I can create a trip request with bike/load and daily constraints,P0,Form submits typed request and persists trip shell
BP-002,Routing,As a rider I can get a bikepacking route from a mock provider,P0,Route geometry score warnings returned deterministically
BP-003,Scoring,As a rider I can see why a route is risky or suitable,P0,Score includes surface grade access water and food warnings
BP-004,Staging,As a rider I can split a route into daily stages,P0,Stages respect daily distance range and attach sleep options
BP-005,POI Corridor,As a rider I can see water food sleep and repair near my route,P0,POIs sorted by meters from start and detour distance
BP-006,Gaps,As a rider I can see long water food repair and bailout gaps,P0,Warnings produced when thresholds exceeded
BP-007,Export,As a rider I can export GPX,P0,GPX validates and includes stage waypoints
BP-008,Offline,As a rider I can generate an offline pack manifest,P0,Manifest includes route stages POIs rules and exports
BP-009,Rules,As a rider I can see local rule cards,P1,Rule card has source date confidence and advisory status
BP-010,Reports,As a rider I can submit structured local reports,P1,Report stores type location observed_at trust and expiry
BP-011,Weather,As a rider I can see weather warnings along stages,P2,Forecast endpoint returns route/stage risk summaries
BP-012,Devices,As a rider I can export TCX/FIT or sync devices,P2,Device export provider interface and first implementation exist
1 id epic story priority acceptance
2 BP-001 Planning As a rider I can create a trip request with bike/load and daily constraints P0 Form submits typed request and persists trip shell
3 BP-002 Routing As a rider I can get a bikepacking route from a mock provider P0 Route geometry score warnings returned deterministically
4 BP-003 Scoring As a rider I can see why a route is risky or suitable P0 Score includes surface grade access water and food warnings
5 BP-004 Staging As a rider I can split a route into daily stages P0 Stages respect daily distance range and attach sleep options
6 BP-005 POI Corridor As a rider I can see water food sleep and repair near my route P0 POIs sorted by meters from start and detour distance
7 BP-006 Gaps As a rider I can see long water food repair and bailout gaps P0 Warnings produced when thresholds exceeded
8 BP-007 Export As a rider I can export GPX P0 GPX validates and includes stage waypoints
9 BP-008 Offline As a rider I can generate an offline pack manifest P0 Manifest includes route stages POIs rules and exports
10 BP-009 Rules As a rider I can see local rule cards P1 Rule card has source date confidence and advisory status
11 BP-010 Reports As a rider I can submit structured local reports P1 Report stores type location observed_at trust and expiry
12 BP-011 Weather As a rider I can see weather warnings along stages P2 Forecast endpoint returns route/stage risk summaries
13 BP-012 Devices As a rider I can export TCX/FIT or sync devices P2 Device export provider interface and first implementation exist

View File

@@ -0,0 +1,52 @@
# Codex Master Prompt
Use this prompt with Codex or another coding agent.
```text
You are building pikebacker, a specialized bikepacking planner and riding companion.
Read the repository docs before coding, especially:
- README.md
- AGENTS.md
- docs/01_product_requirements.md
- docs/02_mvp_scope.md
- docs/03_system_architecture.md
- docs/05_routing_and_scoring.md
- docs/06_offline_strategy.md
- docs/08_data_model.md
- schemas/openapi.yaml
- schemas/database.sql
- codex/IMPLEMENTATION_TASKS.md
Build the MVP as a monorepo with:
- apps/web: TypeScript web planner with map placeholder or MapLibre integration.
- apps/mobile: placeholder/mobile offline architecture if full mobile implementation is too large.
- services/api: backend API implementing the MVP endpoints.
- packages/shared: shared TypeScript domain types and scoring logic.
- services/data-pipeline: import/normalization scripts and docs.
First implement deterministic mock-provider functionality from examples/ fixtures. Do not block on external API keys.
Core MVP behavior:
1. Create a trip request.
2. Plan or load a route from fixture/mock routing provider.
3. Score route segments for bikepacking suitability.
4. Split route into daily stages.
5. Find route-corridor POIs from fixture/database.
6. Generate critical gap warnings.
7. Render route and stage cards in the web planner.
8. Export GPX.
9. Create offline-pack manifest.
10. Submit local reports and queue offline reports.
Engineering rules:
- Keep provider interfaces abstract and testable.
- Do not hardcode API keys.
- Use environment variables for providers.
- Add unit tests for scoring, staging, POI corridor, and GPX export.
- Preserve attribution hooks for OSM-derived data.
- Expose uncertainty and warning severity in API responses.
- Make the app useful with mock data before adding real providers.
After each task, update README or docs if setup/behavior changes.
```

View File

@@ -0,0 +1,157 @@
# Implementation Tasks for Codex
## Task 1 — Create monorepo scaffold
Create:
```text
apps/web
apps/mobile
services/api
services/data-pipeline
packages/shared
```
Acceptance criteria:
- root package/workspace or equivalent created;
- lint/test scripts documented;
- shared domain types imported by web/API where applicable;
- `.env.example` files created;
- no API keys committed.
## Task 2 — Implement shared domain types and scoring
Implement TypeScript types from `schemas/domain_types.ts`.
Add pure functions:
- `scoreSegment(segment, profile)`;
- `scoreRoute(route, profile)`;
- `detectCriticalGaps(route, pois, thresholds)`;
- `estimateHikeABikeRisk(segment, profile)`.
Acceptance criteria:
- unit tests pass;
- sample scoring fixture returns expected warnings;
- profile thresholds are configurable.
## Task 3 — Backend API mock MVP
Implement endpoints from `schemas/openapi.yaml` with mock data:
- `POST /v1/routes/plan`,
- `POST /v1/stages/plan`,
- `GET /v1/routes/{id}/pois`,
- `GET /v1/routes/{id}/gaps`,
- `POST /v1/offline-packs`,
- `GET /v1/routes/{id}/export?format=gpx`,
- `POST /v1/reports`.
Acceptance criteria:
- returns deterministic responses from `examples/`;
- API validation is implemented;
- tests cover success and error cases.
## Task 4 — Stage planner
Implement stage splitting:
- respect min/max daily distance;
- prefer endpoints near sleep options;
- avoid long water/food gaps at stage end;
- include Plan A/B endpoints;
- generate warnings.
Acceptance criteria:
- 3-day and 5-day fixture routes produce plausible stages;
- no stage exceeds hard constraints unless warning returned;
- sleep candidates attached to each stage.
## Task 5 — Web planner UI
Build a web UI:
- trip request form;
- map panel;
- stage timeline;
- POI filter toggles;
- warnings panel;
- export/offline buttons.
Acceptance criteria:
- user can submit sample trip and see route/stages;
- route-corridor POIs shown/listed;
- critical warnings visible;
- GPX export downloads.
## Task 6 — GPX export
Generate valid GPX with:
- route geometry;
- waypoints for stage endpoints;
- optional POIs/course points;
- metadata and attribution.
Acceptance criteria:
- validates as XML;
- imports into common route viewers;
- includes stage waypoints.
## Task 7 — Offline pack manifest
Implement offline manifest generation.
Acceptance criteria:
- includes route, stages, POIs, rules, exports, data versions;
- marks unavailable layers clearly;
- supports future tile pack file references.
## Task 8 — Data pipeline skeleton
Implement scripts/docs for:
- reading OSM PBF extract;
- extracting POI categories;
- normalizing tags;
- inserting into PostGIS;
- importing protected areas/rule cards later.
Acceptance criteria:
- one small sample extract/fixture works;
- schema migrations documented;
- source attribution stored.
## Task 9 — Provider adapters
Add adapters:
- mock routing provider;
- openrouteservice provider placeholder;
- GraphHopper/Valhalla adapter interfaces;
- OSM POI provider.
Acceptance criteria:
- provider selected via environment/config;
- mock provider is default;
- external provider failures return actionable errors.
## Task 10 — Community reports
Implement structured report model and UI submission.
Acceptance criteria:
- reports typed and dated;
- offline queue represented;
- reports can attach to route meters/location;
- moderation status supported.

32
design/map_layers.md Normal file
View File

@@ -0,0 +1,32 @@
# Map Layers
## MVP layers
- Route.
- Stage boundaries.
- Water.
- Food.
- Sleep.
- Repair.
- Bailout.
- Warnings.
- Rule cards.
## Production layers
- Surface/roughness.
- Known/unknown access.
- Protected areas.
- Weather exposure.
- Dry stretches.
- Last-resupply-before-remote-section.
- Local reports.
- Heat/cold risk.
- E-bike charging.
## Visual language
- Critical warnings should be visually distinct.
- Uncertain data should look uncertain, not hidden.
- POIs should default to route-corridor relevance.
- Show stale data markers on POIs and rules.

37
design/wireframes_text.md Normal file
View File

@@ -0,0 +1,37 @@
# Text Wireframes
## Planner page
```text
+--------------------------------------------------------------------------------+
| Header: BikepackPilot | New Trip | Import GPX | Offline Packs | Account |
+------------------------------+-------------------------------------------------+
| Trip Setup | Map |
| Start: [____________] | |
| End: [____________] | route line |
| Dates: [____] - [____] | stage markers |
| Bike: [Loaded gravel v] | POI icons |
| Daily km: 55 / 75 / 95 | |
| Sleep: [Mixed budget v] | |
| Water gap max: 40 km | |
| [Plan Trip] | |
+------------------------------+-------------------------------------------------+
| Warnings | Stage Timeline | POIs | Rules | Export | Offline |
+--------------------------------------------------------------------------------+
```
## Riding mode
```text
+-----------------------------------+
| Day 2 / 81.7 km |
| Next water: 14.3 km |
| Next food: 23.0 km |
| Sleep Plan A: 81.7 km |
| Bailout: train +9.1 km detour |
| Warning: steep climb in 12 km |
| |
| [Reroute to water] [Find sleep] |
| [Bike shop] [Bail out] [Report] |
+-----------------------------------+
```

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
version: "3.9"
services:
db:
image: postgis/postgis:16-3.4
environment:
POSTGRES_DB: bikepackpilot
POSTGRES_USER: bikepackpilot
POSTGRES_PASSWORD: bikepackpilot
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
redis:
image: redis:7-alpine
ports:
- "6379:6379"
# Optional future self-hosted routing service.
# routing:
# image: ghcr.io/gis-ops/valhalla:latest
# ports:
# - "8002:8002"
volumes:
postgres_data:

View File

@@ -0,0 +1,43 @@
# Executive Summary
## Product
A specialized bikepacking planner and riding companion for multi-day trips. It should combine route planning, daily stages, route-corridor logistics, local rules, offline use, and bikepacking-specific route suitability.
## Positioning
**BikepackPilot** is for riders who need answers beyond “which road/path connects A to B?”
It answers:
- Can I ride this with a loaded bike?
- Where can I sleep tonight?
- How far to the next reliable water?
- Is there food before shops close?
- What if this forest road is closed?
- Where is the nearest bike shop or bailout train?
- Can the plan still work offline?
## Primary users
1. **Weekend bikepacker**: 23 days, wants low stress, legal sleep options, reliable water/food.
2. **Long-distance tourer**: multi-week trip, needs offline maps, resupply, device export, budget sleep.
3. **Gravel/adventure rider**: wants maximum dirt but still rideable on a loaded bike.
4. **Remote-route rider**: needs dry-stretch warnings, bailout options, weather exposure, repair planning.
5. **E-bikepacker**: needs charging points, range-aware stages, charger-friendly overnight stops.
## Differentiation from general route planners
The differentiator is not access to a secret map dataset. Major outdoor planners often use OpenStreetMap as a foundation. The differentiator is the proprietary layer built on top:
- bikepacking suitability score,
- rideability with loaded bike,
- water/food/sleep gaps,
- local rules confidence,
- trusted local reports,
- offline route-corridor intelligence,
- multi-day staging optimized around logistics.
## MVP outcome
A user should be able to plan a 37 day trip, inspect stage cards, see critical POIs, export a route, and save an offline pack.

View File

@@ -0,0 +1,133 @@
# Product Requirements Document
## Vision
Build the best planning and riding tool for self-supported bikepacking trips.
## Core promise
Plan a rideable, legal, enjoyable, and logistically safe route for a loaded bike over multiple days, including sleep, water, food, repair, local rules, weather, and bailout options.
## Non-goals for MVP
- Do not build a social network first.
- Do not build full turn-by-turn voice navigation first.
- Do not build a complete global legal camping database first.
- Do not depend on one commercial booking/routing API.
- Do not attempt global correctness for local rules. Use confidence levels.
## Functional requirements
### Trip planning
Users can create a trip with:
- start/end locations or imported GPX,
- start date and optional end date,
- bike type,
- load level,
- rider fitness preference,
- daily target distance range,
- daily target climbing range,
- maximum loaded-bike grade,
- preferred surface mix,
- sleep preference,
- water interval limit,
- resupply interval limit,
- bailout preference.
### Bikepacking routing
The planner returns:
- route geometry,
- elevation profile,
- distance and climb,
- surface breakdown,
- traffic/access confidence,
- steep segment warnings,
- long no-water/no-food warnings,
- possible hike-a-bike warnings,
- stage suggestions.
### Multi-day staging
The app divides the route into daily stages based on:
- daily distance/climb targets,
- daylight window,
- sleep options,
- food/water availability,
- bailout access,
- weather risk if available,
- user preference for indoor/outdoor nights.
Each stage has:
- distance,
- ascent/descent,
- expected riding time range,
- surface mix,
- sleep candidates,
- food/water critical points,
- warnings,
- Plan A and Plan B endpoint.
### Route-corridor logistics
The app supports corridor queries around the route:
- water,
- supermarkets,
- bakeries,
- cafés/restaurants,
- gas stations,
- campsites,
- shelters,
- huts,
- hostels/hotels,
- Warmshowers-style hosts via partnership/deep link if available,
- bike shops,
- repair stations,
- train/bus/ferry bailout.
### Offline
Offline packs should include:
- route geometry,
- map tiles/vector map region,
- route-corridor POIs,
- stage plan,
- elevation profile,
- rule cards,
- emergency/bailout points,
- saved notes,
- exports.
### Community/local intelligence
Users can report:
- water available/unavailable,
- surface mismatch,
- locked gate,
- access restriction,
- dangerous dogs,
- mud/sand/washout,
- bridge/ferry closure,
- good/bad sleep option,
- bike shop availability,
- reroute recommendation.
Reports must be dated, location-bound, typed, confidence-scored, and expire unless reconfirmed.
## Non-functional requirements
- Offline-first for riding-critical functions.
- Clear attribution for OSM-derived data.
- Explicit uncertainty indicators for legal/safety information.
- Fast route-corridor search.
- Provider abstraction to avoid lock-in.
- Export support for GPX first, then TCX/FIT.
- Privacy-conscious location handling.

71
docs/02_mvp_scope.md Normal file
View File

@@ -0,0 +1,71 @@
# MVP Scope
## MVP goal
Deliver a planner that is useful before the ride and minimally useful during the ride.
## MVP features
### Must have
1. Trip request form.
2. Routing adapter using a configurable provider.
3. Bikepacking route scoring summary.
4. Stage planner.
5. Route-corridor POIs for water, food, sleep, and bike repair.
6. Critical-gap warnings.
7. GPX export.
8. Offline-pack manifest and cached trip data.
9. Text-based local rule cards.
10. Structured report submission.
### Should have
1. Map layer toggles.
2. Surface/elevation/stage chart.
3. Bailout POI detection.
4. Weather overlay and wind/rain warnings.
5. TCX export.
6. Deep links to accommodation/search platforms.
### Could have
1. FIT export.
2. Garmin Courses API integration.
3. Wahoo integration.
4. In-app booking.
5. AI route explanation.
6. Trusted-local reputation system.
### Not in MVP
1. Full global offline routing.
2. Real-time social feed.
3. Payments marketplace.
4. Full legal advice engine.
5. Fully automated OSM editing.
## MVP success criteria
A test user can plan a 5-day gravel bikepacking route and identify:
- daily endpoints,
- longest water gap,
- last shop before remote section,
- sleep options each night,
- bike repair options,
- uncertain access segments,
- bailout points,
- exported GPX.
## Target regions for MVP validation
Pick one or two data-rich regions first, for example:
- Germany/Austria/Switzerland,
- Netherlands/Belgium/Germany,
- UK,
- Pacific Northwest,
- Colorado/Utah.
Data quality varies heavily by region. Start where OSM and POI data are strong and bikepacking demand exists.

View File

@@ -0,0 +1,120 @@
# System Architecture
## Recommended stack
### Apps
- `apps/web`: trip planning interface.
- `apps/mobile`: mobile riding/offline interface.
Recommended technologies:
- TypeScript.
- React for web.
- React Native for mobile.
- MapLibre-compatible rendering.
### Backend
- API service: FastAPI or equivalent.
- Database: PostgreSQL + PostGIS.
- Queue: Redis + worker for imports/scoring/offline-pack jobs.
- Object storage: S3-compatible bucket for generated exports/offline manifests.
### Routing
Routing must be accessed through a provider interface. Supported target providers:
- openrouteservice for prototype/API-based routing,
- GraphHopper for self-hosted or API routing,
- Valhalla for self-hosted routing and isochrones,
- BRouter-inspired offline routing later.
### Map display
- MapLibre-compatible client.
- Vector tiles from self-hosted OpenMapTiles, Protomaps/PMTiles, MapTiler, or equivalent.
- Do not rely on public OSM tile servers for production app traffic.
### Data pipeline
Pipeline jobs:
1. Download OSM extracts.
2. Normalize bikepacking-relevant tags.
3. Build route graph or send extract to routing engine.
4. Import POIs.
5. Attach elevation.
6. Import protected areas/rules.
7. Generate derived segment scores.
8. Build offline pack manifests.
## Services
```text
Client Apps
|-- Web planner
|-- Mobile rider app
Backend API
|-- Auth/session
|-- Trip planner
|-- Stage planner
|-- Route scorer
|-- POI corridor service
|-- Offline pack service
|-- Export service
|-- Community report service
|-- Rules service
Provider Layer
|-- Routing provider
|-- Elevation provider
|-- POI provider
|-- Weather provider
|-- Accommodation provider
|-- Device export provider
Data Stores
|-- PostGIS
|-- Object storage
|-- Cache/queue
```
## Provider interfaces
Every external dependency should be swappable.
```ts
interface RoutingProvider {
planRoute(request: RoutePlanRequest): Promise<RouteGeometryResult>;
reroute(request: RerouteRequest): Promise<RouteGeometryResult>;
}
interface PoiProvider {
searchCorridor(request: CorridorPoiRequest): Promise<Poi[]>;
}
interface ElevationProvider {
samplePolyline(polyline: Coordinate[]): Promise<ElevationSample[]>;
}
interface WeatherProvider {
forecastAlongRoute(request: WeatherRouteRequest): Promise<RouteWeatherRisk[]>;
}
interface RulesProvider {
getRuleCards(request: RuleCardRequest): Promise<RuleCard[]>;
}
```
## MVP architecture simplification
For first implementation:
- use a hosted routing provider or mock routing fixture;
- use OSM-derived POI fixtures in PostGIS;
- implement stage planning and scoring locally;
- render a route and stage cards;
- export GPX;
- define offline pack manifest without full tile download implementation.

124
docs/04_data_sources.md Normal file
View File

@@ -0,0 +1,124 @@
# Data Sources
Verified source links are listed in `docs/source_references.md`.
## Core principle
The raw route network can be built from OpenStreetMap. The competitive advantage comes from derived intelligence:
- route segment suitability,
- loaded-bike rideability,
- route-corridor logistics,
- water/food/sleep reliability,
- local rules confidence,
- trusted local reports,
- actual ride feedback.
## Data-source matrix
| Domain | Source candidates | MVP use | Production use | Caveats |
|---|---|---|---|---|
| Base map/network | OpenStreetMap extracts via Geofabrik or planet.osm | Road/path graph, surfaces, access, POIs | Full regional/global ingestion | ODbL obligations; data quality varies |
| Routing engine | openrouteservice, GraphHopper, Valhalla, BRouter | Hosted API or local engine adapter | Self-hosted customized profiles | Requires tuning; public APIs have limits |
| Elevation | Copernicus DEM GLO-30/GLO-90, NASA SRTM | Elevation profile and grade warnings | Segment-level grade/hike-a-bike modeling | DEM artifacts; bridges/tunnels need handling |
| Vector maps | OpenMapTiles, Protomaps/PMTiles, MapTiler, custom tiles | Web/mobile map display | Offline vector packs | Attribution and tile generation costs |
| POIs | OSM POIs, Overture Places, partner APIs | Water/food/sleep/repair corridor | Reliability scoring and deduplication | POIs stale/incomplete; licensing varies |
| Protected areas | Protected Planet WDPA/WDPCA, national/regional open data | Rule warnings | Rule confidence engine | Rules are jurisdiction-specific |
| Weather | Open-Meteo, MET Norway/Yr, national weather services, commercial APIs | Forecast along route | Weather-aware rerouting | Forecast uncertainty; API licensing |
| Accommodation | OSM campsites/shelters, Booking Demand API, direct campsite/hut partners, Warmshowers-style partnerships | Deep links and search cards | Reservation/check availability where allowed | API access/partner terms |
| Bike devices | GPX/TCX/FIT export, Garmin Courses API, Wahoo Cloud API | GPX export | Direct sync | API approval required |
## OSM tags to prioritize
### Route network
- `highway=*`
- `bicycle=*`
- `access=*`
- `surface=*`
- `smoothness=*`
- `tracktype=*`
- `sac_scale=*`
- `mtb:scale=*`
- `incline=*`
- `ford=*`
- `bridge=*`
- `tunnel=*`
- `barrier=*`
- `route=bicycle`
- `network=*`
### Water
- `amenity=drinking_water`
- `drinking_water=yes/no`
- `natural=spring`
- `man_made=water_well`
- `amenity=fountain`
### Food and resupply
- `shop=supermarket`
- `shop=convenience`
- `shop=bakery`
- `amenity=cafe`
- `amenity=restaurant`
- `amenity=fast_food`
- `amenity=fuel`
### Sleep
- `tourism=camp_site`
- `tourism=caravan_site`
- `tourism=alpine_hut`
- `tourism=wilderness_hut`
- `tourism=hostel`
- `tourism=hotel`
- `amenity=shelter`
### Bike repair
- `shop=bicycle`
- `amenity=bicycle_repair_station`
- `service:bicycle:repair=yes`
- `service:bicycle:pump=yes`
### Bailout
- `railway=station`
- `public_transport=station`
- `amenity=bus_station`
- `route=ferry`
## Data ingestion notes
### OSM
Use regional PBF extracts for development. Do not query public Overpass for production-scale route-corridor POI needs. Import regional extracts into PostGIS or a search index.
### POI deduplication
POIs may appear in multiple sources. Use a deduplication key based on:
- geohash proximity,
- normalized name,
- category,
- phone/website if available,
- source reliability.
### Reliability scoring
Each POI should have:
- `source`,
- `last_seen_at`,
- `last_confirmed_by_user_at`,
- `seasonality`,
- `opening_hours`,
- `confidence`,
- `bikepacker_notes_count`,
- `negative_reports_count`.
### Rules
Rules must be jurisdiction-specific and confidence-scored. Never present legal/camping rules as absolute unless sourced from official/local authority data and current.

View File

@@ -0,0 +1,156 @@
# Routing and Scoring Model
## Goal
Optimize for a loaded bikepacking trip, not a generic bike route.
## Route cost model
A candidate route is scored by segment and by whole-trip logistics.
```text
segment_cost =
distance_cost
+ climb_cost
+ steepness_cost
+ poor_surface_cost
+ traffic_stress_cost
+ access_uncertainty_cost
+ hike_a_bike_risk_cost
+ remoteness_cost
+ weather_sensitivity_cost
- official_cycle_route_bonus
- confirmed_good_segment_bonus
- scenic_bonus
```
```text
stage_cost =
ride_effort_cost
+ sleep_risk_cost
+ water_gap_cost
+ food_gap_cost
+ daylight_risk_cost
+ weather_risk_cost
+ bailout_gap_cost
+ repair_gap_cost
```
## Segment attributes
Each segment should carry:
- distance meters,
- ascent/descent,
- max grade,
- average grade,
- surface,
- smoothness,
- OSM highway class,
- bicycle/access tags,
- traffic stress proxy,
- cycle route membership,
- protected-area overlap,
- POI distance to next water/food/repair/sleep,
- community report score,
- confidence score.
## Bikepacking profiles
### Loaded gravel
- Prefer gravel/compact dirt and low-traffic roads.
- Penalize technical MTB trails.
- Penalize grades above 12% more heavily when loaded.
- Penalize unknown surfaces in remote areas.
### Hardtail bikepacking
- Allow more rough surfaces.
- Allow singletrack where bicycle access and difficulty are acceptable.
- Penalize sustained paved highways and high traffic.
### Road touring
- Prefer paved low-traffic roads and official cycle routes.
- Avoid rough surfaces, tracks, and paths with unknown rideability.
### E-bikepacking
- Add range and charging constraints.
- Penalize long remote stretches without accommodation/charging.
- Respect local e-bike access distinctions where available.
### Beginner-safe
- Strongly prefer known surfaces and legal certainty.
- Avoid high traffic, remote stretches, long dry gaps, and steep grades.
## Confidence model
The app should show both recommendation and confidence.
Example route summary:
```text
Distance: 382 km
Ascent: 5,420 m
Known surface: 88%
Unpaved: 42%
Longest water gap: 36 km
Longest food gap: 58 km
Access confidence: Medium-high
Sleep certainty: 4/5 nights confirmed options
Bailout gap: Max 71 km from train/ferry/bus
Warnings: 2 steep loaded-bike climbs, 1 uncertain access segment
```
## Hike-a-bike risk
Estimate using:
- steep grade,
- poor smoothness,
- `mtb:scale`,
- `sac_scale`,
- surface type,
- trail width if available,
- rider profile,
- actual ride speed reports,
- user reports.
A route can include hike-a-bike if the profile allows it, but it must be visible.
## Critical gap detection
Calculate gaps along the route:
- water gap,
- food gap,
- sleep gap,
- repair gap,
- bailout gap,
- charging gap for e-bike.
Warnings should be generated when a gap exceeds profile thresholds.
## Stage planning algorithm
1. Generate candidate endpoints every 515 km around the daily target window.
2. Score each endpoint by sleep options, water/food, daylight fit, safety, and bailout.
3. Prefer endpoints with at least one high-confidence sleep option.
4. Add Plan B endpoints before and after Plan A.
5. Rebalance subsequent stages to avoid one impossible day.
6. Surface warnings for stages where no good endpoint exists.
## Example thresholds
| Profile | Target km/day | Max grade warning | Max water gap | Max food gap |
|---|---:|---:|---:|---:|
| Beginner-safe | 4070 | 10% | 25 km | 40 km |
| Loaded gravel | 60100 | 12% | 40 km | 70 km |
| Hardtail remote | 5090 | 15% | 55 km | 90 km |
| Road touring | 70130 | 10% | 40 km | 70 km |
| E-bikepacking | 50100 | 12% | 40 km | 60 km + charging |
Thresholds should be configurable per user.

View File

@@ -0,0 +1,89 @@
# Offline Strategy
## Offline requirement
Offline use must cover riding-critical information, not only maps.
## Offline pack contents
A trip offline pack should include:
- route geometry,
- route alternatives,
- daily stages,
- elevation profile,
- selected vector/raster map area,
- route-corridor POIs,
- emergency/bailout POIs,
- local rule cards,
- cached weather forecast at departure,
- saved bookings/deep links/addresses,
- user notes,
- GPX/TCX/FIT exports,
- report queue for later upload.
## Pack scope
Use a corridor-based pack:
```text
primary corridor: 5 km on either side of route
logistics corridor: 20 km around stage endpoints and bailout towns
map context: bounding box with simplification by zoom level
```
## Offline modes
### MVP offline mode
- Download planned route, POIs, stage plan, rules, and GPX.
- Allow map viewing if tile pack is present.
- Allow report capture and sync later.
- Allow basic distance-to-next-critical-thing calculation from cached data.
### Production offline mode
- Offline rerouting using a compact regional routing graph.
- Offline POI search.
- Offline rule lookup.
- Offline elevation sampling.
- Offline exports.
## Storage model
Mobile app storage:
- SQLite for structured trip data and POIs.
- File storage for tile packs and exports.
- Key-value storage for user preferences and sync state.
## Pack versioning
Each offline pack should include:
```json
{
"pack_id": "pack_123",
"trip_id": "trip_123",
"created_at": "2026-06-30T00:00:00Z",
"data_versions": {
"osm_extract": "geofabrik-europe-germany-2026-06-29",
"poi_index": "2026-06-29",
"rules": "2026-06-01",
"weather_cached_at": "2026-06-30T06:00:00Z"
},
"bbox": [6.0, 47.0, 15.0, 55.0],
"route_buffer_km": 5,
"expires_at": "2026-07-14T00:00:00Z"
}
```
## User-facing warnings
Before departure, show:
- which items are available offline,
- which items are stale,
- which route sections lack cached POIs,
- whether offline rerouting is available,
- which exports are saved.

108
docs/07_ux_flows.md Normal file
View File

@@ -0,0 +1,108 @@
# UX Flows
## Main app surfaces
### 1. Trip planner
Map plus side panel:
- start/end/import GPX,
- dates,
- bike/load type,
- daily target,
- surface preference,
- sleep preference,
- water/food interval,
- routing strictness,
- bailout preference.
Output:
- route line,
- stage cards,
- warnings,
- POI layer toggles,
- export/offline actions.
### 2. Stage timeline
A vertical list of days:
```text
Day 1 — 74 km — 1,150 m climb — 38% unpaved
Sleep: campsite at km 72, hostel at km 78
Water: km 18, 51, 69
Food: supermarket km 42, bakery km 63
Warning: last reliable water before 11 km climb
Bailout: train station +5 km from route at km 58
```
### 3. Riding mode
Large simple panel:
```text
Next water: 12.4 km
Next food: 28.0 km, closes 18:00
Sleep Plan A: 31.5 km
Daylight left: 3h 40m
Bailout: train station 9.1 km detour
Warning: remote section starts in 6 km
```
Actions:
- reroute to water,
- reroute to sleep,
- skip climb,
- find bike shop,
- find bailout,
- report issue.
### 4. POI detail card
For a water point:
```text
Village fountain
Type: drinking water
Distance: 12.4 km on route
Confidence: Medium
Last confirmed: 18 days ago
Notes: Tap on cemetery wall; sometimes locked after 20:00
Actions: Navigate / Save / Report unavailable
```
### 5. Rule card
```text
Wild camping rule — Bavaria, Germany
Status: Restricted / unclear
Summary: Overnight camping outside designated sites may be restricted. Bivouac rules vary by land type and protected areas.
Confidence: Medium
Source: official/regional source URL
Last reviewed: 2026-06-01
App advice: prefer listed campsites/shelters; avoid protected-area bivouac unless locally permitted.
```
## Map layers
- Route/stages.
- Surface and roughness.
- Legal/access confidence.
- Water.
- Food/resupply.
- Sleep.
- Repair.
- Bailout.
- Local reports.
- Weather risk.
- Protected areas/local rules.
## Interaction principles
- Always show confidence and stale data indicators.
- Optimize for quick decisions during riding.
- Avoid hiding critical logistics behind filters.
- Default to route-corridor relevance instead of global POI clutter.
- Explain routing choices with concrete reasons.

181
docs/08_data_model.md Normal file
View File

@@ -0,0 +1,181 @@
# Data Model
The authoritative DDL is in `schemas/database.sql`. This document explains the domain model.
## Core entities
### User
Stores account, preferences, privacy settings, and default bikepacking profile.
### Trip
A planned multi-day journey.
Fields:
- id,
- owner_id,
- title,
- start/end coordinates,
- date range,
- profile,
- status,
- created_at,
- updated_at.
### Route
A route generated for a trip.
Fields:
- id,
- trip_id,
- provider,
- geometry,
- distance,
- ascent/descent,
- surface breakdown,
- access confidence,
- warnings.
### Route segment
A normalized route chunk with scoring attributes.
Fields:
- geometry,
- distance,
- grade,
- surface,
- smoothness,
- highway class,
- access tags,
- risk scores,
- confidence.
### Stage
A daily ride slice.
Fields:
- day_index,
- route_start_m,
- route_end_m,
- distance,
- ascent,
- estimated_time,
- endpoint,
- sleep candidates,
- warnings,
- Plan A/B.
### POI
A point of interest for logistics.
Categories:
- water,
- food,
- sleep,
- repair,
- bailout,
- charging,
- emergency.
### Service gap
A computed gap between relevant POIs along the route.
Fields:
- type,
- start_m,
- end_m,
- distance,
- severity,
- explanation.
### Local report
A typed, dated user/local report.
Fields:
- report type,
- geometry/point,
- route segment optional,
- payload,
- trust score,
- expiry date,
- moderation status.
### Rule card
A localized advisory card about riding/camping/access/fire/protected areas.
Fields:
- jurisdiction,
- rule type,
- summary,
- status,
- confidence,
- source URL,
- last reviewed,
- geometry.
### Offline pack
A generated set of data for a trip.
Fields:
- trip,
- bbox,
- corridor buffer,
- included layers,
- version metadata,
- file references,
- status.
## Important modeling choices
### Route distance index
Store positions along a route as `meters_from_start`. This allows POIs, warnings, reports, and stage boundaries to attach to the route even when coordinates are simplified.
### Confidence levels
Use a normalized enum:
- `known_good`,
- `high`,
- `medium`,
- `low`,
- `unknown`,
- `restricted`,
- `conflicting`.
### Warning severity
Use:
- `info`,
- `notice`,
- `warning`,
- `critical`.
### Report expiration
Most local reports should expire unless reconfirmed:
- water status: 3090 days,
- closure: 760 days depending source,
- surface condition: 1490 days,
- access/legal: reviewed manually,
- sleep hints: 90365 days.

121
docs/09_api_contract.md Normal file
View File

@@ -0,0 +1,121 @@
# API Contract
The machine-readable OpenAPI draft is in `schemas/openapi.yaml`.
## Core endpoints
### Create trip
`POST /v1/trips`
Creates a trip shell from user inputs.
### Plan route
`POST /v1/routes/plan`
Input:
- start/end/imported GPX,
- profile,
- constraints,
- preferred providers.
Output:
- route id,
- geometry,
- distance/elevation,
- scores,
- warnings,
- candidate alternatives.
### Plan stages
`POST /v1/stages/plan`
Input:
- route id,
- day count/date range,
- daily constraints,
- sleep preferences.
Output:
- stage cards,
- Plan A/B endpoints,
- sleep/food/water/repair/bailout candidates,
- stage warnings.
### Corridor POIs
`GET /v1/routes/{route_id}/pois?category=water&buffer_km=5`
Returns route-relevant POIs with distance along route, detour distance, confidence, and source.
### Critical gaps
`GET /v1/routes/{route_id}/gaps`
Returns computed gaps for water, food, sleep, repair, bailout, and charging.
### Rule cards
`GET /v1/rules?bbox=minLon,minLat,maxLon,maxLat&route_id=...`
Returns route- or area-specific advisory cards.
### Offline pack
`POST /v1/offline-packs`
Generates a manifest and download URLs for cached trip data.
### Export
`GET /v1/routes/{route_id}/export?format=gpx`
MVP: GPX. Later: TCX and FIT.
### Local report
`POST /v1/reports`
Submits a dated, location-bound structured report. The MVP response includes `moderationStatus`, `trustScore`, and `queuedOffline` when an offline client id is supplied.
### Report sync
`POST /v1/reports/sync`
Future endpoint. The current MVP queues reports client-side while offline and resubmits them through `POST /v1/reports`.
## Error handling
Errors should include:
- machine-readable code,
- human-readable message,
- remediation suggestion,
- provider/source if relevant.
Example:
```json
{
"error": "PROVIDER_UNAVAILABLE",
"message": "routing provider \"openrouteservice\" is not available in this MVP runtime. Set ROUTING_PROVIDER=mock for fixture mode or configure the selected provider adapter and credentials.",
"details": {
"providerName": "openrouteservice",
"providerType": "routing",
"setupHint": "Set ROUTING_PROVIDER=mock for fixture mode or configure the selected provider adapter and credentials."
}
}
```
## Current MVP notes
- `schemas/openapi.yaml` reflects implemented response fields including route segments, surface breakdown, attribution, stage POI buckets, offline manifest metadata, and report queue ids.
- `format=gpx` is implemented. TCX/FIT are reserved for future `DeviceExportProvider` adapters.
- Mock providers are deterministic and require no external keys.
- Rule cards and access/protected-area warnings are advisory and confidence-scored.

70
docs/10_integrations.md Normal file
View File

@@ -0,0 +1,70 @@
# Integrations
## Routing integrations
Use provider adapters.
### Prototype
- openrouteservice API adapter.
- Mock adapter for tests.
### Production
- GraphHopper or Valhalla self-hosted adapter.
- Custom bikepacking profile layer on top of base routing.
- Offline routing adapter later.
## Map integrations
- MapLibre-compatible rendering.
- Vector tiles from OpenMapTiles, Protomaps/PMTiles, MapTiler, or self-hosted pipeline.
- OSM attribution must be visible.
## Accommodation integrations
MVP should use:
- OSM campsites/shelters/huts/hostels/hotels,
- deep links to search platforms,
- manual entries/imports for partner datasets.
Later:
- Booking.com Demand API if accepted as partner,
- direct campsite/hut reservation systems,
- Warmshowers-style partner/deep links if official access exists.
## Weather integrations
MVP:
- forecast at stage endpoints and exposed segments.
- warnings for heat, rain, wind, storms, freezing nights.
Later:
- route-time weather simulation,
- weather-aware rerouting,
- mud risk after rainfall.
## Device integrations
MVP:
- GPX export.
Next:
- TCX export with course points.
- FIT export.
Later:
- Garmin Courses API subject to approval.
- Wahoo Cloud API subject to approval and use-case fit.
- Hammerhead/Karoo sync if public/partner APIs allow.
## Authentication and partnerships
Do not assume public access to commercial APIs. Use deep links first and provider interfaces so partner integrations can be added without changing the domain model.

View File

@@ -0,0 +1,78 @@
# Local Rules and Liability
## Principle
Rules must be shown as advisory information with source, date, jurisdiction, confidence, and limits.
Do not claim legal certainty unless the rule comes from a current official source and the geographic/jurisdiction match is clear.
## Rule categories
- cycling access,
- trail access,
- private land,
- protected area,
- wild camping,
- bivouac,
- fire bans,
- e-bike access/class,
- ferry/border constraints,
- seasonal closures,
- hunting/forestry closures,
- drone restrictions if relevant.
## Confidence states
- `known_allowed`,
- `known_restricted`,
- `seasonal_or_conditional`,
- `likely_allowed`,
- `likely_restricted`,
- `unclear`,
- `conflicting_sources`,
- `needs_local_confirmation`.
## Source hierarchy
Prefer:
1. official authority datasets/pages,
2. protected-area authority pages,
3. national/regional open data portals,
4. OSM access tags,
5. partner/local expert data,
6. user reports.
## User-facing copy examples
### Access warning
```text
Access uncertainty ahead
This forest track has incomplete bicycle access data and crosses a protected-area boundary. Use caution and follow local signs.
```
### Camping warning
```text
Wild-camping rules unclear
This area may restrict overnight camping outside designated sites. Prefer listed campsite/shelter options unless you have local permission.
```
### Seasonal closure
```text
Possible seasonal restriction
This segment overlaps a protected area. Check local signs and current authority notices before entering.
```
## Liability mitigations
- Show source and review date.
- Show uncertainty.
- Provide alternatives.
- Let users report errors.
- Avoid routing through known restricted/private segments by default.
- Keep critical legal rules server-updatable.
- Do not monetize illegal camping hints.
- Moderate sensitive reports.

View File

@@ -0,0 +1,80 @@
# Community and Local Intelligence
## Objective
Turn field reports into a structured, trust-scored, time-aware route quality layer.
## Report types
- `water_available`
- `water_unavailable`
- `surface_wrong`
- `rough_loaded_bike`
- `hike_a_bike`
- `locked_gate`
- `private_access`
- `closure`
- `dangerous_dogs`
- `mud_sand_washout`
- `bridge_out`
- `ferry_issue`
- `sleep_option_good`
- `sleep_option_bad`
- `shop_closed_or_missing`
- `repair_available`
- `reroute_recommended`
## Report payload example
```json
{
"type": "water_unavailable",
"location": { "lat": 47.1234, "lon": 11.5678 },
"route_id": "route_123",
"meters_from_start": 84231,
"observed_at": "2026-06-30T14:30:00Z",
"details": "Tap locked; no public access after 18:00.",
"photo_optional": true
}
```
## Trust model
Signals:
- account age,
- number of confirmed reports,
- local status,
- photo/GPS evidence,
- agreement with other riders,
- recency,
- conflict count.
## Expiry model
Reports should not live forever.
Default expiration:
- water status: 60 days,
- closures: 30 days,
- mud/surface condition: 30 days,
- locked gate: 90 days,
- shop status: 90 days,
- legal/access reports: manual review,
- local hints: 180 days.
## Moderation
Moderate:
- illegal camping instructions,
- private property entry hints,
- sensitive locations,
- abuse/harassment,
- unsafe advice,
- business defamation.
## OSM feedback loop
Objective, verifiable corrections can be contributed back to OSM by trained editors following OSM community norms. Subjective suitability, safety, and camping hints should remain in the app layer unless they map to accepted OSM tags and are verifiable.

View File

@@ -0,0 +1,54 @@
# Security and Privacy
## Sensitive data
The app may process:
- location history,
- planned travel dates,
- sleep locations,
- emergency contacts,
- account identity,
- API tokens,
- community reports,
- booking/deep-link actions.
## Privacy requirements
- Default trips to private.
- Let users delete trips and reports where legally possible.
- Do not share exact sleep plans publicly by default.
- Minimize continuous location collection.
- Allow offline use without constant tracking.
- Separate analytics from precise route history.
- Hash/anonymize telemetry where possible.
## Security requirements
- Store secrets in environment variables or secret manager.
- Use short-lived signed URLs for offline-pack downloads.
- Validate GPX uploads.
- Sanitize user-generated content.
- Rate-limit report submission.
- Protect against route/POI scraping if commercial data is included.
- Keep provider API keys server-side.
## Compliance notes
For EU users, assume GDPR obligations:
- explicit consent for location tracking,
- data export/deletion,
- purpose limitation,
- retention policy,
- processor agreements for analytics/cloud vendors.
## Safety disclaimers
Show concise warnings:
- route data may be wrong,
- access rules can change,
- weather forecasts are uncertain,
- users must obey signs and local laws,
- remote riding requires judgment and preparation.

63
docs/14_testing_plan.md Normal file
View File

@@ -0,0 +1,63 @@
# Testing Plan
## Unit tests
### Scoring
- penalizes steep loose climbs for loaded gravel profile;
- allows rougher segments for hardtail profile;
- penalizes unknown access;
- rewards official cycle routes;
- increases severity for long water gaps;
- detects no sleep options near target endpoint.
### Staging
- splits route into daily ranges;
- avoids impossible climbing days;
- chooses endpoints with sleep candidates;
- produces Plan B endpoints;
- rebalances when one day exceeds constraints.
### POI corridor
- finds POIs within buffer;
- sorts by distance along route;
- deduplicates overlapping sources;
- computes detour distance;
- respects category filters.
### Offline manifest
- includes required layers;
- records source versions;
- flags stale data;
- supports report sync queue.
## Integration tests
- route planning provider returns normalized geometry;
- stage planner consumes route geometry and POIs;
- GPX export round-trips with common viewers;
- API returns consistent warnings from fixtures;
- map renders route/stages/POIs.
## Fixture tests
Use sample fixtures in `examples/`:
- simple 2-day route,
- remote route with water gap,
- route with uncertain access,
- route with sleep endpoints,
- route with steep rough segment.
## Manual QA scenarios
1. Plan 3-day trip with campsites only.
2. Plan 5-day trip with indoor sleep every third night.
3. Import GPX and ask app to stage it.
4. Download offline pack and simulate airplane mode.
5. Submit water-unavailable report offline and sync later.
6. Export GPX and import into Garmin Connect or another route viewer.
7. Check that legal/rule warnings show source and uncertainty.

58
docs/15_roadmap.md Normal file
View File

@@ -0,0 +1,58 @@
# Roadmap
## Phase 0 — Repository and design foundation
- Create monorepo.
- Implement shared domain types.
- Add mock data fixtures.
- Add scoring/staging unit tests.
## Phase 1 — Web planner MVP
- Map UI.
- Trip request form.
- Route rendering.
- Stage cards.
- POI corridor display.
- Critical gap warnings.
- GPX export.
## Phase 2 — Backend MVP
- Route planning endpoint.
- Provider abstraction.
- Stage planner endpoint.
- POI corridor endpoint.
- Offline manifest endpoint.
- Report endpoint.
## Phase 3 — Real data integration
- OSM extract import.
- POI normalization.
- Elevation enrichment.
- Hosted/self-hosted routing engine adapter.
- Rule card ingestion for one pilot region.
## Phase 4 — Mobile/offline MVP
- Mobile route/stage viewer.
- Offline trip cache.
- Next-critical-thing panel.
- Offline report queue.
- Basic reroute to cached POI or external navigation deep link.
## Phase 5 — Advanced bikepacking intelligence
- Segment-level suitability scoring from ridden data.
- Trusted local reports.
- Weather-aware warnings.
- Sleep reliability score.
- Adaptive route planning based on actual rider speed.
## Phase 6 — Partnerships and device sync
- Accommodation partner APIs/deep links.
- Garmin/Wahoo integration subject to approval.
- Hut/campsite partner feeds.
- Regional tourism board routes and rules.

91
docs/source_references.md Normal file
View File

@@ -0,0 +1,91 @@
# Source References
Verified on 2026-06-30. Check terms again before production use.
## Baseline map/routing data
- Komoot support: the Komoot map is based on OpenStreetMap data for map display, route planning, and POIs.
https://support.komoot.com/hc/en-us/articles/10194668255258-Improve-the-komoot-Map
- OpenStreetMap ODbL license information.
https://wiki.openstreetmap.org/wiki/Open_Database_License
https://osmfoundation.org/wiki/Licence/Licence_and_Legal_FAQ
- OSM tile usage policy: OSM data is free; public tile servers are capacity-limited and not for heavy production app traffic.
https://operations.osmfoundation.org/policies/tiles/
- Geofabrik regional OSM extracts.
https://download.geofabrik.de/
https://www.geofabrik.de/data/download.html
- Planet OSM weekly full database dump.
https://planet.openstreetmap.org/
https://wiki.openstreetmap.org/wiki/Planet.osm
- Overpass API.
https://wiki.openstreetmap.org/wiki/Overpass_API
## Routing engines
- openrouteservice API and backend docs.
https://openrouteservice.org/
https://giscience.github.io/openrouteservice/api-reference/endpoints/directions/
- GraphHopper routing engine.
https://github.com/graphhopper/graphhopper
- Valhalla routing engine.
https://github.com/valhalla/valhalla
https://valhalla.github.io/valhalla/api/turn-by-turn/overview/
- BRouter offline bicycle routing.
https://brouter.de/brouter/
https://brouter.de/brouter/offline.html
## Elevation
- Copernicus DEM GLO-30/GLO-90.
https://dataspace.copernicus.eu/explore-data/data-collections/copernicus-contributing-missions/collections-description/COP-DEM
- NASA SRTM.
https://www.earthdata.nasa.gov/data/instruments/srtm
## Map display and offline map options
- MapLibre GL JS.
https://maplibre.org/
https://www.maplibre.org/maplibre-gl-js/docs/
- OpenMapTiles.
https://openmaptiles.org/
- Protomaps / PMTiles.
https://protomaps.com/
https://docs.protomaps.com/guide/getting-started
## POIs, rules, weather, accommodation
- Overture Maps Places.
https://docs.overturemaps.org/guides/places/
- Protected Planet / WDPA/WDPCA.
https://www.protectedplanet.net/en/thematic-areas/wdpa
https://www.protectedplanet.net/en
- Open-Meteo Forecast API.
https://open-meteo.com/en/docs
- Booking.com Demand API.
https://developers.booking.com/demand
https://developers.booking.com/demand/docs/getting-started/overview
## Device integrations
- Garmin Courses API.
https://developer.garmin.com/gc-developer-program/courses-api/
- Garmin Connect course file import.
https://support.garmin.com/en-US/?faq=wKuZXCaZRP4mWPX5aRz5h5
- Wahoo Cloud API.
https://developers.wahooligan.com/cloud

View File

@@ -0,0 +1,45 @@
# Example Overpass Queries for Prototyping
Do not use public Overpass instances for production-scale app traffic. These queries are for small-area prototyping only.
## Drinking water in a bounding box
```overpass
[out:json][timeout:25];
(
node["amenity"="drinking_water"]({{bbox}});
way["amenity"="drinking_water"]({{bbox}});
relation["amenity"="drinking_water"]({{bbox}});
node["drinking_water"="yes"]({{bbox}});
node["natural"="spring"]({{bbox}});
);
out center tags;
```
## Bike shops and repair stations
```overpass
[out:json][timeout:25];
(
node["shop"="bicycle"]({{bbox}});
way["shop"="bicycle"]({{bbox}});
node["amenity"="bicycle_repair_station"]({{bbox}});
node["service:bicycle:repair"="yes"]({{bbox}});
);
out center tags;
```
## Campsites, huts, and shelters
```overpass
[out:json][timeout:25];
(
node["tourism"="camp_site"]({{bbox}});
way["tourism"="camp_site"]({{bbox}});
node["tourism"="wilderness_hut"]({{bbox}});
way["tourism"="wilderness_hut"]({{bbox}});
node["amenity"="shelter"]({{bbox}});
way["amenity"="shelter"]({{bbox}});
);
out center tags;
```

View File

@@ -0,0 +1,25 @@
{
"profile": "loaded_gravel",
"segment": {
"distanceM": 1800,
"avgGradePercent": 9.8,
"maxGradePercent": 16.2,
"surface": "gravel",
"smoothness": "bad",
"highway": "track",
"bicycleAccess": "unknown",
"legalAccessConfidence": "unknown",
"trafficStress": 0.1,
"officialCycleRoute": false,
"protectedAreaOverlap": true,
"sourceConfidence": "medium"
},
"expected": {
"hikeABikeRisk": "high",
"warnings": [
"STEEP_LOADED_BIKE_CLIMB",
"UNKNOWN_ACCESS",
"PROTECTED_AREA_OVERLAP"
]
}
}

View File

@@ -0,0 +1,82 @@
{
"routeId": "route_demo_001",
"stages": [
{
"dayIndex": 1,
"distanceKm": 72.4,
"ascentM": 980,
"surface": {
"paved": 0.56,
"gravel": 0.34,
"dirt": 0.1
},
"sleep": [
{
"name": "Lakeside campsite",
"confidence": "high",
"detourKm": 0.8
}
],
"water": [
{
"km": 18.2,
"name": "Village fountain"
},
{
"km": 50.9,
"name": "Cemetery tap"
}
],
"food": [
{
"km": 43.0,
"name": "Supermarket"
}
],
"warnings": [
"Last reliable water before 11 km climb"
]
},
{
"dayIndex": 2,
"distanceKm": 81.7,
"ascentM": 1420,
"surface": {
"paved": 0.38,
"gravel": 0.52,
"dirt": 0.1
},
"sleep": [
{
"name": "Mountain hostel",
"confidence": "medium",
"detourKm": 2.1
}
],
"water": [
{
"km": 14.3,
"name": "Spring"
},
{
"km": 61.1,
"name": "Public tap"
}
],
"food": [
{
"km": 23.0,
"name": "Bakery"
},
{
"km": 65.4,
"name": "Gas station"
}
],
"warnings": [
"One uncertain access segment",
"Steep loose climb at km 57"
]
}
]
}

View File

@@ -0,0 +1,31 @@
{
"start": {
"lat": 48.137,
"lon": 11.575
},
"end": {
"lat": 47.269,
"lon": 11.404
},
"startDate": "2026-07-10",
"endDate": "2026-07-14",
"profile": "loaded_gravel",
"sleepPreference": "mixed_budget",
"dailyDistanceKm": {
"min": 55,
"target": 75,
"max": 95
},
"dailyAscentM": {
"target": 1100,
"max": 1800
},
"maxLoadedGradePercent": 12,
"preferredUnpavedPercent": {
"min": 30,
"max": 70
},
"waterGapMaxKm": 40,
"foodGapMaxKm": 70,
"requireBailoutEveryKm": 90
}

3734
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "pikebacker",
"version": "0.1.0",
"private": true,
"type": "module",
"workspaces": [
"apps/*",
"services/*",
"packages/*"
],
"scripts": {
"build": "npm -w @pikebacker/shared run build && npm -w @pikebacker/api run build && npm -w @pikebacker/web run build && npm -w @pikebacker/mobile run build && npm -w @pikebacker/data-pipeline run build",
"test": "npm -w @pikebacker/shared run test && npm -w @pikebacker/api run test",
"typecheck": "npm -w @pikebacker/shared run build && npm -w @pikebacker/shared run typecheck && npm -w @pikebacker/api run typecheck && npm -w @pikebacker/web run typecheck && npm -w @pikebacker/mobile run typecheck && npm -w @pikebacker/data-pipeline run typecheck",
"dev:api": "npm -w @pikebacker/api run dev",
"dev:web": "npm -w @pikebacker/web run dev",
"fixtures:pois": "npm -w @pikebacker/data-pipeline run normalize:sample"
},
"devDependencies": {
"@types/node": "^22.10.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^4.1.9"
}
}

28
packages/shared/README.md Normal file
View File

@@ -0,0 +1,28 @@
# Shared Package
Purpose: shared domain types and pure logic.
Implement here:
- domain types,
- profile thresholds,
- segment scoring,
- route scoring,
- critical gap detection,
- stage helper functions,
- GPX serialization helpers if shared with API.
Pure functions should have unit tests and deterministic fixtures.
## Implemented functions
- `scoreSegment(segment, profile)`
- `scoreRoute(route, profile)`
- `estimateHikeABikeRisk(segment, profile)`
- `filterPoisByCorridor(pois, route, filter)`
- `detectCriticalGaps(route, pois, thresholds)`
- `planStages(route, pois, tripRequest)`
- `exportRouteToGpx(route, stages, pois, options)`
- `createOfflinePackManifest(input)`
The mock trip request is kept aligned with `examples/sample_trip_request.json`; tests cover scoring, staging, POI corridor/gaps, and GPX export.

View File

@@ -0,0 +1,22 @@
{
"name": "@pikebacker/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"fast-xml-parser": "^5.9.3"
}
}

7
packages/shared/src/fixtures.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
import type { Poi, RoutePlan, RouteSegment, RuleCard, TripRequest } from './types.js';
export declare const mockTripRequest: TripRequest;
export declare const mockRouteSegments: RouteSegment[];
export declare function createMockRoute(tripId?: string): RoutePlan;
export declare const mockPois: Poi[];
export declare const mockRules: RuleCard[];
//# sourceMappingURL=fixtures.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"fixtures.d.ts","sourceRoot":"","sources":["fixtures.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAEtF,eAAO,MAAM,eAAe,EAAE,WAc7B,CAAC;AA6CF,eAAO,MAAM,iBAAiB,EAAE,YAAY,EAyB1C,CAAC;AAEH,wBAAgB,eAAe,CAAC,MAAM,SAAkB,GAAG,SAAS,CAyBnE;AAED,eAAO,MAAM,QAAQ,EAAE,GAAG,EA4BzB,CAAC;AAEF,eAAO,MAAM,SAAS,EAAE,QAAQ,EAqB/B,CAAC"}

View File

@@ -0,0 +1,175 @@
import { scoreRoute } from './scoring.js';
export const mockTripRequest = {
start: { lat: 48.137, lon: 11.575 },
end: { lat: 47.269, lon: 11.404 },
startDate: '2026-07-10',
endDate: '2026-07-14',
profile: 'loaded_gravel',
sleepPreference: 'mixed_budget',
dailyDistanceKm: { min: 55, target: 75, max: 95 },
dailyAscentM: { target: 1100, max: 1800 },
maxLoadedGradePercent: 12,
preferredUnpavedPercent: { min: 30, max: 70 },
waterGapMaxKm: 40,
foodGapMaxKm: 70,
requireBailoutEveryKm: 90
};
const routeCoordinates = [
{ lat: 48.137, lon: 11.575 },
{ lat: 48.045, lon: 11.49 },
{ lat: 47.965, lon: 11.335 },
{ lat: 47.83, lon: 11.19 },
{ lat: 47.705, lon: 11.095 },
{ lat: 47.612, lon: 11.23 },
{ lat: 47.52, lon: 11.085 },
{ lat: 47.45, lon: 11.245 },
{ lat: 47.39, lon: 11.08 },
{ lat: 47.315, lon: 11.205 },
{ lat: 47.255, lon: 11.07 },
{ lat: 47.18, lon: 11.215 },
{ lat: 47.235, lon: 11.36 },
{ lat: 47.13, lon: 11.47 },
{ lat: 47.205, lon: 11.555 },
{ lat: 47.269, lon: 11.404 }
];
const segmentDistances = [
22000, 24000, 25000, 26000, 22000, 28000, 23000, 26000, 24000, 28000, 25000, 28000, 24000, 30000, 27000
];
const segmentAscents = [180, 260, 420, 650, 310, 520, 210, 430, 390, 620, 280, 510, 240, 480, 520];
const segmentDescents = [120, 180, 260, 330, 420, 270, 250, 380, 290, 460, 420, 310, 350, 420, 540];
const surfaces = [
'asphalt',
'gravel',
'fine_gravel',
'dirt',
'gravel',
'compacted',
'asphalt',
'gravel',
'dirt',
'gravel',
'asphalt',
'track',
'fine_gravel',
'gravel',
'asphalt'
];
export const mockRouteSegments = segmentDistances.map((distanceM, index) => {
const startMeters = segmentDistances.slice(0, index).reduce((sum, value) => sum + value, 0);
const endMeters = startMeters + distanceM;
const segmentId = `seg_demo_${String(index + 1).padStart(2, '0')}`;
return {
id: segmentId,
geometry: [routeCoordinates[index] ?? routeCoordinates[0], routeCoordinates[index + 1] ?? routeCoordinates[routeCoordinates.length - 1]],
distanceM,
startMeters,
endMeters,
ascentM: segmentAscents[index] ?? 0,
descentM: segmentDescents[index] ?? 0,
avgGradePercent: index === 9 ? 8.4 : index === 3 ? 7.6 : 3 + (index % 4),
maxGradePercent: index === 9 ? 16.4 : index === 3 ? 13.8 : 8 + (index % 4),
surface: surfaces[index],
smoothness: index === 9 ? 'bad' : index === 11 ? 'intermediate' : 'good',
highway: index === 11 ? 'track' : index === 9 ? 'track' : index % 3 === 0 ? 'cycleway' : 'unclassified',
bicycleAccess: index === 8 ? 'unknown' : 'yes',
legalAccessConfidence: index === 8 ? 'unknown' : index === 10 ? 'low' : 'high',
trafficStress: index === 10 ? 0.62 : index % 5 === 0 ? 0.34 : 0.18,
officialCycleRoute: index === 1 || index === 6 || index === 14,
protectedAreaOverlap: index === 9,
communityScore: index === 6 ? 0.8 : undefined,
sourceConfidence: index === 8 ? 'medium' : 'high'
};
});
export function createMockRoute(tripId = 'trip_demo_001') {
const baseRoute = {
id: 'route_demo_001',
tripId,
geometry: routeCoordinates,
distanceM: segmentDistances.reduce((sum, value) => sum + value, 0),
ascentM: segmentAscents.reduce((sum, value) => sum + value, 0),
descentM: segmentDescents.reduce((sum, value) => sum + value, 0),
segments: mockRouteSegments,
surfaceBreakdown: {},
suitabilityScore: 0,
warnings: [],
provider: 'mock',
attribution: [
'Mock route fixture derived from OSM-like tags for MVP development.',
'Map and POI attribution hooks preserve OpenStreetMap contributor requirements.'
]
};
const routeScore = scoreRoute(baseRoute, mockTripRequest.profile);
return {
...baseRoute,
surfaceBreakdown: routeScore.surfaceBreakdown,
suitabilityScore: routeScore.suitabilityScore,
warnings: routeScore.warnings
};
}
export const mockPois = [
poi('poi_water_001', 'water', 'Village fountain', 18200, 48.075, 11.512, 'known_good', 25),
poi('poi_food_001', 'food', 'Bakery and mini market', 43000, 47.98, 11.37, 'high', 180),
poi('poi_water_002', 'water', 'Cemetery tap', 50900, 47.92, 11.27, 'high', 90),
poi('poi_sleep_001', 'sleep', 'Lakeside campsite', 72400, 47.805, 11.145, 'high', 800, { sleepType: 'campsite' }),
poi('poi_bailout_001', 'bailout', 'Regional rail station', 85000, 47.75, 11.12, 'high', 600),
poi('poi_water_003', 'water', 'Spring above forest road', 90000, 47.715, 11.12, 'medium', 120),
poi('poi_food_002', 'food', 'Gas station shop', 113000, 47.62, 11.22, 'high', 250),
poi('poi_repair_001', 'repair', 'Bike shop Murnau', 112000, 47.62, 11.235, 'high', 1300),
poi('poi_water_004', 'water', 'Public tap', 126000, 47.53, 11.105, 'medium', 210),
poi('poi_sleep_002', 'sleep', 'Mountain hostel', 154000, 47.505, 11.16, 'medium', 2100, { sleepType: 'hostel' }),
poi('poi_food_003', 'food', 'Village supermarket', 165000, 47.47, 11.22, 'high', 650),
poi('poi_water_005', 'water', 'Trailhead fountain', 174000, 47.43, 11.19, 'high', 80),
poi('poi_bailout_002', 'bailout', 'Bus stop with bike carriage', 175000, 47.42, 11.2, 'medium', 400),
poi('poi_water_006', 'water', 'Alpine hut tap', 225000, 47.29, 11.18, 'medium', 1400),
poi('poi_sleep_003', 'sleep', 'Farm campground', 228000, 47.275, 11.16, 'high', 900, { sleepType: 'campsite' }),
poi('poi_food_004', 'food', 'Bakery before pass', 236000, 47.25, 11.13, 'high', 500),
poi('poi_repair_002', 'repair', 'Repair station at trail hub', 266000, 47.18, 11.24, 'medium', 250),
poi('poi_bailout_003', 'bailout', 'Train station valley option', 265000, 47.18, 11.23, 'high', 1600),
poi('poi_water_007', 'water', 'Village fountain after pass', 282000, 47.22, 11.33, 'high', 70),
poi('poi_sleep_004', 'sleep', 'Budget inn', 305000, 47.16, 11.43, 'medium', 1700, { sleepType: 'hotel' }),
poi('poi_food_005', 'food', 'Supermarket last resupply', 312000, 47.14, 11.47, 'high', 600),
poi('poi_water_008', 'water', 'Town public tap', 339000, 47.19, 11.53, 'known_good', 45),
poi('poi_bailout_004', 'bailout', 'Mainline rail station', 350000, 47.215, 11.55, 'high', 900),
poi('poi_food_006', 'food', 'Innsbruck market hall', 370000, 47.25, 11.44, 'high', 400),
poi('poi_repair_003', 'repair', 'Innsbruck bike workshop', 380000, 47.267, 11.41, 'high', 500),
poi('poi_sleep_005', 'sleep', 'Finish hostel', 382000, 47.269, 11.404, 'high', 300, { sleepType: 'hostel' }),
poi('poi_water_009', 'water', 'Finish drinking fountain', 382000, 47.269, 11.404, 'high', 30)
];
export const mockRules = [
{
id: 'rule_bavaria_access_001',
ruleType: 'access',
jurisdiction: 'Bavaria mock corridor',
status: 'advisory',
confidence: 'medium',
summary: 'Forest and protected-area access can depend on local signage. Verify bicycle access at uncertain segments.',
sourceUrl: 'https://www.openstreetmap.org/copyright',
lastReviewedAt: '2026-07-02'
},
{
id: 'rule_tyrol_camping_001',
ruleType: 'camping',
jurisdiction: 'Tyrol mock corridor',
status: 'advisory',
confidence: 'low',
summary: 'Wild camping rules vary by municipality and protected status. Prefer official campsites or indoor lodging unless locally confirmed.',
sourceUrl: 'https://www.openstreetmap.org/copyright',
lastReviewedAt: '2026-07-02'
}
];
function poi(id, category, name, metersFromStart, lat, lon, confidence, detourDistanceM, tags) {
return {
id,
category,
name,
location: { lat, lon },
source: 'mock_osm_fixture',
confidence,
metersFromStart,
distanceFromRouteM: Math.min(detourDistanceM, 2000),
detourDistanceM,
tags,
lastConfirmedAt: '2026-07-02T00:00:00.000Z'
};
}
//# sourceMappingURL=fixtures.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import sampleTripRequest from '../../../examples/sample_trip_request.json';
import { mockTripRequest } from './fixtures.js';
describe('example fixtures', () => {
it('keeps the mock trip request aligned with examples/sample_trip_request.json', () => {
expect(mockTripRequest).toEqual(sampleTripRequest);
});
});

View File

@@ -0,0 +1,194 @@
import { scoreRoute } from './scoring.js';
import type { Poi, RoutePlan, RouteSegment, RuleCard, TripRequest } from './types.js';
export const mockTripRequest: TripRequest = {
start: { lat: 48.137, lon: 11.575 },
end: { lat: 47.269, lon: 11.404 },
startDate: '2026-07-10',
endDate: '2026-07-14',
profile: 'loaded_gravel',
sleepPreference: 'mixed_budget',
dailyDistanceKm: { min: 55, target: 75, max: 95 },
dailyAscentM: { target: 1100, max: 1800 },
maxLoadedGradePercent: 12,
preferredUnpavedPercent: { min: 30, max: 70 },
waterGapMaxKm: 40,
foodGapMaxKm: 70,
requireBailoutEveryKm: 90
};
const routeCoordinates = [
{ lat: 48.137, lon: 11.575, elevationM: 520 },
{ lat: 48.045, lon: 11.49, elevationM: 575 },
{ lat: 47.965, lon: 11.335, elevationM: 640 },
{ lat: 47.83, lon: 11.19, elevationM: 860 },
{ lat: 47.705, lon: 11.095, elevationM: 1160 },
{ lat: 47.612, lon: 11.23, elevationM: 1030 },
{ lat: 47.52, lon: 11.085, elevationM: 1210 },
{ lat: 47.45, lon: 11.245, elevationM: 1115 },
{ lat: 47.39, lon: 11.08, elevationM: 1300 },
{ lat: 47.315, lon: 11.205, elevationM: 1475 },
{ lat: 47.255, lon: 11.07, elevationM: 1320 },
{ lat: 47.18, lon: 11.215, elevationM: 1505 },
{ lat: 47.235, lon: 11.36, elevationM: 1380 },
{ lat: 47.13, lon: 11.47, elevationM: 1625 },
{ lat: 47.205, lon: 11.555, elevationM: 1450 },
{ lat: 47.269, lon: 11.404, elevationM: 575 }
];
const segmentDistances = [
22000, 24000, 25000, 26000, 22000, 28000, 23000, 26000, 24000, 28000, 25000, 28000, 24000, 30000, 27000
];
const segmentAscents = [180, 260, 420, 650, 310, 520, 210, 430, 390, 620, 280, 510, 240, 480, 520];
const segmentDescents = [120, 180, 260, 330, 420, 270, 250, 380, 290, 460, 420, 310, 350, 420, 540];
const surfaces = [
'asphalt',
'gravel',
'fine_gravel',
'dirt',
'gravel',
'compacted',
'asphalt',
'gravel',
'dirt',
'gravel',
'asphalt',
'track',
'fine_gravel',
'gravel',
'asphalt'
];
export const mockRouteSegments: RouteSegment[] = segmentDistances.map((distanceM, index) => {
const startMeters = segmentDistances.slice(0, index).reduce((sum, value) => sum + value, 0);
const endMeters = startMeters + distanceM;
const segmentId = `seg_demo_${String(index + 1).padStart(2, '0')}`;
return {
id: segmentId,
geometry: [routeCoordinates[index] ?? routeCoordinates[0]!, routeCoordinates[index + 1] ?? routeCoordinates[routeCoordinates.length - 1]!],
distanceM,
startMeters,
endMeters,
ascentM: segmentAscents[index] ?? 0,
descentM: segmentDescents[index] ?? 0,
avgGradePercent: index === 9 ? 8.4 : index === 3 ? 7.6 : 3 + (index % 4),
maxGradePercent: index === 9 ? 16.4 : index === 3 ? 13.8 : 8 + (index % 4),
surface: surfaces[index],
smoothness: index === 9 ? 'bad' : index === 11 ? 'intermediate' : 'good',
highway: index === 11 ? 'track' : index === 9 ? 'track' : index % 3 === 0 ? 'cycleway' : 'unclassified',
bicycleAccess: index === 8 ? 'unknown' : 'yes',
legalAccessConfidence: index === 8 ? 'unknown' : index === 10 ? 'low' : 'high',
trafficStress: index === 10 ? 0.62 : index % 5 === 0 ? 0.34 : 0.18,
officialCycleRoute: index === 1 || index === 6 || index === 14,
protectedAreaOverlap: index === 9,
communityScore: index === 6 ? 0.8 : undefined,
sourceConfidence: index === 8 ? 'medium' : 'high'
};
});
export function createMockRoute(tripId = 'trip_demo_001'): RoutePlan {
const baseRoute: RoutePlan = {
id: 'route_demo_001',
tripId,
geometry: routeCoordinates,
distanceM: segmentDistances.reduce((sum, value) => sum + value, 0),
ascentM: segmentAscents.reduce((sum, value) => sum + value, 0),
descentM: segmentDescents.reduce((sum, value) => sum + value, 0),
segments: mockRouteSegments,
surfaceBreakdown: {},
suitabilityScore: 0,
warnings: [],
provider: 'mock',
attribution: [
'Mock route fixture derived from OSM-like tags for MVP development.',
'Map and POI attribution hooks preserve OpenStreetMap contributor requirements.'
]
};
const routeScore = scoreRoute(baseRoute, mockTripRequest.profile);
return {
...baseRoute,
surfaceBreakdown: routeScore.surfaceBreakdown,
suitabilityScore: routeScore.suitabilityScore,
warnings: routeScore.warnings
};
}
export const mockPois: Poi[] = [
poi('poi_water_001', 'water', 'Village fountain', 18200, 48.075, 11.512, 'known_good', 25),
poi('poi_food_001', 'food', 'Bakery and mini market', 43000, 47.98, 11.37, 'high', 180),
poi('poi_water_002', 'water', 'Cemetery tap', 50900, 47.92, 11.27, 'high', 90),
poi('poi_sleep_001', 'sleep', 'Lakeside campsite', 72400, 47.805, 11.145, 'high', 800, { sleepType: 'campsite' }),
poi('poi_bailout_001', 'bailout', 'Regional rail station', 85000, 47.75, 11.12, 'high', 600),
poi('poi_water_003', 'water', 'Spring above forest road', 90000, 47.715, 11.12, 'medium', 120),
poi('poi_food_002', 'food', 'Gas station shop', 113000, 47.62, 11.22, 'high', 250),
poi('poi_repair_001', 'repair', 'Bike shop Murnau', 112000, 47.62, 11.235, 'high', 1300),
poi('poi_water_004', 'water', 'Public tap', 126000, 47.53, 11.105, 'medium', 210),
poi('poi_sleep_002', 'sleep', 'Mountain hostel', 154000, 47.505, 11.16, 'medium', 2100, { sleepType: 'hostel' }),
poi('poi_food_003', 'food', 'Village supermarket', 165000, 47.47, 11.22, 'high', 650),
poi('poi_water_005', 'water', 'Trailhead fountain', 174000, 47.43, 11.19, 'high', 80),
poi('poi_bailout_002', 'bailout', 'Bus stop with bike carriage', 175000, 47.42, 11.2, 'medium', 400),
poi('poi_water_006', 'water', 'Alpine hut tap', 225000, 47.29, 11.18, 'medium', 1400),
poi('poi_sleep_003', 'sleep', 'Farm campground', 228000, 47.275, 11.16, 'high', 900, { sleepType: 'campsite' }),
poi('poi_food_004', 'food', 'Bakery before pass', 236000, 47.25, 11.13, 'high', 500),
poi('poi_repair_002', 'repair', 'Repair station at trail hub', 266000, 47.18, 11.24, 'medium', 250),
poi('poi_bailout_003', 'bailout', 'Train station valley option', 265000, 47.18, 11.23, 'high', 1600),
poi('poi_water_007', 'water', 'Village fountain after pass', 282000, 47.22, 11.33, 'high', 70),
poi('poi_sleep_004', 'sleep', 'Budget inn', 305000, 47.16, 11.43, 'medium', 1700, { sleepType: 'hotel' }),
poi('poi_food_005', 'food', 'Supermarket last resupply', 312000, 47.14, 11.47, 'high', 600),
poi('poi_water_008', 'water', 'Town public tap', 339000, 47.19, 11.53, 'known_good', 45),
poi('poi_bailout_004', 'bailout', 'Mainline rail station', 350000, 47.215, 11.55, 'high', 900),
poi('poi_food_006', 'food', 'Innsbruck market hall', 370000, 47.25, 11.44, 'high', 400),
poi('poi_repair_003', 'repair', 'Innsbruck bike workshop', 380000, 47.267, 11.41, 'high', 500),
poi('poi_sleep_005', 'sleep', 'Finish hostel', 382000, 47.269, 11.404, 'high', 300, { sleepType: 'hostel' }),
poi('poi_water_009', 'water', 'Finish drinking fountain', 382000, 47.269, 11.404, 'high', 30)
];
export const mockRules: RuleCard[] = [
{
id: 'rule_bavaria_access_001',
ruleType: 'access',
jurisdiction: 'Bavaria mock corridor',
status: 'advisory',
confidence: 'medium',
summary: 'Forest and protected-area access can depend on local signage. Verify bicycle access at uncertain segments.',
sourceUrl: 'https://www.openstreetmap.org/copyright',
lastReviewedAt: '2026-07-02'
},
{
id: 'rule_tyrol_camping_001',
ruleType: 'camping',
jurisdiction: 'Tyrol mock corridor',
status: 'advisory',
confidence: 'low',
summary: 'Wild camping rules vary by municipality and protected status. Prefer official campsites or indoor lodging unless locally confirmed.',
sourceUrl: 'https://www.openstreetmap.org/copyright',
lastReviewedAt: '2026-07-02'
}
];
function poi(
id: string,
category: Poi['category'],
name: string,
metersFromStart: number,
lat: number,
lon: number,
confidence: Poi['confidence'],
detourDistanceM: number,
tags?: Record<string, string>
): Poi {
return {
id,
category,
name,
location: { lat, lon },
source: 'mock_osm_fixture',
confidence,
metersFromStart,
distanceFromRouteM: Math.min(detourDistanceM, 2000),
detourDistanceM,
tags,
lastConfirmedAt: '2026-07-02T00:00:00.000Z'
};
}

11
packages/shared/src/geo.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import type { Coordinate, RoutePlan } from './types.js';
export declare function haversineDistanceM(a: Coordinate, b: Coordinate): number;
export declare function polylineDistanceM(points: Coordinate[]): number;
export declare function bboxForCoordinates(points: Coordinate[], paddingDegrees?: number): [number, number, number, number];
export declare function routeMetersForSegments(route: RoutePlan): Array<{
id: string;
startM: number;
endM: number;
}>;
export declare function coordinateAtDistance(route: RoutePlan, meters: number): Coordinate;
//# sourceMappingURL=geo.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"geo.d.ts","sourceRoot":"","sources":["geo.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAQxD,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC,EAAE,UAAU,GAAG,MAAM,CASvE;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,UAAU,EAAE,GAAG,MAAM,CAU9D;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,UAAU,EAAE,EAAE,cAAc,SAAI,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,CAkB7G;AAED,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,SAAS,GAAG,KAAK,CAAC;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAQ5G;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,UAAU,CAejF"}

104
packages/shared/src/geo.js Normal file
View File

@@ -0,0 +1,104 @@
const EARTH_RADIUS_M = 6371008.8;
function toRadians(value) {
return (value * Math.PI) / 180;
}
export function haversineDistanceM(a, b) {
const lat1 = toRadians(a.lat);
const lat2 = toRadians(b.lat);
const deltaLat = toRadians(b.lat - a.lat);
const deltaLon = toRadians(b.lon - a.lon);
const sinLat = Math.sin(deltaLat / 2);
const sinLon = Math.sin(deltaLon / 2);
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon;
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
}
export function polylineDistanceM(points) {
let distance = 0;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
if (previous && current) {
distance += haversineDistanceM(previous, current);
}
}
return distance;
}
export function bboxForCoordinates(points, paddingDegrees = 0) {
if (points.length === 0) {
return [0, 0, 0, 0];
}
let minLon = points[0]?.lon ?? 0;
let minLat = points[0]?.lat ?? 0;
let maxLon = minLon;
let maxLat = minLat;
for (const point of points) {
minLon = Math.min(minLon, point.lon);
minLat = Math.min(minLat, point.lat);
maxLon = Math.max(maxLon, point.lon);
maxLat = Math.max(maxLat, point.lat);
}
return [minLon - paddingDegrees, minLat - paddingDegrees, maxLon + paddingDegrees, maxLat + paddingDegrees];
}
export function routeMetersForSegments(route) {
let cursor = 0;
return route.segments.map((segment) => {
const startM = segment.startMeters ?? cursor;
const endM = segment.endMeters ?? startM + segment.distanceM;
cursor = endM;
return { id: segment.id, startM, endM };
});
}
export function coordinateAtDistance(route, meters) {
const clampedMeters = Math.max(0, Math.min(route.distanceM, meters));
let cursor = 0;
for (const segment of route.segments) {
const startM = segment.startMeters ?? cursor;
const endM = segment.endMeters ?? startM + segment.distanceM;
if (clampedMeters >= startM && clampedMeters <= endM) {
const segmentPoints = segment.geometry.length >= 2 ? segment.geometry : route.geometry;
return interpolateAlongPolyline(segmentPoints, (clampedMeters - startM) / Math.max(1, endM - startM));
}
cursor = endM;
}
return route.geometry[route.geometry.length - 1] ?? { lat: 0, lon: 0 };
}
function interpolateAlongPolyline(points, fraction) {
if (points.length === 0) {
return { lat: 0, lon: 0 };
}
if (points.length === 1) {
return points[0] ?? { lat: 0, lon: 0 };
}
const distances = [0];
let total = 0;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
if (previous && current) {
total += haversineDistanceM(previous, current);
distances.push(total);
}
}
if (total === 0) {
return points[0] ?? { lat: 0, lon: 0 };
}
const target = Math.max(0, Math.min(1, fraction)) * total;
for (let index = 1; index < distances.length; index += 1) {
const previousDistance = distances[index - 1] ?? 0;
const currentDistance = distances[index] ?? previousDistance;
if (target <= currentDistance) {
const previous = points[index - 1];
const current = points[index];
if (!previous || !current) {
break;
}
const localFraction = (target - previousDistance) / Math.max(1, currentDistance - previousDistance);
return {
lat: previous.lat + (current.lat - previous.lat) * localFraction,
lon: previous.lon + (current.lon - previous.lon) * localFraction
};
}
}
return points[points.length - 1] ?? { lat: 0, lon: 0 };
}
//# sourceMappingURL=geo.js.map

File diff suppressed because one or more lines are too long

125
packages/shared/src/geo.ts Normal file
View File

@@ -0,0 +1,125 @@
import type { Coordinate, RoutePlan } from './types.js';
const EARTH_RADIUS_M = 6371008.8;
function toRadians(value: number): number {
return (value * Math.PI) / 180;
}
export function haversineDistanceM(a: Coordinate, b: Coordinate): number {
const lat1 = toRadians(a.lat);
const lat2 = toRadians(b.lat);
const deltaLat = toRadians(b.lat - a.lat);
const deltaLon = toRadians(b.lon - a.lon);
const sinLat = Math.sin(deltaLat / 2);
const sinLon = Math.sin(deltaLon / 2);
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLon * sinLon;
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(h)));
}
export function polylineDistanceM(points: Coordinate[]): number {
let distance = 0;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
if (previous && current) {
distance += haversineDistanceM(previous, current);
}
}
return distance;
}
export function bboxForCoordinates(points: Coordinate[], paddingDegrees = 0): [number, number, number, number] {
if (points.length === 0) {
return [0, 0, 0, 0];
}
let minLon = points[0]?.lon ?? 0;
let minLat = points[0]?.lat ?? 0;
let maxLon = minLon;
let maxLat = minLat;
for (const point of points) {
minLon = Math.min(minLon, point.lon);
minLat = Math.min(minLat, point.lat);
maxLon = Math.max(maxLon, point.lon);
maxLat = Math.max(maxLat, point.lat);
}
return [minLon - paddingDegrees, minLat - paddingDegrees, maxLon + paddingDegrees, maxLat + paddingDegrees];
}
export function routeMetersForSegments(route: RoutePlan): Array<{ id: string; startM: number; endM: number }> {
let cursor = 0;
return route.segments.map((segment) => {
const startM = segment.startMeters ?? cursor;
const endM = segment.endMeters ?? startM + segment.distanceM;
cursor = endM;
return { id: segment.id, startM, endM };
});
}
export function coordinateAtDistance(route: RoutePlan, meters: number): Coordinate {
const clampedMeters = Math.max(0, Math.min(route.distanceM, meters));
let cursor = 0;
for (const segment of route.segments) {
const startM = segment.startMeters ?? cursor;
const endM = segment.endMeters ?? startM + segment.distanceM;
if (clampedMeters >= startM && clampedMeters <= endM) {
const segmentPoints = segment.geometry.length >= 2 ? segment.geometry : route.geometry;
return interpolateAlongPolyline(segmentPoints, (clampedMeters - startM) / Math.max(1, endM - startM));
}
cursor = endM;
}
return route.geometry[route.geometry.length - 1] ?? { lat: 0, lon: 0 };
}
function interpolateAlongPolyline(points: Coordinate[], fraction: number): Coordinate {
if (points.length === 0) {
return { lat: 0, lon: 0 };
}
if (points.length === 1) {
return points[0] ?? { lat: 0, lon: 0 };
}
const distances: number[] = [0];
let total = 0;
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
if (previous && current) {
total += haversineDistanceM(previous, current);
distances.push(total);
}
}
if (total === 0) {
return points[0] ?? { lat: 0, lon: 0 };
}
const target = Math.max(0, Math.min(1, fraction)) * total;
for (let index = 1; index < distances.length; index += 1) {
const previousDistance = distances[index - 1] ?? 0;
const currentDistance = distances[index] ?? previousDistance;
if (target <= currentDistance) {
const previous = points[index - 1];
const current = points[index];
if (!previous || !current) {
break;
}
const localFraction = (target - previousDistance) / Math.max(1, currentDistance - previousDistance);
const coordinate: Coordinate = {
lat: previous.lat + (current.lat - previous.lat) * localFraction,
lon: previous.lon + (current.lon - previous.lon) * localFraction
};
if (typeof previous.elevationM === 'number' && typeof current.elevationM === 'number') {
coordinate.elevationM = previous.elevationM + (current.elevationM - previous.elevationM) * localFraction;
}
return coordinate;
}
}
return points[points.length - 1] ?? { lat: 0, lon: 0 };
}

9
packages/shared/src/gpx.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
import type { Poi, RoutePlan, StagePlan } from './types.js';
export type GpxOptions = {
name?: string;
description?: string;
includePois?: boolean;
attribution?: string[];
};
export declare function exportRouteToGpx(route: RoutePlan, stages?: StagePlan[], pois?: Poi[], options?: GpxOptions): string;
//# sourceMappingURL=gpx.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"gpx.d.ts","sourceRoot":"","sources":["gpx.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE5D,MAAM,MAAM,UAAU,GAAG;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB,CAAC;AAEF,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,GAAE,SAAS,EAAO,EAAE,IAAI,GAAE,GAAG,EAAO,EAAE,OAAO,GAAE,UAAe,GAAG,MAAM,CA8C/H"}

View File

@@ -0,0 +1,70 @@
import { coordinateAtDistance } from './geo.js';
export function exportRouteToGpx(route, stages = [], pois = [], options = {}) {
const metadataName = options.name ?? `Pikebacker ${route.id}`;
const metadataDescription = options.description ??
'Bikepacking route export with stage endpoints and route-corridor logistics POIs. Advisory information is confidence-scored and not legal advice.';
const attribution = options.attribution ?? route.attribution ?? ['Contains OSM-derived fixture data for MVP testing.'];
const stageWaypoints = stages.map((stage) => {
const coordinate = coordinateAtDistance(route, stage.endMeters);
return waypointXml(coordinate.lat, coordinate.lon, `Stage ${stage.dayIndex} end`, 'stage-end', {
routeMeters: String(stage.endMeters)
});
});
const poiWaypoints = options.includePois
? pois.map((poi) => waypointXml(poi.location.lat, poi.location.lon, poi.name, poi.category, {
confidence: poi.confidence,
metersFromStart: String(poi.metersFromStart ?? ''),
source: poi.source
}))
: [];
const routePoints = route.geometry
.map((point) => ` <rtept lat="${number(point.lat)}" lon="${number(point.lon)}" />`)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Pikebacker MVP" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 https://www.topografix.com/GPX/1/1/gpx.xsd">
<metadata>
<name>${escapeXml(metadataName)}</name>
<desc>${escapeXml(metadataDescription)}</desc>
<copyright author="OpenStreetMap contributors">
<license>https://www.openstreetmap.org/copyright</license>
</copyright>
<extensions>
<pikebacker:attribution xmlns:pikebacker="https://pikebacker.local/schema">${escapeXml(attribution.join(' | '))}</pikebacker:attribution>
<pikebacker:suitabilityScore xmlns:pikebacker="https://pikebacker.local/schema">${route.suitabilityScore.toFixed(1)}</pikebacker:suitabilityScore>
</extensions>
</metadata>
${[...stageWaypoints, ...poiWaypoints].join('\n')}
<rte>
<name>${escapeXml(metadataName)}</name>
<desc>${escapeXml(`Distance ${(route.distanceM / 1000).toFixed(1)} km, ascent ${route.ascentM} m`)}</desc>
${routePoints}
</rte>
</gpx>
`;
}
function waypointXml(lat, lon, name, type, extensions) {
const extensionEntries = Object.entries(extensions)
.filter(([, value]) => value !== '')
.map(([key, value]) => ` <pikebacker:${escapeXmlName(key)} xmlns:pikebacker="https://pikebacker.local/schema">${escapeXml(value)}</pikebacker:${escapeXmlName(key)}>`)
.join('\n');
const extensionsXml = extensionEntries ? `\n <extensions>\n${extensionEntries}\n </extensions>` : '';
return ` <wpt lat="${number(lat)}" lon="${number(lon)}">
<name>${escapeXml(name)}</name>
<type>${escapeXml(type)}</type>${extensionsXml}
</wpt>`;
}
function escapeXml(value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
function escapeXmlName(value) {
return value.replace(/[^A-Za-z0-9_.-]/g, '');
}
function number(value) {
return value.toFixed(6);
}
//# sourceMappingURL=gpx.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"gpx.js","sourceRoot":"","sources":["gpx.ts"],"names":[],"mappings":"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"}

View File

@@ -0,0 +1,20 @@
import { XMLParser } from 'fast-xml-parser';
import { describe, expect, it } from 'vitest';
import { createMockRoute, mockPois, mockTripRequest } from './fixtures.js';
import { exportRouteToGpx } from './gpx.js';
import { planStages } from './staging.js';
describe('GPX export', () => {
it('serializes valid GPX with route points, stage waypoints, POIs, and attribution', () => {
const route = createMockRoute();
const stages = planStages(route, mockPois, mockTripRequest);
const gpx = exportRouteToGpx(route, stages, mockPois, { includePois: true, name: 'Fixture route' });
const parsed = new XMLParser({ ignoreAttributes: false }).parse(gpx) as { gpx?: unknown };
expect(parsed.gpx).toBeTruthy();
expect(gpx).toContain('<rtept lat="48.137000" lon="11.575000" />');
expect(gpx).toContain('<name>Stage 1 end</name>');
expect(gpx).toContain('OpenStreetMap contributors');
expect(gpx).toContain('mock_osm_fixture');
});
});

View File

@@ -0,0 +1,89 @@
import { coordinateAtDistance } from './geo.js';
import type { Poi, RoutePlan, StagePlan } from './types.js';
export type GpxOptions = {
name?: string;
description?: string;
includePois?: boolean;
attribution?: string[];
};
export function exportRouteToGpx(route: RoutePlan, stages: StagePlan[] = [], pois: Poi[] = [], options: GpxOptions = {}): string {
const metadataName = options.name ?? `Pikebacker ${route.id}`;
const metadataDescription =
options.description ??
'Bikepacking route export with stage endpoints and route-corridor logistics POIs. Advisory information is confidence-scored and not legal advice.';
const attribution = options.attribution ?? route.attribution ?? ['Contains OSM-derived fixture data for MVP testing.'];
const stageWaypoints = stages.map((stage) => {
const coordinate = coordinateAtDistance(route, stage.endMeters);
return waypointXml(coordinate.lat, coordinate.lon, `Stage ${stage.dayIndex} end`, 'stage-end', {
routeMeters: String(stage.endMeters)
});
});
const poiWaypoints = options.includePois
? pois.map((poi) =>
waypointXml(poi.location.lat, poi.location.lon, poi.name, poi.category, {
confidence: poi.confidence,
metersFromStart: String(poi.metersFromStart ?? ''),
source: poi.source
})
)
: [];
const routePoints = route.geometry
.map((point) => ` <rtept lat="${number(point.lat)}" lon="${number(point.lon)}" />`)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="Pikebacker MVP" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 https://www.topografix.com/GPX/1/1/gpx.xsd">
<metadata>
<name>${escapeXml(metadataName)}</name>
<desc>${escapeXml(metadataDescription)}</desc>
<copyright author="OpenStreetMap contributors">
<license>https://www.openstreetmap.org/copyright</license>
</copyright>
<extensions>
<pikebacker:attribution xmlns:pikebacker="https://pikebacker.local/schema">${escapeXml(attribution.join(' | '))}</pikebacker:attribution>
<pikebacker:suitabilityScore xmlns:pikebacker="https://pikebacker.local/schema">${route.suitabilityScore.toFixed(1)}</pikebacker:suitabilityScore>
</extensions>
</metadata>
${[...stageWaypoints, ...poiWaypoints].join('\n')}
<rte>
<name>${escapeXml(metadataName)}</name>
<desc>${escapeXml(`Distance ${(route.distanceM / 1000).toFixed(1)} km, ascent ${route.ascentM} m`)}</desc>
${routePoints}
</rte>
</gpx>
`;
}
function waypointXml(lat: number, lon: number, name: string, type: string, extensions: Record<string, string>): string {
const extensionEntries = Object.entries(extensions)
.filter(([, value]) => value !== '')
.map(
([key, value]) =>
` <pikebacker:${escapeXmlName(key)} xmlns:pikebacker="https://pikebacker.local/schema">${escapeXml(value)}</pikebacker:${escapeXmlName(key)}>`
)
.join('\n');
const extensionsXml = extensionEntries ? `\n <extensions>\n${extensionEntries}\n </extensions>` : '';
return ` <wpt lat="${number(lat)}" lon="${number(lon)}">
<name>${escapeXml(name)}</name>
<type>${escapeXml(type)}</type>${extensionsXml}
</wpt>`;
}
function escapeXml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
function escapeXmlName(value: string): string {
return value.replace(/[^A-Za-z0-9_.-]/g, '');
}
function number(value: number): string {
return value.toFixed(6);
}

10
packages/shared/src/index.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
export * from './types.js';
export * from './profiles.js';
export * from './geo.js';
export * from './scoring.js';
export * from './pois.js';
export * from './staging.js';
export * from './gpx.js';
export * from './offline.js';
export * from './fixtures.js';
//# sourceMappingURL=index.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC;AAC1B,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,eAAe,CAAC"}

View File

@@ -0,0 +1,10 @@
export * from './types.js';
export * from './profiles.js';
export * from './geo.js';
export * from './scoring.js';
export * from './pois.js';
export * from './staging.js';
export * from './gpx.js';
export * from './offline.js';
export * from './fixtures.js';
//# sourceMappingURL=index.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC;AAC1B,cAAc,cAAc,CAAC;AAC7B,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,eAAe,CAAC"}

View File

@@ -0,0 +1,9 @@
export * from './types.js';
export * from './profiles.js';
export * from './geo.js';
export * from './scoring.js';
export * from './pois.js';
export * from './staging.js';
export * from './gpx.js';
export * from './offline.js';
export * from './fixtures.js';

13
packages/shared/src/offline.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
import type { OfflinePackManifest, Poi, RoutePlan, RuleCard, StagePlan } from './types.js';
export type OfflinePackInput = {
tripId: string;
route: RoutePlan;
stages: StagePlan[];
pois: Poi[];
rules: RuleCard[];
routeBufferKm?: number;
createdAt?: string;
gpxBytes?: number;
};
export declare function createOfflinePackManifest(input: OfflinePackInput): OfflinePackManifest;
//# sourceMappingURL=offline.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"offline.d.ts","sourceRoot":"","sources":["offline.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,mBAAmB,EAAE,GAAG,EAAE,SAAS,EAAgB,QAAQ,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEzG,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,SAAS,CAAC;IACjB,MAAM,EAAE,SAAS,EAAE,CAAC;IACpB,IAAI,EAAE,GAAG,EAAE,CAAC;IACZ,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,gBAAgB,GAAG,mBAAmB,CAoDtF"}

View File

@@ -0,0 +1,59 @@
import { bboxForCoordinates } from './geo.js';
export function createOfflinePackManifest(input) {
const createdAt = input.createdAt ?? '2026-07-02T00:00:00.000Z';
const routeBufferKm = input.routeBufferKm ?? 5;
const bboxPaddingDegrees = routeBufferKm / 111;
const warnings = [
{
code: 'OFFLINE_TILES_UNAVAILABLE',
severity: 'notice',
title: 'Offline map tiles not bundled',
message: 'MVP pack includes route, POIs, stages, rules, GPX, and report queue metadata. Tile files are represented as future references.'
},
{
code: 'OFFLINE_REROUTING_UNAVAILABLE',
severity: 'notice',
title: 'Offline rerouting unavailable',
message: 'Use the cached route and bailout POIs offline. Full offline routing graph support is not included in the MVP manifest.'
}
];
return {
packId: `pack_${input.tripId}_${input.route.id}`,
tripId: input.tripId,
routeId: input.route.id,
createdAt,
bbox: bboxForCoordinates(input.route.geometry, bboxPaddingDegrees),
routeBufferKm,
includedLayers: [
'route_geometry',
'stage_plan',
'route_corridor_pois',
'critical_gaps',
'rule_cards',
'gpx_export',
'offline_report_queue'
],
dataVersions: {
osm_extract: 'mock-osm-fixture-2026-07-02',
poi_index: 'mock-poi-fixture-2026-07-02',
rules: 'mock-rules-2026-07-02',
scoring_model: 'pikebacker-mvp-0.1.0'
},
files: [
{ type: 'route_json', path: `offline://${input.route.id}/route.json` },
{ type: 'stages_json', path: `offline://${input.route.id}/stages.json` },
{ type: 'pois_json', path: `offline://${input.route.id}/pois.json` },
{ type: 'rules_json', path: `offline://${input.route.id}/rules.json` },
{ type: 'gpx', path: `offline://${input.route.id}/route.gpx`, bytes: input.gpxBytes },
{ type: 'tile_pack_placeholder', path: `offline://${input.route.id}/tiles.pmtiles`, bytes: 0 }
],
warnings,
expiresAt: addDaysIso(createdAt, 14)
};
}
function addDaysIso(isoDate, days) {
const date = new Date(isoDate);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString();
}
//# sourceMappingURL=offline.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"offline.js","sourceRoot":"","sources":["offline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAc9C,MAAM,UAAU,yBAAyB,CAAC,KAAuB;IAC/D,MAAM,SAAS,GAAG,KAAK,CAAC,SAAS,IAAI,0BAA0B,CAAC;IAChE,MAAM,aAAa,GAAG,KAAK,CAAC,aAAa,IAAI,CAAC,CAAC;IAC/C,MAAM,kBAAkB,GAAG,aAAa,GAAG,GAAG,CAAC;IAC/C,MAAM,QAAQ,GAAmB;QAC/B;YACE,IAAI,EAAE,2BAA2B;YACjC,QAAQ,EAAE,QAAQ;YAClB,KAAK,EAAE,+BAA+B;YACtC,OAAO,EAAE,gIAAgI;SAC1I;QACD;YACE,IAAI,EAAE,+BAA+B;YACrC,QAAQ,EAAE,QAAQ;YAClB,KAAK,EAAE,+BAA+B;YACtC,OAAO,EAAE,wHAAwH;SAClI;KACF,CAAC;IAEF,OAAO;QACL,MAAM,EAAE,QAAQ,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,KAAK,CAAC,EAAE,EAAE;QAChD,MAAM,EAAE,KAAK,CAAC,MAAM;QACpB,OAAO,EAAE,KAAK,CAAC,KAAK,CAAC,EAAE;QACvB,SAAS;QACT,IAAI,EAAE,kBAAkB,CAAC,KAAK,CAAC,KAAK,CAAC,QAAQ,EAAE,kBAAkB,CAAC;QAClE,aAAa;QACb,cAAc,EAAE;YACd,gBAAgB;YAChB,YAAY;YACZ,qBAAqB;YACrB,eAAe;YACf,YAAY;YACZ,YAAY;YACZ,sBAAsB;SACvB;QACD,YAAY,EAAE;YACZ,WAAW,EAAE,6BAA6B;YAC1C,SAAS,EAAE,6BAA6B;YACxC,KAAK,EAAE,uBAAuB;YAC9B,aAAa,EAAE,sBAAsB;SACtC;QACD,KAAK,EAAE;YACL,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,aAAa,KAAK,CAAC,KAAK,CAAC,EAAE,aAAa,EAAE;YACtE,EAAE,IAAI,EAAE,aAAa,EAAE,IAAI,EAAE,aAAa,KAAK,CAAC,KAAK,CAAC,EAAE,cAAc,EAAE;YACxE,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,aAAa,KAAK,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE;YACpE,EAAE,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,aAAa,KAAK,CAAC,KAAK,CAAC,EAAE,aAAa,EAAE;YACtE,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,aAAa,KAAK,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE,KAAK,EAAE,KAAK,CAAC,QAAQ,EAAE;YACrF,EAAE,IAAI,EAAE,uBAAuB,EAAE,IAAI,EAAE,aAAa,KAAK,CAAC,KAAK,CAAC,EAAE,gBAAgB,EAAE,KAAK,EAAE,CAAC,EAAE;SAC/F;QACD,QAAQ;QACR,SAAS,EAAE,UAAU,CAAC,SAAS,EAAE,EAAE,CAAC;KACrC,CAAC;AACJ,CAAC;AAED,SAAS,UAAU,CAAC,OAAe,EAAE,IAAY;IAC/C,MAAM,IAAI,GAAG,IAAI,IAAI,CAAC,OAAO,CAAC,CAAC;IAC/B,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,UAAU,EAAE,GAAG,IAAI,CAAC,CAAC;IAC1C,OAAO,IAAI,CAAC,WAAW,EAAE,CAAC;AAC5B,CAAC"}

View File

@@ -0,0 +1,73 @@
import { bboxForCoordinates } from './geo.js';
import type { OfflinePackManifest, Poi, RoutePlan, RouteWarning, RuleCard, StagePlan } from './types.js';
export type OfflinePackInput = {
tripId: string;
route: RoutePlan;
stages: StagePlan[];
pois: Poi[];
rules: RuleCard[];
routeBufferKm?: number;
createdAt?: string;
gpxBytes?: number;
};
export function createOfflinePackManifest(input: OfflinePackInput): OfflinePackManifest {
const createdAt = input.createdAt ?? '2026-07-02T00:00:00.000Z';
const routeBufferKm = input.routeBufferKm ?? 5;
const bboxPaddingDegrees = routeBufferKm / 111;
const warnings: RouteWarning[] = [
{
code: 'OFFLINE_TILES_UNAVAILABLE',
severity: 'notice',
title: 'Offline map tiles not bundled',
message: 'MVP pack includes route, POIs, stages, rules, GPX, and report queue metadata. Tile files are represented as future references.'
},
{
code: 'OFFLINE_REROUTING_UNAVAILABLE',
severity: 'notice',
title: 'Offline rerouting unavailable',
message: 'Use the cached route and bailout POIs offline. Full offline routing graph support is not included in the MVP manifest.'
}
];
return {
packId: `pack_${input.tripId}_${input.route.id}`,
tripId: input.tripId,
routeId: input.route.id,
createdAt,
bbox: bboxForCoordinates(input.route.geometry, bboxPaddingDegrees),
routeBufferKm,
includedLayers: [
'route_geometry',
'stage_plan',
'route_corridor_pois',
'critical_gaps',
'rule_cards',
'gpx_export',
'offline_report_queue'
],
dataVersions: {
osm_extract: 'mock-osm-fixture-2026-07-02',
poi_index: 'mock-poi-fixture-2026-07-02',
rules: 'mock-rules-2026-07-02',
scoring_model: 'pikebacker-mvp-0.1.0'
},
files: [
{ type: 'route_json', path: `offline://${input.route.id}/route.json` },
{ type: 'stages_json', path: `offline://${input.route.id}/stages.json` },
{ type: 'pois_json', path: `offline://${input.route.id}/pois.json` },
{ type: 'rules_json', path: `offline://${input.route.id}/rules.json` },
{ type: 'gpx', path: `offline://${input.route.id}/route.gpx`, bytes: input.gpxBytes },
{ type: 'tile_pack_placeholder', path: `offline://${input.route.id}/tiles.pmtiles`, bytes: 0 }
],
warnings,
expiresAt: addDaysIso(createdAt, 14)
};
}
function addDaysIso(isoDate: string, days: number): string {
const date = new Date(isoDate);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString();
}

15
packages/shared/src/pois.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
import type { CriticalGapThresholds, Poi, PoiCategory, RoutePlan, ServiceGap, WarningSeverity } from './types.js';
export type CorridorFilter = {
category?: PoiCategory;
bufferKm?: number;
};
export declare function filterPoisByCorridor(pois: Poi[], _route: RoutePlan, filter?: CorridorFilter): Poi[];
export declare function detectCriticalGaps(route: RoutePlan, pois: Poi[], thresholds: CriticalGapThresholds): ServiceGap[];
export declare function warningsFromGaps(gaps: ServiceGap[]): {
code: string;
severity: WarningSeverity;
title: string;
message: string;
metersFromStart: number;
}[];
//# sourceMappingURL=pois.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"pois.d.ts","sourceRoot":"","sources":["pois.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,GAAG,EAAE,WAAW,EAAE,SAAS,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAIlH,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,CAAC,EAAE,WAAW,CAAC;IACvB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAE,cAAmB,GAAG,GAAG,EAAE,CAMvG;AAED,wBAAgB,kBAAkB,CAChC,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE,GAAG,EAAE,EACX,UAAU,EAAE,qBAAqB,GAChC,UAAU,EAAE,CAoCd;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,UAAU,EAAE;;;;;;IAQlD"}

View File

@@ -0,0 +1,72 @@
const LOGISTICS_CATEGORIES = ['water', 'food', 'sleep', 'repair', 'bailout', 'charging'];
export function filterPoisByCorridor(pois, _route, filter = {}) {
const bufferM = (filter.bufferKm ?? 5) * 1000;
return pois
.filter((poi) => (filter.category ? poi.category === filter.category : true))
.filter((poi) => (poi.distanceFromRouteM ?? 0) <= bufferM)
.sort(comparePoisByRoutePosition);
}
export function detectCriticalGaps(route, pois, thresholds) {
const gaps = [];
for (const category of LOGISTICS_CATEGORIES) {
const threshold = thresholds[category];
if (!threshold) {
continue;
}
const routePositions = pois
.filter((poi) => poi.category === category && typeof poi.metersFromStart === 'number')
.map((poi) => Math.max(0, Math.min(route.distanceM, poi.metersFromStart ?? 0)))
.sort((left, right) => left - right);
const positions = [0, ...routePositions, route.distanceM];
for (let index = 1; index < positions.length; index += 1) {
const startM = positions[index - 1] ?? 0;
const endM = positions[index] ?? route.distanceM;
const distanceM = endM - startM;
if (distanceM > threshold) {
gaps.push({
category,
startM,
endM,
distanceM,
severity: severityForGap(distanceM, threshold),
explanation: explainGap(category, distanceM, threshold)
});
}
}
}
return gaps.sort((left, right) => {
const severityRank = rankSeverity(right.severity) - rankSeverity(left.severity);
return severityRank || left.startM - right.startM;
});
}
export function warningsFromGaps(gaps) {
return gaps.map((gap) => ({
code: `${gap.category.toUpperCase()}_GAP`,
severity: gap.severity,
title: `Long ${gap.category} gap`,
message: gap.explanation,
metersFromStart: gap.startM
}));
}
function comparePoisByRoutePosition(left, right) {
return (left.metersFromStart ?? Number.MAX_SAFE_INTEGER) - (right.metersFromStart ?? Number.MAX_SAFE_INTEGER);
}
function severityForGap(distanceM, thresholdM) {
const ratio = distanceM / thresholdM;
if (ratio >= 1.5) {
return 'critical';
}
if (ratio >= 1.0) {
return 'warning';
}
return 'notice';
}
function rankSeverity(severity) {
return severity === 'critical' ? 4 : severity === 'warning' ? 3 : severity === 'notice' ? 2 : 1;
}
function explainGap(category, distanceM, thresholdM) {
const distanceKm = (distanceM / 1000).toFixed(1);
const thresholdKm = (thresholdM / 1000).toFixed(0);
return `${distanceKm} km without a confirmed ${category} POI, above the ${thresholdKm} km bikepacking threshold.`;
}
//# sourceMappingURL=pois.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"pois.js","sourceRoot":"","sources":["pois.ts"],"names":[],"mappings":"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"}

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { createMockRoute, mockPois, mockTripRequest } from './fixtures.js';
import { thresholdsFromTripRequest } from './profiles.js';
import { detectCriticalGaps, filterPoisByCorridor } from './pois.js';
describe('route corridor POIs and gaps', () => {
it('filters route-corridor POIs by category and buffer', () => {
const route = createMockRoute();
const sleepPois = filterPoisByCorridor(mockPois, route, { category: 'sleep', bufferKm: 2 });
expect(sleepPois.map((poi) => poi.category)).toEqual(expect.arrayContaining(['sleep']));
expect(sleepPois.every((poi) => (poi.distanceFromRouteM ?? 0) <= 2000)).toBe(true);
expect(sleepPois[0]?.metersFromStart).toBeLessThan(sleepPois[sleepPois.length - 1]?.metersFromStart ?? 0);
});
it('detects critical logistics gaps from deterministic fixtures', () => {
const route = createMockRoute();
const gaps = detectCriticalGaps(route, mockPois, thresholdsFromTripRequest(mockTripRequest));
expect(gaps.map((gap) => gap.category)).toEqual(expect.arrayContaining(['water', 'food']));
expect(gaps.find((gap) => gap.category === 'water')?.severity).toBe('warning');
expect(gaps.every((gap) => gap.distanceM > 0)).toBe(true);
});
});

View File

@@ -0,0 +1,93 @@
import type { CriticalGapThresholds, Poi, PoiCategory, RoutePlan, ServiceGap, WarningSeverity } from './types.js';
const LOGISTICS_CATEGORIES: PoiCategory[] = ['water', 'food', 'sleep', 'repair', 'bailout', 'charging'];
export type CorridorFilter = {
category?: PoiCategory;
bufferKm?: number;
};
export function filterPoisByCorridor(pois: Poi[], _route: RoutePlan, filter: CorridorFilter = {}): Poi[] {
const bufferM = (filter.bufferKm ?? 5) * 1000;
return pois
.filter((poi) => (filter.category ? poi.category === filter.category : true))
.filter((poi) => (poi.distanceFromRouteM ?? 0) <= bufferM)
.sort(comparePoisByRoutePosition);
}
export function detectCriticalGaps(
route: RoutePlan,
pois: Poi[],
thresholds: CriticalGapThresholds
): ServiceGap[] {
const gaps: ServiceGap[] = [];
for (const category of LOGISTICS_CATEGORIES) {
const threshold = thresholds[category];
if (!threshold) {
continue;
}
const routePositions = pois
.filter((poi) => poi.category === category && typeof poi.metersFromStart === 'number')
.map((poi) => Math.max(0, Math.min(route.distanceM, poi.metersFromStart ?? 0)))
.sort((left, right) => left - right);
const positions = [0, ...routePositions, route.distanceM];
for (let index = 1; index < positions.length; index += 1) {
const startM = positions[index - 1] ?? 0;
const endM = positions[index] ?? route.distanceM;
const distanceM = endM - startM;
if (distanceM > threshold) {
gaps.push({
category,
startM,
endM,
distanceM,
severity: severityForGap(distanceM, threshold),
explanation: explainGap(category, distanceM, threshold)
});
}
}
}
return gaps.sort((left, right) => {
const severityRank = rankSeverity(right.severity) - rankSeverity(left.severity);
return severityRank || left.startM - right.startM;
});
}
export function warningsFromGaps(gaps: ServiceGap[]) {
return gaps.map((gap) => ({
code: `${gap.category.toUpperCase()}_GAP`,
severity: gap.severity,
title: `Long ${gap.category} gap`,
message: gap.explanation,
metersFromStart: gap.startM
}));
}
function comparePoisByRoutePosition(left: Poi, right: Poi): number {
return (left.metersFromStart ?? Number.MAX_SAFE_INTEGER) - (right.metersFromStart ?? Number.MAX_SAFE_INTEGER);
}
function severityForGap(distanceM: number, thresholdM: number): WarningSeverity {
const ratio = distanceM / thresholdM;
if (ratio >= 1.5) {
return 'critical';
}
if (ratio >= 1.0) {
return 'warning';
}
return 'notice';
}
function rankSeverity(severity: WarningSeverity): number {
return severity === 'critical' ? 4 : severity === 'warning' ? 3 : severity === 'notice' ? 2 : 1;
}
function explainGap(category: PoiCategory, distanceM: number, thresholdM: number): string {
const distanceKm = (distanceM / 1000).toFixed(1);
const thresholdKm = (thresholdM / 1000).toFixed(0);
return `${distanceKm} km without a confirmed ${category} POI, above the ${thresholdKm} km bikepacking threshold.`;
}

6
packages/shared/src/profiles.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
import type { BikepackingProfile, BikepackingProfileId, CriticalGapThresholds, TripRequest } from './types.js';
export declare const BIKEPACKING_PROFILES: Record<BikepackingProfileId, BikepackingProfile>;
export declare function getProfile(profileId: BikepackingProfileId): BikepackingProfile;
export declare function thresholdsFromTripRequest(request: TripRequest): CriticalGapThresholds;
export declare function maxLoadedGradeForRequest(request: TripRequest): number;
//# sourceMappingURL=profiles.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"profiles.d.ts","sourceRoot":"","sources":["profiles.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAE/G,eAAO,MAAM,oBAAoB,EAAE,MAAM,CAAC,oBAAoB,EAAE,kBAAkB,CA4EjF,CAAC;AAEF,wBAAgB,UAAU,CAAC,SAAS,EAAE,oBAAoB,GAAG,kBAAkB,CAE9E;AAED,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,WAAW,GAAG,qBAAqB,CAUrF;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAErE"}

View File

@@ -0,0 +1,95 @@
export const BIKEPACKING_PROFILES = {
beginner_safe: {
id: 'beginner_safe',
label: 'Beginner-safe',
dailyDistanceKm: { min: 40, target: 55, max: 70 },
maxGradeWarningPercent: 10,
waterGapMaxKm: 25,
foodGapMaxKm: 40,
repairGapMaxKm: 80,
bailoutGapMaxKm: 50,
sleepGapMaxKm: 65,
roughSurfaceTolerance: 0.35,
trafficTolerance: 0.25,
unknownAccessPenalty: 18,
preferredSurfaces: ['paved', 'asphalt', 'fine_gravel', 'compacted']
},
loaded_gravel: {
id: 'loaded_gravel',
label: 'Loaded gravel',
dailyDistanceKm: { min: 60, target: 80, max: 100 },
maxGradeWarningPercent: 12,
waterGapMaxKm: 40,
foodGapMaxKm: 70,
repairGapMaxKm: 110,
bailoutGapMaxKm: 90,
sleepGapMaxKm: 95,
roughSurfaceTolerance: 0.6,
trafficTolerance: 0.45,
unknownAccessPenalty: 12,
preferredSurfaces: ['gravel', 'fine_gravel', 'compacted', 'paved', 'asphalt', 'dirt']
},
hardtail_bikepacking: {
id: 'hardtail_bikepacking',
label: 'Hardtail bikepacking',
dailyDistanceKm: { min: 50, target: 70, max: 90 },
maxGradeWarningPercent: 15,
waterGapMaxKm: 55,
foodGapMaxKm: 90,
repairGapMaxKm: 130,
bailoutGapMaxKm: 110,
sleepGapMaxKm: 100,
roughSurfaceTolerance: 0.8,
trafficTolerance: 0.35,
unknownAccessPenalty: 10,
preferredSurfaces: ['dirt', 'singletrack', 'gravel', 'track', 'compacted']
},
road_touring: {
id: 'road_touring',
label: 'Road touring',
dailyDistanceKm: { min: 70, target: 100, max: 130 },
maxGradeWarningPercent: 10,
waterGapMaxKm: 40,
foodGapMaxKm: 70,
repairGapMaxKm: 120,
bailoutGapMaxKm: 90,
sleepGapMaxKm: 115,
roughSurfaceTolerance: 0.2,
trafficTolerance: 0.5,
unknownAccessPenalty: 14,
preferredSurfaces: ['paved', 'asphalt', 'concrete']
},
ebikepacking: {
id: 'ebikepacking',
label: 'E-bikepacking',
dailyDistanceKm: { min: 50, target: 75, max: 100 },
maxGradeWarningPercent: 12,
waterGapMaxKm: 40,
foodGapMaxKm: 60,
repairGapMaxKm: 100,
bailoutGapMaxKm: 80,
sleepGapMaxKm: 90,
roughSurfaceTolerance: 0.5,
trafficTolerance: 0.4,
unknownAccessPenalty: 15,
preferredSurfaces: ['paved', 'asphalt', 'gravel', 'compacted']
}
};
export function getProfile(profileId) {
return BIKEPACKING_PROFILES[profileId];
}
export function thresholdsFromTripRequest(request) {
const profile = getProfile(request.profile);
return {
water: (request.waterGapMaxKm ?? profile.waterGapMaxKm) * 1000,
food: (request.foodGapMaxKm ?? profile.foodGapMaxKm) * 1000,
repair: profile.repairGapMaxKm * 1000,
bailout: (request.requireBailoutEveryKm ?? profile.bailoutGapMaxKm) * 1000,
sleep: profile.sleepGapMaxKm * 1000,
charging: request.profile === 'ebikepacking' ? 60000 : undefined
};
}
export function maxLoadedGradeForRequest(request) {
return request.maxLoadedGradePercent ?? getProfile(request.profile).maxGradeWarningPercent;
}
//# sourceMappingURL=profiles.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"profiles.js","sourceRoot":"","sources":["profiles.ts"],"names":[],"mappings":"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"}

View File

@@ -0,0 +1,99 @@
import type { BikepackingProfile, BikepackingProfileId, CriticalGapThresholds, TripRequest } from './types.js';
export const BIKEPACKING_PROFILES: Record<BikepackingProfileId, BikepackingProfile> = {
beginner_safe: {
id: 'beginner_safe',
label: 'Beginner-safe',
dailyDistanceKm: { min: 40, target: 55, max: 70 },
maxGradeWarningPercent: 10,
waterGapMaxKm: 25,
foodGapMaxKm: 40,
repairGapMaxKm: 80,
bailoutGapMaxKm: 50,
sleepGapMaxKm: 65,
roughSurfaceTolerance: 0.35,
trafficTolerance: 0.25,
unknownAccessPenalty: 18,
preferredSurfaces: ['paved', 'asphalt', 'fine_gravel', 'compacted']
},
loaded_gravel: {
id: 'loaded_gravel',
label: 'Loaded gravel',
dailyDistanceKm: { min: 60, target: 80, max: 100 },
maxGradeWarningPercent: 12,
waterGapMaxKm: 40,
foodGapMaxKm: 70,
repairGapMaxKm: 110,
bailoutGapMaxKm: 90,
sleepGapMaxKm: 95,
roughSurfaceTolerance: 0.6,
trafficTolerance: 0.45,
unknownAccessPenalty: 12,
preferredSurfaces: ['gravel', 'fine_gravel', 'compacted', 'paved', 'asphalt', 'dirt']
},
hardtail_bikepacking: {
id: 'hardtail_bikepacking',
label: 'Hardtail bikepacking',
dailyDistanceKm: { min: 50, target: 70, max: 90 },
maxGradeWarningPercent: 15,
waterGapMaxKm: 55,
foodGapMaxKm: 90,
repairGapMaxKm: 130,
bailoutGapMaxKm: 110,
sleepGapMaxKm: 100,
roughSurfaceTolerance: 0.8,
trafficTolerance: 0.35,
unknownAccessPenalty: 10,
preferredSurfaces: ['dirt', 'singletrack', 'gravel', 'track', 'compacted']
},
road_touring: {
id: 'road_touring',
label: 'Road touring',
dailyDistanceKm: { min: 70, target: 100, max: 130 },
maxGradeWarningPercent: 10,
waterGapMaxKm: 40,
foodGapMaxKm: 70,
repairGapMaxKm: 120,
bailoutGapMaxKm: 90,
sleepGapMaxKm: 115,
roughSurfaceTolerance: 0.2,
trafficTolerance: 0.5,
unknownAccessPenalty: 14,
preferredSurfaces: ['paved', 'asphalt', 'concrete']
},
ebikepacking: {
id: 'ebikepacking',
label: 'E-bikepacking',
dailyDistanceKm: { min: 50, target: 75, max: 100 },
maxGradeWarningPercent: 12,
waterGapMaxKm: 40,
foodGapMaxKm: 60,
repairGapMaxKm: 100,
bailoutGapMaxKm: 80,
sleepGapMaxKm: 90,
roughSurfaceTolerance: 0.5,
trafficTolerance: 0.4,
unknownAccessPenalty: 15,
preferredSurfaces: ['paved', 'asphalt', 'gravel', 'compacted']
}
};
export function getProfile(profileId: BikepackingProfileId): BikepackingProfile {
return BIKEPACKING_PROFILES[profileId];
}
export function thresholdsFromTripRequest(request: TripRequest): CriticalGapThresholds {
const profile = getProfile(request.profile);
return {
water: (request.waterGapMaxKm ?? profile.waterGapMaxKm) * 1000,
food: (request.foodGapMaxKm ?? profile.foodGapMaxKm) * 1000,
repair: profile.repairGapMaxKm * 1000,
bailout: (request.requireBailoutEveryKm ?? profile.bailoutGapMaxKm) * 1000,
sleep: profile.sleepGapMaxKm * 1000,
charging: request.profile === 'ebikepacking' ? 60000 : undefined
};
}
export function maxLoadedGradeForRequest(request: TripRequest): number {
return request.maxLoadedGradePercent ?? getProfile(request.profile).maxGradeWarningPercent;
}

6
packages/shared/src/scoring.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
import type { BikepackingProfileId, HikeABikeRisk, RoutePlan, RouteScore, RouteSegment, SegmentScore } from './types.js';
export declare function estimateHikeABikeRisk(segment: RouteSegment, profileId: BikepackingProfileId): HikeABikeRisk;
export declare function scoreSegment(segment: RouteSegment, profileId: BikepackingProfileId): SegmentScore;
export declare function scoreRoute(route: RoutePlan, profileId: BikepackingProfileId): RouteScore;
export declare function calculateSurfaceBreakdown(segments: RouteSegment[]): Record<string, number>;
//# sourceMappingURL=scoring.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"scoring.d.ts","sourceRoot":"","sources":["scoring.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,oBAAoB,EACpB,aAAa,EACb,SAAS,EACT,UAAU,EACV,YAAY,EAEZ,YAAY,EACb,MAAM,YAAY,CAAC;AAMpB,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,oBAAoB,GAAG,aAAa,CAmC3G;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,YAAY,EAAE,SAAS,EAAE,oBAAoB,GAAG,YAAY,CAyFjG;AAED,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,oBAAoB,GAAG,UAAU,CAgBxF;AAED,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,YAAY,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAe1F"}

View File

@@ -0,0 +1,158 @@
import { getProfile } from './profiles.js';
const ROUGH_SURFACES = new Set(['sand', 'mud', 'rock', 'roots', 'grass', 'ground', 'singletrack']);
const ROUGH_SMOOTHNESS = new Set(['bad', 'very_bad', 'horrible', 'very_horrible', 'impassable']);
const UNPAVED_SURFACES = new Set(['gravel', 'fine_gravel', 'compacted', 'dirt', 'ground', 'sand', 'mud', 'grass']);
export function estimateHikeABikeRisk(segment, profileId) {
const profile = getProfile(profileId);
const maxGrade = segment.maxGradePercent ?? segment.avgGradePercent ?? 0;
let risk = 0;
if (maxGrade > profile.maxGradeWarningPercent) {
risk += 2 + (maxGrade - profile.maxGradeWarningPercent) / 2;
}
if (segment.avgGradePercent && segment.avgGradePercent > profile.maxGradeWarningPercent - 2) {
risk += 1.5;
}
if (segment.smoothness && ROUGH_SMOOTHNESS.has(segment.smoothness)) {
risk += 2.2;
}
if (segment.surface && ROUGH_SURFACES.has(segment.surface)) {
risk += 1.8;
}
if (segment.highway === 'path' || segment.highway === 'track') {
risk += 0.8;
}
if (segment.legalAccessConfidence === 'unknown' || segment.bicycleAccess === 'unknown') {
risk += 0.6;
}
risk -= profile.roughSurfaceTolerance;
if (risk >= 8) {
return 'critical';
}
if (risk >= 4.8) {
return 'high';
}
if (risk >= 2.3) {
return 'medium';
}
return 'low';
}
export function scoreSegment(segment, profileId) {
const profile = getProfile(profileId);
const hikeABikeRisk = estimateHikeABikeRisk(segment, profileId);
const warnings = [];
const costBreakdown = {};
const maxGrade = segment.maxGradePercent ?? 0;
costBreakdown.distance = Math.min(10, segment.distanceM / 10000);
costBreakdown.climb = Math.min(18, (segment.ascentM ?? 0) / 70);
if (maxGrade > profile.maxGradeWarningPercent) {
costBreakdown.steepness = Math.min(28, (maxGrade - profile.maxGradeWarningPercent) * 3.5);
warnings.push({
code: 'STEEP_LOADED_BIKE_CLIMB',
severity: maxGrade > profile.maxGradeWarningPercent + 5 ? 'critical' : 'warning',
title: 'Steep loaded-bike climb',
message: `Segment reaches ${maxGrade.toFixed(1)}%, above the ${profile.maxGradeWarningPercent}% ${profile.label} warning threshold.`,
metersFromStart: segment.startMeters,
segmentId: segment.id
});
}
else {
costBreakdown.steepness = 0;
}
if (segment.surface && !profile.preferredSurfaces.includes(segment.surface)) {
costBreakdown.surface = ROUGH_SURFACES.has(segment.surface) ? 14 : 8;
}
else if (segment.smoothness && ROUGH_SMOOTHNESS.has(segment.smoothness)) {
costBreakdown.surface = 8;
}
else {
costBreakdown.surface = 0;
}
costBreakdown.traffic = Math.max(0, ((segment.trafficStress ?? 0) - profile.trafficTolerance) * 22);
if (segment.legalAccessConfidence === 'unknown' || segment.bicycleAccess === 'unknown') {
costBreakdown.access = profile.unknownAccessPenalty;
warnings.push({
code: 'UNKNOWN_ACCESS',
severity: 'warning',
title: 'Uncertain bicycle access',
message: 'Bicycle access is not confirmed for this segment. Treat it as advisory and verify locally.',
metersFromStart: segment.startMeters,
segmentId: segment.id
});
}
else if (segment.legalAccessConfidence === 'restricted' || segment.bicycleAccess === 'no') {
costBreakdown.access = 35;
warnings.push({
code: 'RESTRICTED_ACCESS',
severity: 'critical',
title: 'Potential access restriction',
message: 'This segment may be restricted for bicycles. Verify before relying on the route.',
metersFromStart: segment.startMeters,
segmentId: segment.id
});
}
else {
costBreakdown.access = 0;
}
if (segment.protectedAreaOverlap) {
costBreakdown.protectedArea = 8;
warnings.push({
code: 'PROTECTED_AREA_OVERLAP',
severity: 'notice',
title: 'Protected-area overlap',
message: 'Route crosses or borders a protected area. Local riding and camping rules may be stricter here.',
metersFromStart: segment.startMeters,
segmentId: segment.id
});
}
else {
costBreakdown.protectedArea = 0;
}
costBreakdown.hikeABike =
hikeABikeRisk === 'critical' ? 24 : hikeABikeRisk === 'high' ? 14 : hikeABikeRisk === 'medium' ? 6 : 0;
const bonus = (segment.officialCycleRoute ? 7 : 0) +
(segment.communityScore && segment.communityScore > 0 ? Math.min(8, segment.communityScore * 8) : 0);
const totalCost = Object.values(costBreakdown).reduce((sum, value) => sum + value, 0);
const score = clamp(100 - totalCost + bonus, 0, 100);
return {
segmentId: segment.id,
score: round(score, 1),
hikeABikeRisk,
warnings,
costBreakdown
};
}
export function scoreRoute(route, profileId) {
const segmentScores = route.segments.map((segment) => scoreSegment(segment, profileId));
const distanceTotal = Math.max(1, route.segments.reduce((sum, segment) => sum + segment.distanceM, 0));
const weightedScore = route.segments.reduce((sum, segment, index) => {
const segmentScore = segmentScores[index]?.score ?? 0;
return sum + segmentScore * (segment.distanceM / distanceTotal);
}, 0) || 0;
const warnings = segmentScores.flatMap((score) => score.warnings);
return {
suitabilityScore: round(weightedScore, 1),
warnings,
segmentScores,
surfaceBreakdown: calculateSurfaceBreakdown(route.segments)
};
}
export function calculateSurfaceBreakdown(segments) {
const totals = new Map();
const totalDistance = Math.max(1, segments.reduce((sum, segment) => sum + segment.distanceM, 0));
for (const segment of segments) {
const surface = segment.surface ?? 'unknown';
const normalized = UNPAVED_SURFACES.has(surface) ? surface : surface;
totals.set(normalized, (totals.get(normalized) ?? 0) + segment.distanceM);
}
return Object.fromEntries([...totals.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([surface, meters]) => [surface, round(meters / totalDistance, 3)]));
}
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function round(value, precision) {
const factor = 10 ** precision;
return Math.round(value * factor) / factor;
}
//# sourceMappingURL=scoring.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import sampleScoring from '../../../examples/sample_segment_scoring.json';
import { estimateHikeABikeRisk, scoreSegment } from './scoring.js';
import type { RouteSegment } from './types.js';
describe('segment scoring', () => {
it('matches the sample loaded-gravel warning fixture', () => {
const fixture = sampleScoring as {
profile: 'loaded_gravel';
segment: Omit<RouteSegment, 'id' | 'geometry'>;
expected: { hikeABikeRisk: string; warnings: string[] };
};
const segment: RouteSegment = {
id: 'fixture_segment',
geometry: [
{ lat: 48.1, lon: 11.5 },
{ lat: 48.0, lon: 11.4 }
],
...fixture.segment
};
const result = scoreSegment(segment, fixture.profile);
expect(estimateHikeABikeRisk(segment, fixture.profile)).toBe(fixture.expected.hikeABikeRisk);
expect(result.warnings.map((warning) => warning.code)).toEqual(expect.arrayContaining(fixture.expected.warnings));
expect(result.score).toBeLessThan(80);
});
});

View File

@@ -0,0 +1,186 @@
import { getProfile } from './profiles.js';
import type {
BikepackingProfileId,
HikeABikeRisk,
RoutePlan,
RouteScore,
RouteSegment,
RouteWarning,
SegmentScore
} from './types.js';
const ROUGH_SURFACES = new Set(['sand', 'mud', 'rock', 'roots', 'grass', 'ground', 'singletrack']);
const ROUGH_SMOOTHNESS = new Set(['bad', 'very_bad', 'horrible', 'very_horrible', 'impassable']);
const UNPAVED_SURFACES = new Set(['gravel', 'fine_gravel', 'compacted', 'dirt', 'ground', 'sand', 'mud', 'grass']);
export function estimateHikeABikeRisk(segment: RouteSegment, profileId: BikepackingProfileId): HikeABikeRisk {
const profile = getProfile(profileId);
const maxGrade = segment.maxGradePercent ?? segment.avgGradePercent ?? 0;
let risk = 0;
if (maxGrade > profile.maxGradeWarningPercent) {
risk += 2 + (maxGrade - profile.maxGradeWarningPercent) / 2;
}
if (segment.avgGradePercent && segment.avgGradePercent > profile.maxGradeWarningPercent - 2) {
risk += 1.5;
}
if (segment.smoothness && ROUGH_SMOOTHNESS.has(segment.smoothness)) {
risk += 2.2;
}
if (segment.surface && ROUGH_SURFACES.has(segment.surface)) {
risk += 1.8;
}
if (segment.highway === 'path' || segment.highway === 'track') {
risk += 0.8;
}
if (segment.legalAccessConfidence === 'unknown' || segment.bicycleAccess === 'unknown') {
risk += 0.6;
}
risk -= profile.roughSurfaceTolerance;
if (risk >= 8) {
return 'critical';
}
if (risk >= 4.8) {
return 'high';
}
if (risk >= 2.3) {
return 'medium';
}
return 'low';
}
export function scoreSegment(segment: RouteSegment, profileId: BikepackingProfileId): SegmentScore {
const profile = getProfile(profileId);
const hikeABikeRisk = estimateHikeABikeRisk(segment, profileId);
const warnings: RouteWarning[] = [];
const costBreakdown: Record<string, number> = {};
const maxGrade = segment.maxGradePercent ?? 0;
costBreakdown.distance = Math.min(10, segment.distanceM / 10000);
costBreakdown.climb = Math.min(18, (segment.ascentM ?? 0) / 70);
if (maxGrade > profile.maxGradeWarningPercent) {
costBreakdown.steepness = Math.min(28, (maxGrade - profile.maxGradeWarningPercent) * 3.5);
warnings.push({
code: 'STEEP_LOADED_BIKE_CLIMB',
severity: maxGrade > profile.maxGradeWarningPercent + 5 ? 'critical' : 'warning',
title: 'Steep loaded-bike climb',
message: `Segment reaches ${maxGrade.toFixed(1)}%, above the ${profile.maxGradeWarningPercent}% ${profile.label} warning threshold.`,
metersFromStart: segment.startMeters,
segmentId: segment.id
});
} else {
costBreakdown.steepness = 0;
}
if (segment.surface && !profile.preferredSurfaces.includes(segment.surface)) {
costBreakdown.surface = ROUGH_SURFACES.has(segment.surface) ? 14 : 8;
} else if (segment.smoothness && ROUGH_SMOOTHNESS.has(segment.smoothness)) {
costBreakdown.surface = 8;
} else {
costBreakdown.surface = 0;
}
costBreakdown.traffic = Math.max(0, ((segment.trafficStress ?? 0) - profile.trafficTolerance) * 22);
if (segment.legalAccessConfidence === 'unknown' || segment.bicycleAccess === 'unknown') {
costBreakdown.access = profile.unknownAccessPenalty;
warnings.push({
code: 'UNKNOWN_ACCESS',
severity: 'warning',
title: 'Uncertain bicycle access',
message: 'Bicycle access is not confirmed for this segment. Treat it as advisory and verify locally.',
metersFromStart: segment.startMeters,
segmentId: segment.id
});
} else if (segment.legalAccessConfidence === 'restricted' || segment.bicycleAccess === 'no') {
costBreakdown.access = 35;
warnings.push({
code: 'RESTRICTED_ACCESS',
severity: 'critical',
title: 'Potential access restriction',
message: 'This segment may be restricted for bicycles. Verify before relying on the route.',
metersFromStart: segment.startMeters,
segmentId: segment.id
});
} else {
costBreakdown.access = 0;
}
if (segment.protectedAreaOverlap) {
costBreakdown.protectedArea = 8;
warnings.push({
code: 'PROTECTED_AREA_OVERLAP',
severity: 'notice',
title: 'Protected-area overlap',
message: 'Route crosses or borders a protected area. Local riding and camping rules may be stricter here.',
metersFromStart: segment.startMeters,
segmentId: segment.id
});
} else {
costBreakdown.protectedArea = 0;
}
costBreakdown.hikeABike =
hikeABikeRisk === 'critical' ? 24 : hikeABikeRisk === 'high' ? 14 : hikeABikeRisk === 'medium' ? 6 : 0;
const bonus =
(segment.officialCycleRoute ? 7 : 0) +
(segment.communityScore && segment.communityScore > 0 ? Math.min(8, segment.communityScore * 8) : 0);
const totalCost = Object.values(costBreakdown).reduce((sum, value) => sum + value, 0);
const score = clamp(100 - totalCost + bonus, 0, 100);
return {
segmentId: segment.id,
score: round(score, 1),
hikeABikeRisk,
warnings,
costBreakdown
};
}
export function scoreRoute(route: RoutePlan, profileId: BikepackingProfileId): RouteScore {
const segmentScores = route.segments.map((segment) => scoreSegment(segment, profileId));
const distanceTotal = Math.max(1, route.segments.reduce((sum, segment) => sum + segment.distanceM, 0));
const weightedScore =
route.segments.reduce((sum, segment, index) => {
const segmentScore = segmentScores[index]?.score ?? 0;
return sum + segmentScore * (segment.distanceM / distanceTotal);
}, 0) || 0;
const warnings = segmentScores.flatMap((score) => score.warnings);
return {
suitabilityScore: round(weightedScore, 1),
warnings,
segmentScores,
surfaceBreakdown: calculateSurfaceBreakdown(route.segments)
};
}
export function calculateSurfaceBreakdown(segments: RouteSegment[]): Record<string, number> {
const totals = new Map<string, number>();
const totalDistance = Math.max(1, segments.reduce((sum, segment) => sum + segment.distanceM, 0));
for (const segment of segments) {
const surface = segment.surface ?? 'unknown';
const normalized = UNPAVED_SURFACES.has(surface) ? surface : surface;
totals.set(normalized, (totals.get(normalized) ?? 0) + segment.distanceM);
}
return Object.fromEntries(
[...totals.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([surface, meters]) => [surface, round(meters / totalDistance, 3)])
);
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function round(value: number, precision: number): number {
const factor = 10 ** precision;
return Math.round(value * factor) / factor;
}

4
packages/shared/src/staging.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
import type { Poi, RoutePlan, StagePlan, TripRequest } from './types.js';
export declare function planStages(route: RoutePlan, pois: Poi[], tripRequest: TripRequest): StagePlan[];
export declare function estimateStageCount(distanceM: number, tripRequest: TripRequest): number;
//# sourceMappingURL=staging.d.ts.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"staging.d.ts","sourceRoot":"","sources":["staging.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,GAAG,EAAE,SAAS,EAAgB,SAAS,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAIvF,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,GAAG,EAAE,EAAE,WAAW,EAAE,WAAW,GAAG,SAAS,EAAE,CA0D/F;AAED,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,WAAW,GAAG,MAAM,CAMtF"}

View File

@@ -0,0 +1,218 @@
import { getProfile, thresholdsFromTripRequest } from './profiles.js';
import { detectCriticalGaps, warningsFromGaps } from './pois.js';
const ENDPOINT_TOLERANCE_M = 8000;
export function planStages(route, pois, tripRequest) {
const profile = getProfile(tripRequest.profile);
const desiredStages = estimateStageCount(route.distanceM, tripRequest);
const stages = [];
let startMeters = 0;
for (let dayIndex = 1; dayIndex <= desiredStages; dayIndex += 1) {
const isLastStage = dayIndex === desiredStages;
const remainingStages = desiredStages - dayIndex;
const targetEnd = isLastStage ? route.distanceM : Math.round((route.distanceM / desiredStages) * dayIndex);
const minEnd = startMeters + tripRequest.dailyDistanceKm.min * 1000;
const maxEnd = Math.min(route.distanceM - remainingStages * tripRequest.dailyDistanceKm.min * 1000, startMeters + tripRequest.dailyDistanceKm.max * 1000);
const boundedTarget = Math.max(minEnd, Math.min(maxEnd, targetEnd));
const sleepCandidate = isLastStage
? undefined
: chooseSleepEndpoint(pois, boundedTarget, minEnd, maxEnd, tripRequest.sleepPreference);
const endMeters = isLastStage ? route.distanceM : sleepCandidate?.metersFromStart ?? boundedTarget;
const distanceM = Math.max(0, endMeters - startMeters);
const stagePois = poisWithin(pois, startMeters, endMeters);
const ascentM = estimateStageAscent(route, startMeters, endMeters);
const descentM = estimateStageDescent(route, startMeters, endMeters);
const stageWarnings = stageWarningsFor({
route,
stagePois,
startMeters,
endMeters,
distanceM,
ascentM,
tripRequest,
hasSleepEndpoint: isLastStage || Boolean(sleepCandidate)
});
stages.push({
id: `stage_${route.id}_${dayIndex}`,
routeId: route.id,
dayIndex,
startMeters,
endMeters,
distanceM,
ascentM,
descentM,
expectedRideTimeMinutes: expectedRideTimeMinutes(distanceM, ascentM, profile.roughSurfaceTolerance),
sleepCandidates: nearbyEndpointPois(stagePois, endMeters, 'sleep'),
waterPois: stagePois.filter((poi) => poi.category === 'water'),
foodPois: stagePois.filter((poi) => poi.category === 'food'),
repairPois: stagePois.filter((poi) => poi.category === 'repair'),
bailoutPois: stagePois.filter((poi) => poi.category === 'bailout'),
warnings: stageWarnings,
planB: planBForEndpoint(pois, endMeters, minEnd, maxEnd)
});
startMeters = endMeters;
}
return stages;
}
export function estimateStageCount(distanceM, tripRequest) {
const dateDays = inclusiveDateDays(tripRequest.startDate, tripRequest.endDate);
if (dateDays) {
return dateDays;
}
return Math.max(1, Math.ceil(distanceM / Math.max(1, tripRequest.dailyDistanceKm.target * 1000)));
}
function inclusiveDateDays(startDate, endDate) {
if (!startDate || !endDate) {
return undefined;
}
const start = Date.parse(`${startDate}T00:00:00Z`);
const end = Date.parse(`${endDate}T00:00:00Z`);
if (Number.isNaN(start) || Number.isNaN(end) || end < start) {
return undefined;
}
return Math.floor((end - start) / 86400000) + 1;
}
function chooseSleepEndpoint(pois, targetM, minM, maxM, sleepPreference) {
const candidates = pois
.filter((poi) => poi.category === 'sleep')
.filter((poi) => typeof poi.metersFromStart === 'number')
.filter((poi) => {
const meters = poi.metersFromStart ?? 0;
return meters >= minM && meters <= maxM;
})
.filter((poi) => {
if (sleepPreference === 'indoor_only') {
return poi.tags?.sleepType === 'hostel' || poi.tags?.sleepType === 'hotel';
}
if (sleepPreference === 'official_campsites') {
return poi.tags?.sleepType === 'campsite';
}
return true;
});
return candidates.sort((left, right) => {
const leftDistance = Math.abs((left.metersFromStart ?? 0) - targetM);
const rightDistance = Math.abs((right.metersFromStart ?? 0) - targetM);
const confidenceDelta = confidenceRank(right.confidence) - confidenceRank(left.confidence);
return leftDistance - rightDistance || confidenceDelta;
})[0];
}
function poisWithin(pois, startM, endM) {
return pois
.filter((poi) => {
const meters = poi.metersFromStart;
return typeof meters === 'number' && meters >= startM && meters <= endM;
})
.sort((left, right) => (left.metersFromStart ?? 0) - (right.metersFromStart ?? 0));
}
function nearbyEndpointPois(pois, endM, category) {
return pois
.filter((poi) => poi.category === category)
.filter((poi) => Math.abs((poi.metersFromStart ?? 0) - endM) <= ENDPOINT_TOLERANCE_M)
.slice(0, 4);
}
function estimateStageAscent(route, startM, endM) {
return Math.round(route.segments.reduce((sum, segment) => {
const segmentStart = segment.startMeters ?? 0;
const segmentEnd = segment.endMeters ?? segmentStart + segment.distanceM;
const overlap = Math.max(0, Math.min(endM, segmentEnd) - Math.max(startM, segmentStart));
if (overlap === 0) {
return sum;
}
return sum + (segment.ascentM ?? 0) * (overlap / Math.max(1, segment.distanceM));
}, 0));
}
function estimateStageDescent(route, startM, endM) {
return Math.round(route.segments.reduce((sum, segment) => {
const segmentStart = segment.startMeters ?? 0;
const segmentEnd = segment.endMeters ?? segmentStart + segment.distanceM;
const overlap = Math.max(0, Math.min(endM, segmentEnd) - Math.max(startM, segmentStart));
if (overlap === 0) {
return sum;
}
return sum + (segment.descentM ?? 0) * (overlap / Math.max(1, segment.distanceM));
}, 0));
}
function expectedRideTimeMinutes(distanceM, ascentM, roughSurfaceTolerance) {
const baseMovingKmh = 16 + roughSurfaceTolerance * 3;
const low = Math.round((distanceM / 1000 / baseMovingKmh) * 60 + (ascentM / 450) * 45);
const high = Math.round((distanceM / 1000 / Math.max(9, baseMovingKmh - 4)) * 60 + (ascentM / 350) * 55);
return { low, high };
}
function stageWarningsFor(input) {
const warnings = [];
const maxDistanceM = input.tripRequest.dailyDistanceKm.max * 1000;
if (input.distanceM > maxDistanceM) {
warnings.push({
code: 'STAGE_DISTANCE_OVER_MAX',
severity: 'warning',
title: 'Stage exceeds daily distance',
message: `Stage is ${(input.distanceM / 1000).toFixed(1)} km, above the requested ${input.tripRequest.dailyDistanceKm.max} km maximum.`,
metersFromStart: input.startMeters
});
}
if (input.tripRequest.dailyAscentM?.max && input.ascentM > input.tripRequest.dailyAscentM.max) {
warnings.push({
code: 'STAGE_ASCENT_OVER_MAX',
severity: 'warning',
title: 'Stage exceeds climbing limit',
message: `Stage climbs ${input.ascentM} m, above the requested ${input.tripRequest.dailyAscentM.max} m maximum.`,
metersFromStart: input.startMeters
});
}
if (!input.hasSleepEndpoint) {
warnings.push({
code: 'NO_CONFIDENT_SLEEP_ENDPOINT',
severity: 'warning',
title: 'No confident sleep endpoint',
message: 'No sleep POI matched the stage endpoint window. Plan a fallback before committing to this day.',
metersFromStart: input.endMeters
});
}
const stageRoute = { ...input.route, distanceM: input.endMeters - input.startMeters };
const shiftedPois = input.stagePois.map((poi) => ({
...poi,
metersFromStart: typeof poi.metersFromStart === 'number' ? poi.metersFromStart - input.startMeters : undefined
}));
const stageThresholds = thresholdsFromTripRequest(input.tripRequest);
warnings.push(...warningsFromGaps(detectCriticalGaps(stageRoute, shiftedPois, stageThresholds)));
return dedupeWarnings(warnings);
}
function planBForEndpoint(pois, endM, minM, maxM) {
const sleepPois = pois
.filter((poi) => poi.category === 'sleep' && typeof poi.metersFromStart === 'number')
.sort((left, right) => (left.metersFromStart ?? 0) - (right.metersFromStart ?? 0));
const earlier = [...sleepPois].reverse().find((poi) => (poi.metersFromStart ?? 0) < endM && (poi.metersFromStart ?? 0) >= minM);
const later = sleepPois.find((poi) => (poi.metersFromStart ?? 0) > endM && (poi.metersFromStart ?? 0) <= maxM);
if (!earlier && !later) {
return undefined;
}
return {
earlierEndMeters: earlier?.metersFromStart,
laterEndMeters: later?.metersFromStart,
reason: 'Fallback sleep endpoints inside the daily distance window.'
};
}
function confidenceRank(confidence) {
return confidence === 'known_good'
? 6
: confidence === 'high'
? 5
: confidence === 'medium'
? 4
: confidence === 'low'
? 3
: confidence === 'unknown'
? 2
: 1;
}
function dedupeWarnings(warnings) {
const seen = new Set();
return warnings.filter((warning) => {
const key = `${warning.code}:${warning.metersFromStart ?? 0}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
//# sourceMappingURL=staging.js.map

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { createMockRoute, mockPois, mockTripRequest } from './fixtures.js';
import { planStages } from './staging.js';
describe('stage planner', () => {
it('splits the sample trip into five plausible bikepacking stages', () => {
const route = createMockRoute();
const stages = planStages(route, mockPois, mockTripRequest);
expect(stages).toHaveLength(5);
expect(stages[0]?.distanceM).toBeGreaterThan(55000);
expect(stages[0]?.sleepCandidates[0]?.name).toBe('Lakeside campsite');
expect(stages.every((stage) => stage.distanceM <= mockTripRequest.dailyDistanceKm.max * 1000)).toBe(true);
expect(stages.some((stage) => stage.waterPois.length > 0)).toBe(true);
});
it('creates a three-day plan without hiding hard-constraint warnings', () => {
const route = createMockRoute();
const stages = planStages(route, mockPois, {
...mockTripRequest,
startDate: undefined,
endDate: undefined,
dailyDistanceKm: { min: 100, target: 128, max: 138 },
dailyAscentM: { max: 2600 }
});
expect(stages).toHaveLength(3);
for (const stage of stages) {
if (stage.distanceM > 138000) {
expect(stage.warnings.map((warning) => warning.code)).toContain('STAGE_DISTANCE_OVER_MAX');
}
}
});
});

Some files were not shown because too many files have changed in this diff Show More