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

View File

@@ -0,0 +1,77 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Bikepacking Profile",
"type": "object",
"required": [
"id",
"label",
"thresholds",
"weights"
],
"properties": {
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"thresholds": {
"type": "object",
"properties": {
"targetDistanceKm": {
"type": "number"
},
"minDistanceKm": {
"type": "number"
},
"maxDistanceKm": {
"type": "number"
},
"maxLoadedGradePercent": {
"type": "number"
},
"maxWaterGapKm": {
"type": "number"
},
"maxFoodGapKm": {
"type": "number"
},
"maxBailoutGapKm": {
"type": "number"
}
}
},
"weights": {
"type": "object",
"properties": {
"distance": {
"type": "number"
},
"climb": {
"type": "number"
},
"steepness": {
"type": "number"
},
"poorSurface": {
"type": "number"
},
"trafficStress": {
"type": "number"
},
"accessUncertainty": {
"type": "number"
},
"hikeABikeRisk": {
"type": "number"
},
"officialCycleRouteBonus": {
"type": "number"
},
"confirmedGoodBonus": {
"type": "number"
}
}
}
}
}

196
schemas/database.sql Normal file
View File

@@ -0,0 +1,196 @@
-- BikepackPilot database draft
-- Requires PostgreSQL + PostGIS.
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TYPE confidence_level AS ENUM (
'known_good', 'high', 'medium', 'low', 'unknown', 'restricted', 'conflicting'
);
CREATE TYPE warning_severity AS ENUM ('info', 'notice', 'warning', 'critical');
CREATE TYPE poi_category AS ENUM (
'water', 'food', 'sleep', 'repair', 'bailout', 'charging', 'emergency'
);
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE,
display_name TEXT,
default_profile TEXT NOT NULL DEFAULT 'loaded_gravel',
privacy_mode TEXT NOT NULL DEFAULT 'private',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE trips (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
title TEXT NOT NULL,
start_point GEOGRAPHY(Point, 4326) NOT NULL,
end_point GEOGRAPHY(Point, 4326) NOT NULL,
start_date DATE,
end_date DATE,
profile TEXT NOT NULL,
constraints JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'draft',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE routes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
trip_id UUID NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
geometry GEOGRAPHY(LineString, 4326) NOT NULL,
distance_m INTEGER NOT NULL,
ascent_m INTEGER DEFAULT 0,
descent_m INTEGER DEFAULT 0,
surface_breakdown JSONB NOT NULL DEFAULT '{}'::jsonb,
suitability_score NUMERIC(5,2),
warnings JSONB NOT NULL DEFAULT '[]'::jsonb,
attribution TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE route_segments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
route_id UUID NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
seq INTEGER NOT NULL,
start_m INTEGER NOT NULL,
end_m INTEGER NOT NULL,
geometry GEOGRAPHY(LineString, 4326) NOT NULL,
distance_m INTEGER NOT NULL,
ascent_m INTEGER DEFAULT 0,
descent_m INTEGER DEFAULT 0,
avg_grade_percent NUMERIC(5,2),
max_grade_percent NUMERIC(5,2),
surface TEXT,
smoothness TEXT,
highway TEXT,
bicycle_access TEXT,
legal_access_confidence confidence_level NOT NULL DEFAULT 'unknown',
source_confidence confidence_level NOT NULL DEFAULT 'unknown',
traffic_stress NUMERIC(4,2),
official_cycle_route BOOLEAN DEFAULT false,
protected_area_overlap BOOLEAN DEFAULT false,
scores JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX route_segments_route_seq_idx ON route_segments(route_id, seq);
CREATE INDEX route_segments_geom_idx ON route_segments USING GIST(geometry);
CREATE TABLE pois (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
category poi_category NOT NULL,
name TEXT,
location GEOGRAPHY(Point, 4326) NOT NULL,
source TEXT NOT NULL,
source_id TEXT,
confidence confidence_level NOT NULL DEFAULT 'unknown',
opening_hours TEXT,
last_seen_at TIMESTAMPTZ,
last_confirmed_by_user_at TIMESTAMPTZ,
tags JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX pois_category_idx ON pois(category);
CREATE INDEX pois_location_idx ON pois USING GIST(location);
CREATE UNIQUE INDEX pois_source_source_id_idx ON pois(source, source_id) WHERE source_id IS NOT NULL;
CREATE TABLE route_pois (
route_id UUID NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
poi_id UUID NOT NULL REFERENCES pois(id) ON DELETE CASCADE,
meters_from_start INTEGER,
distance_from_route_m INTEGER,
detour_distance_m INTEGER,
relevance_score NUMERIC(5,2),
PRIMARY KEY (route_id, poi_id)
);
CREATE TABLE stages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
route_id UUID NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
day_index INTEGER NOT NULL,
start_m INTEGER NOT NULL,
end_m INTEGER NOT NULL,
distance_m INTEGER NOT NULL,
ascent_m INTEGER DEFAULT 0,
descent_m INTEGER DEFAULT 0,
endpoint GEOGRAPHY(Point, 4326) NOT NULL,
sleep_candidates JSONB NOT NULL DEFAULT '[]'::jsonb,
water_pois JSONB NOT NULL DEFAULT '[]'::jsonb,
food_pois JSONB NOT NULL DEFAULT '[]'::jsonb,
repair_pois JSONB NOT NULL DEFAULT '[]'::jsonb,
bailout_pois JSONB NOT NULL DEFAULT '[]'::jsonb,
warnings JSONB NOT NULL DEFAULT '[]'::jsonb,
plan_b JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE service_gaps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
route_id UUID NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
category poi_category NOT NULL,
start_m INTEGER NOT NULL,
end_m INTEGER NOT NULL,
distance_m INTEGER NOT NULL,
severity warning_severity NOT NULL,
explanation TEXT NOT NULL
);
CREATE TABLE rule_cards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rule_type TEXT NOT NULL,
jurisdiction TEXT NOT NULL,
status TEXT NOT NULL,
confidence confidence_level NOT NULL DEFAULT 'unknown',
summary TEXT NOT NULL,
source_url TEXT,
source_name TEXT,
last_reviewed_at DATE,
valid_from DATE,
valid_to DATE,
geometry GEOGRAPHY(Geometry, 4326),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX rule_cards_geom_idx ON rule_cards USING GIST(geometry);
CREATE TABLE local_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
route_id UUID REFERENCES routes(id) ON DELETE SET NULL,
route_segment_id UUID REFERENCES route_segments(id) ON DELETE SET NULL,
report_type TEXT NOT NULL,
location GEOGRAPHY(Point, 4326) NOT NULL,
meters_from_start INTEGER,
observed_at TIMESTAMPTZ NOT NULL,
details TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
trust_score NUMERIC(5,2) DEFAULT 0,
expires_at TIMESTAMPTZ,
moderation_status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX local_reports_location_idx ON local_reports USING GIST(location);
CREATE INDEX local_reports_type_idx ON local_reports(report_type);
CREATE TABLE offline_packs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
trip_id UUID NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
route_id UUID NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
bbox GEOGRAPHY(Polygon, 4326),
route_buffer_km NUMERIC(6,2) NOT NULL DEFAULT 5,
included_layers JSONB NOT NULL DEFAULT '[]'::jsonb,
data_versions JSONB NOT NULL DEFAULT '{}'::jsonb,
files JSONB NOT NULL DEFAULT '[]'::jsonb,
warnings JSONB NOT NULL DEFAULT '[]'::jsonb,
status TEXT NOT NULL DEFAULT 'queued',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ
);

