From abed21be216e02ee47da92957d0eaa02587f91ba Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 2 Jul 2026 21:04:05 +0200 Subject: [PATCH] Alpha stage commit --- .env.example | 34 + .gitignore | 32 +- AGENTS.md | 34 + LICENSE_NOT_SELECTED.md | 5 + README.md | 139 +- REPO_INDEX.md | 29 + apps/mobile/README.md | 31 + apps/mobile/package.json | 15 + apps/mobile/src/offlineStore.ts | 80 + apps/mobile/tsconfig.json | 11 + apps/web/.env.example | 1 + apps/web/README.md | 53 + apps/web/index.html | 12 + apps/web/package.json | 25 + apps/web/src/App.tsx | 2155 ++++++++++ apps/web/src/api.ts | 92 + apps/web/src/main.tsx | 6 + apps/web/src/styles.css | 992 +++++ apps/web/tsconfig.json | 9 + apps/web/vite.config.ts | 18 + codex/BACKLOG.csv | 13 + codex/CODEX_MASTER_PROMPT.md | 52 + codex/IMPLEMENTATION_TASKS.md | 157 + design/map_layers.md | 32 + design/wireframes_text.md | 37 + docker-compose.yml | 23 + docs/00_executive_summary.md | 43 + docs/01_product_requirements.md | 133 + docs/02_mvp_scope.md | 71 + docs/03_system_architecture.md | 120 + docs/04_data_sources.md | 124 + docs/05_routing_and_scoring.md | 156 + docs/06_offline_strategy.md | 89 + docs/07_ux_flows.md | 108 + docs/08_data_model.md | 181 + docs/09_api_contract.md | 121 + docs/10_integrations.md | 70 + docs/11_local_rules_and_liability.md | 78 + docs/12_community_intel.md | 80 + docs/13_security_privacy.md | 54 + docs/14_testing_plan.md | 63 + docs/15_roadmap.md | 58 + docs/source_references.md | 91 + examples/overpass_queries.md | 45 + examples/sample_segment_scoring.json | 25 + examples/sample_stage_plan.json | 82 + examples/sample_trip_request.json | 31 + package-lock.json | 3734 +++++++++++++++++ package.json | 25 + packages/shared/README.md | 28 + packages/shared/package.json | 22 + packages/shared/src/fixtures.d.ts | 7 + packages/shared/src/fixtures.d.ts.map | 1 + packages/shared/src/fixtures.js | 175 + packages/shared/src/fixtures.js.map | 1 + packages/shared/src/fixtures.test.ts | 9 + packages/shared/src/fixtures.ts | 194 + packages/shared/src/geo.d.ts | 11 + packages/shared/src/geo.d.ts.map | 1 + packages/shared/src/geo.js | 104 + packages/shared/src/geo.js.map | 1 + packages/shared/src/geo.ts | 125 + packages/shared/src/gpx.d.ts | 9 + packages/shared/src/gpx.d.ts.map | 1 + packages/shared/src/gpx.js | 70 + packages/shared/src/gpx.js.map | 1 + packages/shared/src/gpx.test.ts | 20 + packages/shared/src/gpx.ts | 89 + packages/shared/src/index.d.ts | 10 + packages/shared/src/index.d.ts.map | 1 + packages/shared/src/index.js | 10 + packages/shared/src/index.js.map | 1 + packages/shared/src/index.ts | 9 + packages/shared/src/offline.d.ts | 13 + packages/shared/src/offline.d.ts.map | 1 + packages/shared/src/offline.js | 59 + packages/shared/src/offline.js.map | 1 + packages/shared/src/offline.ts | 73 + packages/shared/src/pois.d.ts | 15 + packages/shared/src/pois.d.ts.map | 1 + packages/shared/src/pois.js | 72 + packages/shared/src/pois.js.map | 1 + packages/shared/src/pois.test.ts | 24 + packages/shared/src/pois.ts | 93 + packages/shared/src/profiles.d.ts | 6 + packages/shared/src/profiles.d.ts.map | 1 + packages/shared/src/profiles.js | 95 + packages/shared/src/profiles.js.map | 1 + packages/shared/src/profiles.ts | 99 + packages/shared/src/scoring.d.ts | 6 + packages/shared/src/scoring.d.ts.map | 1 + packages/shared/src/scoring.js | 158 + packages/shared/src/scoring.js.map | 1 + packages/shared/src/scoring.test.ts | 28 + packages/shared/src/scoring.ts | 186 + packages/shared/src/staging.d.ts | 4 + packages/shared/src/staging.d.ts.map | 1 + packages/shared/src/staging.js | 218 + packages/shared/src/staging.js.map | 1 + packages/shared/src/staging.test.ts | 34 + packages/shared/src/staging.ts | 269 ++ packages/shared/src/types.d.ts | 216 + packages/shared/src/types.d.ts.map | 1 + packages/shared/src/types.js | 2 + packages/shared/src/types.js.map | 1 + packages/shared/src/types.ts | 243 ++ packages/shared/tsconfig.json | 13 + packages/shared/vitest.config.ts | 8 + schemas/bikepacking_profile.schema.json | 77 + schemas/database.sql | 196 + schemas/domain_types.ts | 150 + schemas/openapi.yaml | 423 ++ services/api/README.md | 76 + services/api/package.json | 27 + services/api/src/app.test.ts | 54 + services/api/src/app.ts | 326 ++ services/api/src/config.ts | 33 + services/api/src/errors.ts | 23 + services/api/src/providers/brouter.ts | 571 +++ services/api/src/providers/index.ts | 42 + services/api/src/providers/mock.ts | 81 + services/api/src/providers/osrm.ts | 198 + services/api/src/providers/overpass.ts | 204 + .../api/src/providers/route-poi-projection.ts | 52 + services/api/src/providers/types.ts | 68 + services/api/src/providers/unavailable.ts | 63 + services/api/src/server.ts | 9 + services/api/src/validation.ts | 89 + services/api/tsconfig.json | 12 + services/api/vitest.config.ts | 17 + services/data-pipeline/README.md | 32 + .../fixtures/osm-pois.sample.json | 47 + services/data-pipeline/package.json | 16 + .../data-pipeline/src/normalize-osm-pois.ts | 116 + services/data-pipeline/tsconfig.json | 11 + tsconfig.base.json | 18 + 136 files changed, 15531 insertions(+), 15 deletions(-) create mode 100644 .env.example create mode 100644 AGENTS.md create mode 100644 LICENSE_NOT_SELECTED.md create mode 100644 REPO_INDEX.md create mode 100644 apps/mobile/README.md create mode 100644 apps/mobile/package.json create mode 100644 apps/mobile/src/offlineStore.ts create mode 100644 apps/mobile/tsconfig.json create mode 100644 apps/web/.env.example create mode 100644 apps/web/README.md create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/api.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/styles.css create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 codex/BACKLOG.csv create mode 100644 codex/CODEX_MASTER_PROMPT.md create mode 100644 codex/IMPLEMENTATION_TASKS.md create mode 100644 design/map_layers.md create mode 100644 design/wireframes_text.md create mode 100644 docker-compose.yml create mode 100644 docs/00_executive_summary.md create mode 100644 docs/01_product_requirements.md create mode 100644 docs/02_mvp_scope.md create mode 100644 docs/03_system_architecture.md create mode 100644 docs/04_data_sources.md create mode 100644 docs/05_routing_and_scoring.md create mode 100644 docs/06_offline_strategy.md create mode 100644 docs/07_ux_flows.md create mode 100644 docs/08_data_model.md create mode 100644 docs/09_api_contract.md create mode 100644 docs/10_integrations.md create mode 100644 docs/11_local_rules_and_liability.md create mode 100644 docs/12_community_intel.md create mode 100644 docs/13_security_privacy.md create mode 100644 docs/14_testing_plan.md create mode 100644 docs/15_roadmap.md create mode 100644 docs/source_references.md create mode 100644 examples/overpass_queries.md create mode 100644 examples/sample_segment_scoring.json create mode 100644 examples/sample_stage_plan.json create mode 100644 examples/sample_trip_request.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 packages/shared/README.md create mode 100644 packages/shared/package.json create mode 100644 packages/shared/src/fixtures.d.ts create mode 100644 packages/shared/src/fixtures.d.ts.map create mode 100644 packages/shared/src/fixtures.js create mode 100644 packages/shared/src/fixtures.js.map create mode 100644 packages/shared/src/fixtures.test.ts create mode 100644 packages/shared/src/fixtures.ts create mode 100644 packages/shared/src/geo.d.ts create mode 100644 packages/shared/src/geo.d.ts.map create mode 100644 packages/shared/src/geo.js create mode 100644 packages/shared/src/geo.js.map create mode 100644 packages/shared/src/geo.ts create mode 100644 packages/shared/src/gpx.d.ts create mode 100644 packages/shared/src/gpx.d.ts.map create mode 100644 packages/shared/src/gpx.js create mode 100644 packages/shared/src/gpx.js.map create mode 100644 packages/shared/src/gpx.test.ts create mode 100644 packages/shared/src/gpx.ts create mode 100644 packages/shared/src/index.d.ts create mode 100644 packages/shared/src/index.d.ts.map create mode 100644 packages/shared/src/index.js create mode 100644 packages/shared/src/index.js.map create mode 100644 packages/shared/src/index.ts create mode 100644 packages/shared/src/offline.d.ts create mode 100644 packages/shared/src/offline.d.ts.map create mode 100644 packages/shared/src/offline.js create mode 100644 packages/shared/src/offline.js.map create mode 100644 packages/shared/src/offline.ts create mode 100644 packages/shared/src/pois.d.ts create mode 100644 packages/shared/src/pois.d.ts.map create mode 100644 packages/shared/src/pois.js create mode 100644 packages/shared/src/pois.js.map create mode 100644 packages/shared/src/pois.test.ts create mode 100644 packages/shared/src/pois.ts create mode 100644 packages/shared/src/profiles.d.ts create mode 100644 packages/shared/src/profiles.d.ts.map create mode 100644 packages/shared/src/profiles.js create mode 100644 packages/shared/src/profiles.js.map create mode 100644 packages/shared/src/profiles.ts create mode 100644 packages/shared/src/scoring.d.ts create mode 100644 packages/shared/src/scoring.d.ts.map create mode 100644 packages/shared/src/scoring.js create mode 100644 packages/shared/src/scoring.js.map create mode 100644 packages/shared/src/scoring.test.ts create mode 100644 packages/shared/src/scoring.ts create mode 100644 packages/shared/src/staging.d.ts create mode 100644 packages/shared/src/staging.d.ts.map create mode 100644 packages/shared/src/staging.js create mode 100644 packages/shared/src/staging.js.map create mode 100644 packages/shared/src/staging.test.ts create mode 100644 packages/shared/src/staging.ts create mode 100644 packages/shared/src/types.d.ts create mode 100644 packages/shared/src/types.d.ts.map create mode 100644 packages/shared/src/types.js create mode 100644 packages/shared/src/types.js.map create mode 100644 packages/shared/src/types.ts create mode 100644 packages/shared/tsconfig.json create mode 100644 packages/shared/vitest.config.ts create mode 100644 schemas/bikepacking_profile.schema.json create mode 100644 schemas/database.sql create mode 100644 schemas/domain_types.ts create mode 100644 schemas/openapi.yaml create mode 100644 services/api/README.md create mode 100644 services/api/package.json create mode 100644 services/api/src/app.test.ts create mode 100644 services/api/src/app.ts create mode 100644 services/api/src/config.ts create mode 100644 services/api/src/errors.ts create mode 100644 services/api/src/providers/brouter.ts create mode 100644 services/api/src/providers/index.ts create mode 100644 services/api/src/providers/mock.ts create mode 100644 services/api/src/providers/osrm.ts create mode 100644 services/api/src/providers/overpass.ts create mode 100644 services/api/src/providers/route-poi-projection.ts create mode 100644 services/api/src/providers/types.ts create mode 100644 services/api/src/providers/unavailable.ts create mode 100644 services/api/src/server.ts create mode 100644 services/api/src/validation.ts create mode 100644 services/api/tsconfig.json create mode 100644 services/api/vitest.config.ts create mode 100644 services/data-pipeline/README.md create mode 100644 services/data-pipeline/fixtures/osm-pois.sample.json create mode 100644 services/data-pipeline/package.json create mode 100644 services/data-pipeline/src/normalize-osm-pois.ts create mode 100644 services/data-pipeline/tsconfig.json create mode 100644 tsconfig.base.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..73a557f --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 8c2b884..5ae8bf6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..88d6556 --- /dev/null +++ b/AGENTS.md @@ -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. diff --git a/LICENSE_NOT_SELECTED.md b/LICENSE_NOT_SELECTED.md new file mode 100644 index 0000000..c188249 --- /dev/null +++ b/LICENSE_NOT_SELECTED.md @@ -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. diff --git a/README.md b/README.md index 4f799b1..f14a0b1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/REPO_INDEX.md b/REPO_INDEX.md new file mode 100644 index 0000000..2890877 --- /dev/null +++ b/REPO_INDEX.md @@ -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. diff --git a/apps/mobile/README.md b/apps/mobile/README.md new file mode 100644 index 0000000..f923aca --- /dev/null +++ b/apps/mobile/README.md @@ -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. diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 0000000..348efa8 --- /dev/null +++ b/apps/mobile/package.json @@ -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" + } +} diff --git a/apps/mobile/src/offlineStore.ts b/apps/mobile/src/offlineStore.ts new file mode 100644 index 0000000..35ada79 --- /dev/null +++ b/apps/mobile/src/offlineStore.ts @@ -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; + setItem(key: string, value: string): Promise; +} + +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 { + 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 { + const raw = await this.store.getItem(REPORT_QUEUE_KEY); + return raw ? (JSON.parse(raw) as OfflineReportQueueItem[]) : []; + } + + async recordSyncFailure(offlineClientId: string, error: string): Promise { + 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 { + 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 { + 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 { + const raw = await this.store.getItem(PACK_INDEX_KEY); + return raw ? (JSON.parse(raw) as OfflinePackRecord[]) : []; + } + + async get(packId: string): Promise { + return (await this.list()).find((record) => record.manifest.packId === packId); + } +} diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json new file mode 100644 index 0000000..0a7cc09 --- /dev/null +++ b/apps/mobile/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"] +} diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..c2058b5 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1 @@ +VITE_API_BASE_URL=http://localhost:8000 diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..8b27647 --- /dev/null +++ b/apps/web/README.md @@ -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. diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..5ac545e --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + Pikebacker Planner + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..ebfd4b5 --- /dev/null +++ b/apps/web/package.json @@ -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" + } +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..633e43c --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,2155 @@ +import { + AlertTriangle, + Bike, + ChevronLeft, + ChevronRight, + CircleX, + Download, + Droplets, + Flag, + GripVertical, + HardDriveDownload, + Layers, + MapPin, + Mountain, + Moon, + PanelBottomClose, + PanelBottomOpen, + RefreshCw, + Send, + Store, + Tent, + Train, + Wrench +} from 'lucide-react'; +import maplibregl, { + type GeoJSONSource, + type LngLatBoundsLike, + type Map as MapLibreMap, + type MapLayerMouseEvent, + type MapMouseEvent, + type StyleSpecification +} from 'maplibre-gl'; +import { useEffect, useMemo, useRef, useState, type FormEvent, type PointerEvent, type ReactNode } from 'react'; +import { + coordinateAtDistance, + haversineDistanceM, + mockTripRequest, + type Coordinate, + type LocalReport, + type LocalReportCreate, + type OfflinePackManifest, + type Poi, + type PoiCategory, + type RoutePlan, + type RuleCard, + type ServiceGap, + type StagePlan, + type Trip, + type TripRequest, + type WarningSeverity +} from '@pikebacker/shared'; +import { createOfflinePack, createPlannerBundle, downloadGpx, submitReport } from './api.js'; + +type PlannerState = { + trip?: Trip; + route?: RoutePlan; + stages: StagePlan[]; + pois: Poi[]; + gaps: ServiceGap[]; + rules: RuleCard[]; +}; + +type RoutingProviderChoice = 'brouter' | 'osrm' | 'mock'; + +type LayerState = { + osm: boolean; + route: boolean; + stages: boolean; + pois: boolean; +}; + +type BottomTab = 'stages' | 'warnings' | 'pois' | 'profile'; + +type MapContextSelection = { + x: number; + y: number; + coordinate: Coordinate; +}; + +const POI_CATEGORIES: PoiCategory[] = ['water', 'food', 'sleep', 'repair', 'bailout']; +const OFFLINE_REPORTS_KEY = 'pikebacker.offlineReports'; +const REPORT_TYPES = [ + { value: 'water_available', label: 'Water available' }, + { value: 'water_unavailable', label: 'Water unavailable' }, + { value: 'surface_wrong', label: 'Surface mismatch' }, + { value: 'rough_loaded_bike', label: 'Rough loaded-bike section' }, + { value: 'hike_a_bike', label: 'Hike-a-bike' }, + { value: 'locked_gate', label: 'Locked gate' }, + { value: 'private_access', label: 'Private access' }, + { value: 'closure', label: 'Closure' }, + { value: 'dangerous_dogs', label: 'Dangerous dogs' }, + { value: 'mud_sand_washout', label: 'Mud/sand/washout' }, + { value: 'bridge_out', label: 'Bridge out' }, + { value: 'ferry_issue', label: 'Ferry issue' }, + { value: 'sleep_option_good', label: 'Good sleep option' }, + { value: 'sleep_option_bad', label: 'Bad sleep option' }, + { value: 'shop_closed_or_missing', label: 'Shop closed/missing' }, + { value: 'repair_available', label: 'Repair available' }, + { value: 'reroute_recommended', label: 'Reroute recommended' } +] as const; + +type ReportType = (typeof REPORT_TYPES)[number]['value']; +type ReportConfidence = 'low' | 'medium' | 'high'; + +function App() { + const [tripRequest, setTripRequest] = useState(mockTripRequest); + const [routingProvider, setRoutingProvider] = useState('brouter'); + const [planner, setPlanner] = useState({ stages: [], pois: [], gaps: [], rules: [] }); + const [selectedCategories, setSelectedCategories] = useState>(new Set(POI_CATEGORIES)); + const [layers, setLayers] = useState({ osm: true, route: true, stages: true, pois: true }); + const [leftCollapsed, setLeftCollapsed] = useState(false); + const [bottomCollapsed, setBottomCollapsed] = useState(false); + const [bottomTab, setBottomTab] = useState('stages'); + const [status, setStatus] = useState('Bikepacking routing and OSM tiles ready'); + const [isLoading, setIsLoading] = useState(false); + const [offlinePack, setOfflinePack] = useState(); + const [reportType, setReportType] = useState('water_available'); + const [reportConfidence, setReportConfidence] = useState('medium'); + const [reportObservedAt, setReportObservedAt] = useState(() => toDatetimeLocalValue(new Date())); + const [reportMetersKm, setReportMetersKm] = useState('18.2'); + const [reportDetails, setReportDetails] = useState('Water tap still running'); + const [queuedReports, setQueuedReports] = useState(() => readQueuedReports()); + const [lastReport, setLastReport] = useState(); + const [mapContext, setMapContext] = useState(); + const [hoveredRouteMeter, setHoveredRouteMeter] = useState(); + + const routingPoints = useMemo(() => getRoutingPoints(tripRequest), [tripRequest]); + const filteredPois = useMemo( + () => planner.pois.filter((poi) => selectedCategories.has(poi.category)), + [planner.pois, selectedCategories] + ); + const warnings = useMemo(() => [...(planner.route?.warnings ?? []), ...planner.stages.flatMap((stage) => stage.warnings)], [planner]); + const reportRouteMaxKm = planner.route ? Math.round((planner.route.distanceM / 1000) * 10) / 10 : 0; + const reportKm = clampReportKm(reportMetersKm, reportRouteMaxKm); + const reportPreviewLocation = planner.route ? coordinateAtDistance(planner.route, Math.round(reportKm * 1000)) : tripRequest.start; + + async function handlePlanSubmit(event: FormEvent) { + event.preventDefault(); + await planWithRequest(tripRequest, statusForProvider(routingProvider)); + } + + async function planWithRequest(nextRequest: TripRequest, planningStatus: string) { + setIsLoading(true); + setOfflinePack(undefined); + setStatus(planningStatus); + try { + const bundle = await createPlannerBundle(nextRequest, routingProvider); + setPlanner(bundle); + setBottomCollapsed(false); + setStatus( + `Loaded ${formatKm(bundle.route.distanceM)} ${bundle.route.provider} route with ${bundle.stages.length} stages and ${bundle.pois.length} corridor POIs.` + ); + } catch (error) { + setStatus(error instanceof Error ? error.message : 'Planning failed'); + } finally { + setIsLoading(false); + } + } + + async function commitRoutingPoints(nextPoints: Coordinate[], options: { autoPlan?: boolean; status?: string } = {}) { + const nextRequest = tripRequestWithRoutingPoints(tripRequest, nextPoints); + setTripRequest(nextRequest); + setMapContext(undefined); + if (options.autoPlan && planner.route) { + await planWithRequest(nextRequest, options.status ?? 'Routing point changed. Replanning route...'); + } else { + setStatus('Routing points updated. Plan route to refresh the corridor.'); + } + } + + function updateRoutingPoint(index: number, coordinate: Coordinate) { + const nextPoints = routingPoints.map((point, pointIndex) => (pointIndex === index ? coordinate : point)); + void commitRoutingPoints(nextPoints); + } + + async function moveRoutingPointFromMap(index: number, coordinate: Coordinate) { + const nextPoints = routingPoints.map((point, pointIndex) => (pointIndex === index ? coordinate : point)); + await commitRoutingPoints(nextPoints, { autoPlan: true, status: `Moved point ${pointLabel(index)}. Replanning route...` }); + } + + function reorderRoutingPoint(fromIndex: number, toIndex: number) { + if (fromIndex === toIndex || toIndex < 0 || toIndex >= routingPoints.length) { + return; + } + const nextPoints = [...routingPoints]; + const [point] = nextPoints.splice(fromIndex, 1); + if (!point) { + return; + } + nextPoints.splice(toIndex, 0, point); + void commitRoutingPoints(nextPoints); + } + + function deleteRoutingPoint(index: number) { + if (routingPoints.length <= 2) { + return; + } + const nextPoints = routingPoints.filter((_point, pointIndex) => pointIndex !== index); + void commitRoutingPoints(nextPoints); + } + + async function insertRoutingPoint(coordinate: Coordinate, insertIndex = routingPoints.length - 1, autoPlan = false) { + const nextPoints = [...routingPoints]; + nextPoints.splice(Math.max(1, Math.min(insertIndex, nextPoints.length - 1)), 0, coordinate); + await commitRoutingPoints(nextPoints, { autoPlan, status: 'Added via point. Replanning route...' }); + } + + function handleSetFromHere(coordinate: Coordinate) { + void commitRoutingPoints([coordinate, ...routingPoints.slice(1)], { autoPlan: Boolean(planner.route), status: 'Start changed. Replanning route...' }); + } + + function handleSetToHere(coordinate: Coordinate) { + void commitRoutingPoints([...routingPoints.slice(0, -1), coordinate], { autoPlan: Boolean(planner.route), status: 'End changed. Replanning route...' }); + } + + function handleInsertViaHere(coordinate: Coordinate) { + void insertRoutingPoint(coordinate, nearestRoutingInsertIndex(routingPoints, coordinate), Boolean(planner.route)); + } + + async function handleDownloadGpx() { + if (!planner.route) { + return; + } + setStatus('Preparing GPX export...'); + try { + const blob = await downloadGpx(planner.route.id); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${planner.route.id}.gpx`; + link.click(); + URL.revokeObjectURL(url); + setStatus('GPX export downloaded.'); + } catch (error) { + setStatus(error instanceof Error ? error.message : 'GPX export failed'); + } + } + + async function handleOfflinePack() { + if (!planner.trip || !planner.route) { + return; + } + setStatus('Creating offline-pack manifest...'); + try { + const manifest = await createOfflinePack(planner.trip.id, planner.route.id); + setOfflinePack(manifest); + setStatus(`Offline manifest created with ${manifest.includedLayers.length} layers.`); + } catch (error) { + setStatus(error instanceof Error ? error.message : 'Offline manifest failed'); + } + } + + async function handleReportSubmit(event: FormEvent) { + event.preventDefault(); + const report = buildReport({ + route: planner.route, + fallbackLocation: tripRequest.start, + reportType, + reportConfidence, + reportObservedAt, + reportMetersKm, + details: reportDetails + }); + try { + const created = await submitReport(report); + setLastReport(created); + setStatus(`${labelForReportType(created.reportType)} report submitted for moderation.`); + } catch { + queueReport(report); + setStatus('Network unavailable. Report queued offline.'); + } + } + + function handleQueueReport() { + const report = buildReport({ + route: planner.route, + fallbackLocation: tripRequest.start, + reportType, + reportConfidence, + reportObservedAt, + reportMetersKm, + details: reportDetails + }); + queueReport(report); + setStatus(`${labelForReportType(report.reportType)} report saved to offline queue.`); + } + + async function handleSyncQueuedReports() { + const queued = readQueuedReports(); + if (queued.length === 0) { + setStatus('No offline reports to sync.'); + return; + } + + const remaining: LocalReportCreate[] = []; + let synced = 0; + for (const report of queued) { + try { + const created = await submitReport(report.offlineClientId ? report : { ...report, offlineClientId: crypto.randomUUID() }); + setLastReport(created); + synced += 1; + } catch { + remaining.push(report); + } + } + saveQueuedReports(remaining); + setQueuedReports(remaining); + setStatus(remaining.length === 0 ? `Synced ${synced} offline reports.` : `Synced ${synced}; ${remaining.length} still queued.`); + } + + function queueReport(report: LocalReportCreate) { + const queued = [...readQueuedReports(), { ...report, offlineClientId: report.offlineClientId ?? crypto.randomUUID() }]; + saveQueuedReports(queued); + setQueuedReports(queued); + } + + function toggleCategory(category: PoiCategory) { + const next = new Set(selectedCategories); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + setSelectedCategories(next); + } + + return ( +
+ { + void insertRoutingPoint(coordinate, nearestRoutingInsertIndex(routingPoints, coordinate), true); + }} + onRouteHoverChange={setHoveredRouteMeter} + onRoutingPointMove={(index, coordinate) => { + void moveRoutingPointFromMap(index, coordinate); + }} + pois={filteredPois} + route={planner.route} + routingPoints={routingPoints} + stages={planner.stages} + /> + + {mapContext ? ( +
+ + + +
+ ) : null} + +
+
Pikebacker MVP
+ Bikepacking planner +
+ +
+ {status} +
+ +