150
schemas/domain_types.ts Normal file
View File

@@ -0,0 +1,150 @@
export type Coordinate = {
lat: number;
lon: number;
elevationM?: number;
};
export type BikepackingProfileId =
| 'loaded_gravel'
| 'hardtail_bikepacking'
| 'road_touring'
| 'ebikepacking'
| 'beginner_safe';
export type SleepPreference =
| 'official_campsites'
| 'mixed_budget'
| 'indoor_only'
| 'wild_where_legal'
| 'any_safe_option';
export type PoiCategory =
| 'water'
| 'food'
| 'sleep'
| 'repair'
| 'bailout'
| 'charging'
| 'emergency';
export type Confidence =
| 'known_good'
| 'high'
| 'medium'
| 'low'
| 'unknown'
| 'restricted'
| 'conflicting';
export type WarningSeverity = 'info' | 'notice' | 'warning' | 'critical';
export type TripRequest = {
start: Coordinate;
end: Coordinate;
viaPoints?: Coordinate[];
startDate?: string;
endDate?: string;
profile: BikepackingProfileId;
sleepPreference: SleepPreference;
dailyDistanceKm: { min: number; target: number; max: number };
dailyAscentM?: { target?: number; max?: number };
maxLoadedGradePercent?: number;
preferredUnpavedPercent?: { min?: number; max?: number };
waterGapMaxKm?: number;
foodGapMaxKm?: number;
requireBailoutEveryKm?: number;
};
export type RouteSegment = {
id: string;
geometry: Coordinate[];
distanceM: number;
ascentM?: number;
descentM?: number;
avgGradePercent?: number;
maxGradePercent?: number;
surface?: string;
smoothness?: string;
highway?: string;
bicycleAccess?: string;
legalAccessConfidence: Confidence;
trafficStress?: number;
officialCycleRoute?: boolean;
protectedAreaOverlap?: boolean;
communityScore?: number;
sourceConfidence: Confidence;
};
export type RouteWarning = {
code: string;
severity: WarningSeverity;
title: string;
message: string;
metersFromStart?: number;
segmentId?: string;
};
export type RoutePlan = {
id: string;
tripId: string;
geometry: Coordinate[];
distanceM: number;
ascentM: number;
descentM: number;
segments: RouteSegment[];
surfaceBreakdown: Record<string, number>;
suitabilityScore: number;
warnings: RouteWarning[];
provider: string;
};
export type Poi = {
id: string;
category: PoiCategory;
name: string;
location: Coordinate;
source: string;
confidence: Confidence;
distanceFromRouteM?: number;
metersFromStart?: number;
detourDistanceM?: number;
openingHours?: string;
lastConfirmedAt?: string;
tags?: Record<string, string>;
};
export type StagePlan = {
id: string;
routeId: string;
dayIndex: number;
startMeters: number;
endMeters: number;
distanceM: number;
ascentM?: number;
descentM?: number;
expectedRideTimeMinutes?: { low: number; high: number };
sleepCandidates: Poi[];
waterPois: Poi[];
foodPois: Poi[];
repairPois: Poi[];
bailoutPois: Poi[];
warnings: RouteWarning[];
planB?: {
earlierEndMeters?: number;
laterEndMeters?: number;
reason: string;
};
};
export type OfflinePackManifest = {
packId: string;
tripId: string;
routeId: string;
createdAt: string;
bbox: [number, number, number, number];
routeBufferKm: number;
includedLayers: string[];
dataVersions: Record<string, string>;
files: Array<{ type: string; url?: string; path?: string; bytes?: number }>;
warnings: RouteWarning[];
};

423
schemas/openapi.yaml Normal file
View File

@@ -0,0 +1,423 @@
openapi: 3.1.0
info:
title: BikepackPilot API
version: 0.1.0
description: MVP API contract for bikepacking route planning, staging, POI corridor, offline packs, exports, and local reports.
servers:
- url: http://localhost:8000
paths:
/v1/trips:
post:
summary: Create trip
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/TripRequest'
responses:
'201':
description: Trip created
content:
application/json:
schema:
$ref: '#/components/schemas/Trip'
/v1/routes/plan:
post:
summary: Plan a bikepacking route
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoutePlanRequest'
responses:
'200':
description: Route plan
content:
application/json:
schema:
$ref: '#/components/schemas/RoutePlan'
/v1/stages/plan:
post:
summary: Split a route into daily stages
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/StagePlanRequest'
responses:
'200':
description: Stage plans
content:
application/json:
schema:
type: object
properties:
stages:
type: array
items:
$ref: '#/components/schemas/StagePlan'
/v1/routes/{routeId}/pois:
get:
summary: Get route-corridor POIs
parameters:
- in: path
name: routeId
required: true
schema: { type: string }
- in: query
name: category
schema: { $ref: '#/components/schemas/PoiCategory' }
- in: query
name: buffer_km
schema: { type: number, default: 5 }
responses:
'200':
description: POIs
content:
application/json:
schema:
type: object
properties:
pois:
type: array
items:
$ref: '#/components/schemas/Poi'
attribution:
type: array
items: { type: string }
/v1/routes/{routeId}/gaps:
get:
summary: Get critical service gaps
parameters:
- in: path
name: routeId
required: true
schema: { type: string }
responses:
'200':
description: Service gaps
content:
application/json:
schema:
type: object
properties:
gaps:
type: array
items:
$ref: '#/components/schemas/ServiceGap'
/v1/rules:
get:
summary: Get local rule cards
parameters:
- in: query
name: bbox
schema:
type: string
description: minLon,minLat,maxLon,maxLat
- in: query
name: route_id
schema: { type: string }
responses:
'200':
description: Rule cards
content:
application/json:
schema:
type: object
properties:
rules:
type: array
items:
$ref: '#/components/schemas/RuleCard'
disclaimer:
type: string
/v1/offline-packs:
post:
summary: Create offline pack manifest
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [tripId, routeId]
properties:
tripId: { type: string }
routeId: { type: string }
routeBufferKm: { type: number, default: 5 }
responses:
'200':
description: Offline pack manifest
content:
application/json:
schema:
$ref: '#/components/schemas/OfflinePackManifest'
/v1/routes/{routeId}/export:
get:
summary: Export route
parameters:
- in: path
name: routeId
required: true
schema: { type: string }
- in: query
name: format
required: true
schema:
type: string
enum: [gpx, tcx, fit]
responses:
'200':
description: Route export file
content:
application/gpx+xml:
schema: { type: string }
application/json:
schema: { type: object }
/v1/reports:
post:
summary: Submit local report
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LocalReportCreate'
responses:
'201':
description: Report submitted
content:
application/json:
schema:
$ref: '#/components/schemas/LocalReport'
components:
schemas:
Coordinate:
type: object
required: [lat, lon]
properties:
lat: { type: number }
lon: { type: number }
elevationM:
type: number
description: Optional elevation in meters above sea level when supplied by the active provider.
TripRequest:
type: object
required: [start, end, profile, sleepPreference, dailyDistanceKm]
properties:
start: { $ref: '#/components/schemas/Coordinate' }
end: { $ref: '#/components/schemas/Coordinate' }
viaPoints:
type: array
items: { $ref: '#/components/schemas/Coordinate' }
maxItems: 24
startDate: { type: string, format: date }
endDate: { type: string, format: date }
profile: { type: string }
sleepPreference: { type: string }
dailyDistanceKm:
type: object
required: [min, target, max]
properties:
min: { type: number }
target: { type: number }
max: { type: number }
waterGapMaxKm: { type: number }
foodGapMaxKm: { type: number }
Trip:
type: object
properties:
id: { type: string }
title: { type: string }
request: { $ref: '#/components/schemas/TripRequest' }
RoutePlanRequest:
type: object
required: [tripRequest]
properties:
tripRequest: { $ref: '#/components/schemas/TripRequest' }
provider: { type: string, default: mock }
RoutePlan:
type: object
properties:
id: { type: string }
tripId: { type: string }
geometry:
type: array
items: { $ref: '#/components/schemas/Coordinate' }
distanceM: { type: integer }
ascentM: { type: integer }
descentM: { type: integer }
provider: { type: string }
segments:
type: array
items: { $ref: '#/components/schemas/RouteSegment' }
surfaceBreakdown:
type: object
additionalProperties: { type: number }
suitabilityScore: { type: number }
warnings:
type: array
items: { $ref: '#/components/schemas/RouteWarning' }
attribution:
type: array
items: { type: string }
RouteSegment:
type: object
properties:
id: { type: string }
geometry:
type: array
items: { $ref: '#/components/schemas/Coordinate' }
distanceM: { type: integer }
startMeters: { type: integer }
endMeters: { type: integer }
ascentM: { type: integer }
descentM: { type: integer }
avgGradePercent: { type: number }
maxGradePercent: { type: number }
surface: { type: string }
smoothness: { type: string }
highway: { type: string }
bicycleAccess: { type: string }
legalAccessConfidence: { type: string }
trafficStress: { type: number }
officialCycleRoute: { type: boolean }
protectedAreaOverlap: { type: boolean }
sourceConfidence: { type: string }
StagePlanRequest:
type: object
required: [routeId, tripRequest]
properties:
routeId: { type: string }
tripRequest: { $ref: '#/components/schemas/TripRequest' }
StagePlan:
type: object
properties:
id: { type: string }
dayIndex: { type: integer }
startMeters: { type: integer }
endMeters: { type: integer }
distanceM: { type: integer }
ascentM: { type: integer }
descentM: { type: integer }
expectedRideTimeMinutes:
type: object
properties:
low: { type: integer }
high: { type: integer }
sleepCandidates:
type: array
items: { $ref: '#/components/schemas/Poi' }
waterPois:
type: array
items: { $ref: '#/components/schemas/Poi' }
foodPois:
type: array
items: { $ref: '#/components/schemas/Poi' }
repairPois:
type: array
items: { $ref: '#/components/schemas/Poi' }
bailoutPois:
type: array
items: { $ref: '#/components/schemas/Poi' }
warnings:
type: array
items: { $ref: '#/components/schemas/RouteWarning' }
planB:
type: object
properties:
earlierEndMeters: { type: integer }
laterEndMeters: { type: integer }
reason: { type: string }
PoiCategory:
type: string
enum: [water, food, sleep, repair, bailout, charging, emergency]
Poi:
type: object
properties:
id: { type: string }
category: { $ref: '#/components/schemas/PoiCategory' }
name: { type: string }
location: { $ref: '#/components/schemas/Coordinate' }
source: { type: string }
confidence: { type: string }
metersFromStart: { type: integer }
distanceFromRouteM: { type: integer }
detourDistanceM: { type: integer }
RouteWarning:
type: object
properties:
code: { type: string }
severity: { type: string, enum: [info, notice, warning, critical] }
title: { type: string }
message: { type: string }
metersFromStart: { type: integer }
segmentId: { type: string }
ServiceGap:
type: object
properties:
category: { $ref: '#/components/schemas/PoiCategory' }
startM: { type: integer }
endM: { type: integer }
distanceM: { type: integer }
severity: { type: string }
explanation: { type: string }
RuleCard:
type: object
properties:
id: { type: string }
ruleType: { type: string }
jurisdiction: { type: string }
status: { type: string }
confidence: { type: string }
summary: { type: string }
sourceUrl: { type: string }
lastReviewedAt: { type: string, format: date }
OfflinePackManifest:
type: object
properties:
packId: { type: string }
tripId: { type: string }
routeId: { type: string }
createdAt: { type: string, format: date-time }
expiresAt: { type: string, format: date-time }
bbox:
type: array
minItems: 4
maxItems: 4
items: { type: number }
routeBufferKm: { type: number }
includedLayers:
type: array
items: { type: string }
dataVersions: { type: object }
files:
type: array
items: { type: object }
warnings:
type: array
items: { $ref: '#/components/schemas/RouteWarning' }
LocalReportCreate:
type: object
required: [reportType, location, observedAt]
properties:
reportType: { type: string }
location: { $ref: '#/components/schemas/Coordinate' }
routeId: { type: string }
metersFromStart: { type: integer }
observedAt: { type: string, format: date-time }
details: { type: string }
payload: { type: object }
offlineClientId: { type: string }
LocalReport:
allOf:
- $ref: '#/components/schemas/LocalReportCreate'
- type: object
properties:
id: { type: string }
moderationStatus: { type: string }
trustScore: { type: number }
queuedOffline: { type: boolean }