mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e225548c79 | |||
| e5bda89faa | |||
| 815020dd58 | |||
| ebfa266ee0 | |||
| f450208db4 | |||
| d1d7857363 | |||
| e7a5acbd68 | |||
| e02cc7ecf7 | |||
| d3219af062 | |||
| ae2d522890 | |||
| b513dde39e | |||
| dee2603d42 | |||
| 633a2b38a4 | |||
| 2a66665935 | |||
| d2bc4cc297 | |||
| 4c65b17a72 | |||
| 2e1db584bd | |||
| 003c5826ab | |||
| dff79089ab | |||
| f9dabccf19 | |||
| f84564e2e9 | |||
| 105cce5deb | |||
| be5004e282 | |||
| b37d66a4c1 | |||
| c620b44a7b | |||
| c58818e8f8 | |||
| 5e1b588791 | |||
| b4ea23203d | |||
| b9d0f6df4b | |||
| aa482be840 | |||
| afa0dd0e33 | |||
| 4be4c4ea11 | |||
| 5db38e82f2 | |||
| 8cbea7ad47 | |||
| 49c598a384 | |||
| 1dd1ad838d | |||
| 7f5352efe5 | |||
| 96f5d9b587 | |||
| 049cc50fda | |||
| cd6f3b9d2c | |||
| 784237a02b | |||
| f9333d51a6 | |||
| 802ac51904 | |||
| f1120be3c1 | |||
| d3aad4c41e | |||
| 91801b10c0 | |||
| b2d9b25bb6 | |||
| 44d52ab215 | |||
| 239856f35c | |||
| 65be8db122 | |||
| 41e256bf0f | |||
| 1e1dd9b814 | |||
| c1f7587704 | |||
| 676c82a308 | |||
| 6fca238440 | |||
| 761ec4e66f | |||
| 620f4c6ecd | |||
| 36ac5de84c | |||
| 061259b3d9 | |||
| fc74991a15 | |||
| 49f97af3fb | |||
| 4215db2908 | |||
| 95c173a8a8 | |||
| 282e3c6b13 | |||
| ea9de65550 | |||
| e79a2591a1 | |||
| dfb038d90f | |||
| 56266138c6 | |||
| 11f4c740e1 | |||
| d6eac772aa | |||
| 67163e7224 | |||
| ba7bd1dd91 | |||
| c82a8f899f | |||
| 23bda0e1e1 | |||
| d1a13058a6 | |||
| 25249749ad | |||
| 9a0a6f8c3b | |||
| 7829043a00 | |||
| 7b5c8c1fae | |||
| eebe9a52e1 | |||
| f1fa155899 | |||
| 4f57c1ee13 | |||
| 3d252ac994 | |||
| e11ae6f056 | |||
| 1db55f734c | |||
| b339ef3f04 | |||
| f1eadd670d | |||
| d6955f20a6 | |||
| 6aa7884874 | |||
| 37179cb608 | |||
| 74258bd5d9 | |||
| 552bd61d11 | |||
| 647e972c8b | |||
| 07a4447bbd | |||
| 3902e77991 | |||
| 7cdd61e7fb | |||
| ed5769fc89 | |||
| b0aa7f8d95 | |||
| 48f0f29273 | |||
| 5aa36aaf3d | |||
| 273bac8917 | |||
| 2b7af6267b | |||
| d613c91e16 | |||
| 0991fa538b | |||
| 65eebec53f | |||
| 15c1a5ae06 | |||
| 4c52d16b7e | |||
| c0eb1c6e38 | |||
| fb363f22c8 | |||
| 3caafe0548 | |||
| bac85cb98a | |||
| 87a765b763 | |||
| 715993feaf | |||
| b3d69aa760 | |||
| 0a2fa32fc1 | |||
| 7f2a63e7f7 | |||
| 2a0ceb2687 | |||
| 8e2840b29d | |||
| 0454a383b3 | |||
| 18684f20f3 | |||
| 8913782694 | |||
| 52315234fd | |||
| 2572132117 | |||
| 755e9f1ca4 | |||
| b132adf3e3 | |||
| 94914295d1 | |||
| 85f5247a00 | |||
| 8a48131618 | |||
| 0a35ce83a5 | |||
| eaace6ada4 | |||
| e6b4df5213 | |||
| 2d315df8bf | |||
| 04b31ec2e5 | |||
| 8eda1475c3 | |||
| ea0589b21e | |||
| 523443f1d7 | |||
| 8c538db74d | |||
| 11a0780a5b | |||
| f75e9e9af8 | |||
| 3e532aab99 | |||
| 6953ede0e3 | |||
| f81aaa9f4b | |||
| 981cf0a143 | |||
| dc7d1f5e10 | |||
| 6f057ab9f4 | |||
| ce58de29a5 | |||
| c5208967ac | |||
| 53c246e8e5 | |||
| dba4434ef6 | |||
| 55f14c6a70 | |||
| 6d54ac629e | |||
| bafcd456c5 | |||
| 9ed88adf34 | |||
| bc10fba389 |
@@ -1,18 +1,17 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Guidance for Claude Code in this repository. This file holds the commands, the invariants, the gates and the philosophy. Inventories (file lists, counts, history) deliberately live in the code and its READMEs, not here — when this file disagrees with the code, the code wins; fix the doc.
|
||||
|
||||
## What TREK is
|
||||
|
||||
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo with three workspaces:
|
||||
A self-hosted, real-time collaborative travel planner. npm-workspaces monorepo:
|
||||
|
||||
- **`shared/`** (`@trek/shared`) — Zod schemas that are the **single source of truth** for API contracts, consumed by both server and client. Also owns all i18n locale files. Must be built before server/client typecheck or run.
|
||||
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server (decorator-driven registration layer in `server/src/nest-mcp/`, formerly the `@trek/nest-mcp` workspace), sandboxed plugin runtime.
|
||||
- **`shared/`** (`@trek/shared`) — Zod schemas, the **single source of truth** for API contracts, plus all i18n locales. Must be built before server/client typecheck or run.
|
||||
- **`server/`** (`@trek/server`) — NestJS API (Express adapter), SQLite via `better-sqlite3`, WebSocket sync, built-in MCP server, sandboxed plugin runtime.
|
||||
- **`client/`** (`@trek/client`) — React 19 + Vite + Zustand + Tailwind PWA, offline-first via Dexie/IndexedDB.
|
||||
- **`plugin-sdk/`** (`trek-plugin-sdk`) — **not a root workspace**: own lockfile, published to npm independently, must ship standalone. Run its commands from `plugin-sdk/`.
|
||||
|
||||
**`plugin-sdk/`** (`trek-plugin-sdk`) also lives in the repo but is **not a root workspace** — it has its own lockfile, is published to npm independently, and must ship standalone. Run its commands from `plugin-sdk/`, not the root.
|
||||
|
||||
Each package has its own `CLAUDE.md` with internals; this file covers the monorepo picture.
|
||||
Each package has its own `CLAUDE.md`; this file covers the monorepo picture.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -21,7 +20,7 @@ Run from the repo root unless noted. **Use `npm run ... --workspace=<pkg>`, not
|
||||
```bash
|
||||
npm run dev # build shared, then watch shared + server + client concurrently
|
||||
npm run build # build shared → server → client (order matters)
|
||||
npm run lint # lint all three workspaces
|
||||
npm run lint # lint all three workspaces (server and shared run eslint --fix and rewrite files; client is check-only)
|
||||
npm run format # prettier --write all three
|
||||
```
|
||||
|
||||
@@ -29,17 +28,14 @@ Tests:
|
||||
|
||||
```bash
|
||||
npm run test # all workspaces
|
||||
npm run test --workspace=server # vitest run (server)
|
||||
npm run test:unit --workspace=server # tests/unit only
|
||||
npm run test:integration --workspace=server
|
||||
npm run test:ws --workspace=server # websocket tests
|
||||
npm run test:e2e # server e2e (boots Nest against temp SQLite)
|
||||
npm run test:cov # coverage (lcov), all four packages incl. shared + plugin-sdk
|
||||
npm run test --workspace=server # vitest run (server); also test:unit / test:integration / test:ws
|
||||
npm run test:e2e # server e2e (boots Nest against a temp SQLite)
|
||||
npm run test:cov # coverage (lcov) for all four packages incl. plugin-sdk
|
||||
cd client && npm run test # client vitest run
|
||||
cd client && npm run e2e # Playwright
|
||||
cd client && npm run lint:pages # enforce the Page pattern (see below)
|
||||
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (--strict in CI mindset)
|
||||
npm run typecheck --workspace=server # tsc --noEmit (also client/shared)
|
||||
cd client && npm run e2e # Playwright (local only)
|
||||
cd client && npm run lint:pages # enforce the Page pattern (CI gate)
|
||||
cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only)
|
||||
npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests)
|
||||
```
|
||||
|
||||
Run a single test file or test:
|
||||
@@ -49,65 +45,84 @@ npx vitest run tests/unit/nest/weather.controller.test.ts # (from server/ or c
|
||||
npx vitest run -t "name of the test" # by test-name pattern
|
||||
```
|
||||
|
||||
i18n parity (enforced in CI — every non-`en` locale must have identical file set + top-level keys):
|
||||
i18n parity (CI gate — every non-`en` locale must have the identical file set and top-level keys):
|
||||
|
||||
```bash
|
||||
npm run i18n:parity --workspace=shared # audit (exit 0)
|
||||
npm run i18n:parity:strict --workspace=shared # CI gate (exit 1 on drift)
|
||||
```
|
||||
|
||||
Dev ports: server listens on **3001**, Vite dev server proxies `/api`, `/ws`, `/uploads`, `/oauth`, etc. to `http://localhost:3001` (see `client/vite.config.js`).
|
||||
Dev ports: the server listens on **3001**; the Vite dev server proxies the API, websocket, uploads, MCP and OAuth paths to it (see `client/vite.config.js`). There is no Node version pin — the CI workflows and the Dockerfile define what is tested. `server/.env.example` is the env-var reference.
|
||||
|
||||
## Server architecture
|
||||
## Server invariants
|
||||
|
||||
Nest owns everything: the strangler migration **completed 2026-08-10**, and the last seam — the pre-`app.init()` MCP/OAuth mount — moved behind the container 2026-08-11: **zero `*.bridge.ts` files remain**. Legacy `src/services/` is deleted (ESLint errors on any import of it) and all business logic lives in DI modules under `server/src/nest/<domain>/`. `server/src/nest/README.md` is the module blueprint. Every cron is Nest-owned (`server/src/nest/scheduling/`, `@nestjs/schedule`).
|
||||
Nest owns everything: every domain is a DI module under `server/src/nest/<domain>/` (`controller` + `service` + `module`, registered in `app.module.ts`), and ESLint refuses imports of the deleted legacy `services/` layer. `server/src/nest/README.md` is the module blueprint; **`weather/` is the reference implementation** — copy its shape. The domain's Zod contract lives in `shared/src/<domain>/`.
|
||||
|
||||
- **`bootstrap.ts` — `buildApp()`** is the single shared builder used by both production (`index.ts`) and the integration/e2e test harness. **Composition order is load-bearing**: global middleware → platform uploads → the discovery metadata middleware → static assets → the named body-parser wrappers are all registered on the raw Express instance **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and won't fall through to anything registered after init. The transport surface (`/api/health`, `/mcp`, `/.well-known/*` documents, `/oauth/*`) is Nest controllers/middleware. The SPA `index.html` fallback and the global error envelope are `APP_FILTER`s (`SpaFallbackFilter`, `TrekExceptionFilter`).
|
||||
- **`/mcp` bodies are raw by design** — bootstrap's parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
|
||||
- **Per-domain module shape** (one folder under `server/src/nest/<domain>/`): `<domain>.controller.ts` + `<domain>.service.ts` + `<domain>.module.ts`, registered in `app.module.ts`. **`weather/` is the reference implementation** — copy its shape. The Zod contract for the domain lives in `shared/src/<domain>/<domain>.schema.ts`.
|
||||
- **Auth is default-deny**: `GlobalAuthGuard` + `MfaPolicyGuard` run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses unknown public routes. New domain logic goes in the module's `.service.ts` — never resurrect a `services/*`-style plain module.
|
||||
- **Trip-scoped routes** must verify trip access (404 on no access) and the relevant permission (403) — the standard shape is `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler (a few domains still check inline; see `roadmap.md`) — and forward `X-Socket-Id` to the WebSocket broadcast so the originating client doesn't echo its own change. Never gate a multipart upload with a guard (ECONNRESET instead of 403 — check in the handler).
|
||||
- **DB** (`server/src/db/`): `better-sqlite3`, WAL mode, FK on. `database.ts` initializes; `schema.ts` creates tables; `migrations.ts` + `seeds.ts` run on boot. In `NODE_ENV=test` each vitest worker gets an isolated `:memory:` DB; the Playwright harness uses `TREK_DB_FILE` for a throwaway file DB.
|
||||
- **WebSocket** (`server/src/websocket.ts`): JWT-authed, room-per-trip (`tripId → Set<WebSocket>`), heartbeat keep-alive, per-connection rate limiting, served on `/ws`.
|
||||
- **MCP** (`server/src/mcp/`): OAuth 2.1-authenticated MCP server exposing TREK to AI assistants (150+ tools in `tools/`, resources, scopes, session manager). See `MCP.md`.
|
||||
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts` (mcp, packing, budget, documents, vacay, atlas, collab, journey, airtrail, llm_parsing, collections).
|
||||
- **Plugins** (`server/src/nest/plugins/`): sandboxed third-party plugin runtime — one forked child process per plugin (`supervisor/`), permission-gated RPC (`host/rpc-host.ts` registers a handler only if the plugin holds the unlocking permission), install-time manifest/signature/egress checks (`install/`, `runtime/egress-policy.ts`). Author tooling lives in `plugin-sdk/`; the `trek-plugin-dev` skill covers plugin authoring.
|
||||
- **`bootstrap.ts` — `buildApp()`** is the single builder for production and the test harness. **Composition order is load-bearing**: everything registered on the raw Express instance must come **before `app.init()`**, because Nest's router throws `NotFoundException` on unmatched routes and nothing registered after init is reachable. Don't add routes to that pre-init shell — it bypasses every global guard, interceptor and filter.
|
||||
- **`/mcp` bodies are raw by design** — the parser wrappers exempt `/mcp` so the MCP SDK reads the untouched stream; never `@Body()` a `/mcp` route.
|
||||
- **Auth is default-deny**: global guards run as `APP_GUARD`s; public routes opt out with `@Public()`, and a boot-time ratchet (`validate-route-guards.ts`) refuses any public route not on its allow-list.
|
||||
- **Trip-scoped routes** verify trip access (404) and the permission (403) — `@UseGuards(JwtAuthGuard, TripAccessGuard)` + `@RequirePermission('<action>')` per handler — and forward `X-Socket-Id` to the broadcast so the originating client doesn't echo its own change. **Never gate a multipart upload with a guard** (the client sees ECONNRESET instead of 403 — check in the handler).
|
||||
- **DB** (`server/src/db/`): `better-sqlite3`, WAL, FK on. Migrations are positional and append-only. `NODE_ENV=test` gives each vitest worker an isolated `:memory:` DB.
|
||||
- **Realtime**: `/ws` is a Nest gateway in `server/src/nest/realtime/` — inject `RealtimeService`; `server/src/websocket.ts` is a legacy re-export stub.
|
||||
- **MCP**: tools are declared in `<domain>.mcp.ts` files beside each module, on the `server/src/nest-mcp/` decorator layer. A validation or permission change on a REST route must land in the parallel MCP tool in the same change. See `MCP.md`.
|
||||
- **Addons** are admin-toggleable feature modules keyed by `ADDON_IDS` in `server/src/addons.ts`.
|
||||
- **Plugins** (`server/src/nest/plugins/`): one forked child process per plugin, permission-gated RPC (a handler is registered only if the plugin holds the unlocking permission), install-time manifest/signature/egress/version-range gates. The protocol tables are **generated** into `plugin-sdk/` and `shared/` by `gen:plugin-facts`; `check:plugin-facts` is the CI gate. Author tooling is `plugin-sdk/`; the `trek-plugin-dev` skill covers authoring.
|
||||
- **Storage**: uploads go through the driver abstraction in `server/src/nest/storage/` (local / S3 / mirror). Never touch the filesystem or the S3 SDK from a domain service.
|
||||
- **Crons** go through `server/src/nest/scheduling/` only — never a `@Cron` decorator (it bypasses the test-time gate).
|
||||
|
||||
## Client architecture
|
||||
## Client invariants
|
||||
|
||||
Offline-first, with a layered data flow. A Page never owns state directly:
|
||||
|
||||
- **Page pattern** (enforced by `lint:pages`, see `client/src/pages/PATTERN.md`): a `*Page.tsx` default export is a **wiring container** — it composes a co-located `use<Page>()` hook into JSX and must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` in its own body. Stateful logic lives in the hook.
|
||||
- **State**: Zustand stores in `client/src/store/`. The big one is `tripStore.ts`, assembled from **slices** in `store/slices/` (places, packing, todo, budget, reservations, files, …). `slices/remoteEventHandler.ts` applies incoming WebSocket events to local state.
|
||||
- **Data flow (offline-first)**: `store → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Repos check `navigator.onLine`: online they hit the REST API and upsert into Dexie; offline they read from Dexie. Writes go through `sync/mutationQueue.ts` — optimistic Dexie write, then on reconnect `flush()` replays REST with an `X-Idempotency-Key` header so retried writes don't double-apply. `sync/tripSyncManager.ts` and `sync/syncTriggers.ts` drive reconnection sync; `sync/tilePrefetcher.ts` caches map tiles.
|
||||
- **Styling**: use the appearance tokens (`var(--token)` inline or `bg-[var(--...)]` Tailwind classes), never hardcoded color literals or numeric `fontSize` — `theme:lint` flags bypasses so user-chosen scheme/transparency/text-size keep working. Suppress intentional exceptions (map/PDF/brand colors) with a `theme-lint-disable` line comment.
|
||||
- **PWA**: `vite-plugin-pwa` + Workbox caches tiles/API/uploads. `prebuild` generates icons.
|
||||
- **Page pattern** (enforced by `lint:pages`, spec in `client/src/pages/PATTERN.md`): a `*Page.tsx` is a **wiring container** composing a co-located `use<Page>()` hook; it must not call React state/effect/memo hooks itself.
|
||||
- **Data flow**: `store (Zustand, client/src/store/) → repo (client/src/repo/) → api (client/src/api/) | Dexie (client/src/db/offlineDb.ts)`. Writes go through `sync/mutationQueue.ts`: optimistic Dexie write, then a replay with an `X-Idempotency-Key` header on reconnect. `store/slices/remoteEventHandler.ts` applies incoming WebSocket events. Components never call the API directly.
|
||||
- **Styling**: semantic Tailwind tokens (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`) or the underlying `var(--token)` variables — never color literals, arbitrary-value color classes or numeric inline `fontSize`, so user-chosen scheme/transparency/text-size keep working. `theme:lint` flags bypasses; suppress intentional exceptions (map/PDF/brand) with a `theme-lint-disable` line comment.
|
||||
- **PWA**: `vite-plugin-pwa` + Workbox cache tiles, API and uploads; `prebuild` generates icons.
|
||||
|
||||
## Shared contracts & i18n
|
||||
|
||||
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema in `shared/src/<domain>/`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up.
|
||||
- **i18n locale files live in `shared/src/i18n/<locale>/`**, one file per domain. `en/` is canonical. When you add/change a translation key, **add it to every locale** or `i18n:parity:strict` fails CI. The client resolves keys via `client/src/i18n/TranslationContext.tsx` (`t(key)`), which only reads top-level string keys.
|
||||
- A route is "done" only once its contract lives in `shared/` and both sides import the inferred types. Edit the Zod schema, rebuild shared, then server (validation + DTO types) and client (typed requests) pick it up.
|
||||
- Locale files live in `shared/src/i18n/<locale>/`, one file per domain; `en/` is canonical. When you add or change a key, **add a real translation to every locale** — parity fails CI on missing keys, and an English placeholder is not acceptable in a non-`en` locale.
|
||||
|
||||
## Project direction
|
||||
|
||||
These principles come out of a verified 2026 full-repo audit and shape how all new code should be written:
|
||||
These principles come out of a full-repo audit and shape all new code:
|
||||
|
||||
- **Protect the crown jewels.** The client's offline-first data core (repos + mutation queue + Dexie), the out-of-process plugin sandbox, the Zod contract layer, and the auth primitives are production-grade. Extend them; never route around them or rewrite them in anger.
|
||||
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (historically Nest controllers over legacy `services/*` — that migration is done; on the client it's the long tail of feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
|
||||
- **No "modern shell over legacy core."** The debt pattern is a modern frame delegating to the old approach underneath (on the client: feature components calling the raw API past the offline core). New code goes all-in on the target architecture; when touching a legacy seam, migrate it rather than adding another wrapper.
|
||||
- **Single source of truth over manual synchronization.** Never add a hand-mirrored copy of state, schema, or contract (server↔Dexie↔Zustand, schema↔SQL, the plugin contract). Derive from one source, or guard the copy with a parity test that cannot silently skip.
|
||||
- **DI over global mutable module state.** The `db` proxy, `ws` singleton, and event-sink globals are what force `require()`-to-dodge-cycles and load-bearing boot ordering. New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
|
||||
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (DI providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents of the same machinery.
|
||||
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not silently degrade. Never lower a quality gate (strict TS, lint severity, coverage, parity checks) to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`.
|
||||
- **DI over global mutable module state.** New server code injects its dependencies; new client code avoids `window`-global buses and mutable module state.
|
||||
- **Use the runtime's native idioms.** Prefer React 19 features (`useOptimistic`, Actions, `use()`/Suspense) and Nest subsystems (providers, pipes, guards, `@nestjs/schedule`) over hand-rolled equivalents.
|
||||
- **Fail closed, gates stay on.** Security switches default to safe; misconfiguration must refuse, not degrade. Never lower a quality gate to land a change — no new `any`, no new `eslint-disable`, no downgrading rules to `warn`, no lowered coverage thresholds.
|
||||
|
||||
## Conventions (from CONTRIBUTING.md)
|
||||
|
||||
- **Target the `dev` branch** for PRs, not `main` (exception: `wiki/`-only changes).
|
||||
- **Discuss first**: outside contributions must be pitched in the `#github-pr` Discord channel before any code is written — undiscussed PRs are closed.
|
||||
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and require a linked issue (`Closes #N`) for bug fixes or an approved feature-request discussion for features — no issue/discussion, no PR.
|
||||
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **Do not add Co-Authored-By or other tool-attribution trailers** to commit messages.
|
||||
- One focused change per PR; no breaking changes; no unrelated reformatting/refactors. Tests required — the project holds **80%+ coverage** (the `src/nest/**` vitest coverage gate enforces ≥80%).
|
||||
- When migrating/adding a route, **parity is law**: same URL, method, query/body, HTTP status, `Set-Cookie`, and JSON body — including bespoke error strings (e.g. reproduce `{ error: 'Admin only' }` exactly rather than relying on a generic guard message). Note Nest defaults POST to 201; add `@HttpCode(200)` where the legacy contract returned 200. Declare static sub-routes (`/reorder`, `/in-app/all`) **before** `:id` param routes.
|
||||
- **Discuss first**: outside contributions are pitched in the `#github-pr` Discord channel before any code is written.
|
||||
- **PRs follow `.github/PULL_REQUEST_TEMPLATE.md`** and need a linked issue (`Closes #N`) for fixes or an approved feature discussion for features.
|
||||
- **Conventional commits** (`fix(maps): ...`, `feat(budget): ...`). **No Co-Authored-By or other tool-attribution trailers.**
|
||||
- One focused change per PR; no breaking changes; no unrelated reformatting. Tests ship in the same change — the project holds 80%+ coverage.
|
||||
- **Parity is law** when touching a route: same URL, method, query/body, HTTP status, `Set-Cookie` and JSON body, including bespoke error strings. Nest defaults POST to 201 — add `@HttpCode(200)` where the contract returns 200. Declare static sub-routes (`/reorder`) **before** `:id` param routes.
|
||||
|
||||
## Quality gates in CI
|
||||
|
||||
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), `lint:check`, `lint:pages`, `check:plugin-facts`, `i18n:parity:strict`, the S3 contract suite, coverage for all four packages, then a SonarCloud scan. `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint`, Playwright, `check:gl-split` — run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
|
||||
|
||||
**SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it:
|
||||
|
||||
| Condition (new code) | Must be |
|
||||
|---|---|
|
||||
| Coverage | ≥ 80% |
|
||||
| Duplicated lines | ≤ 3% |
|
||||
| Security / reliability / maintainability rating | A |
|
||||
| Security hotspots reviewed | 100% |
|
||||
|
||||
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors that are already heavily duplicated. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. Locale files are excluded from duplication, so i18n additions are free.
|
||||
- **Coverage is per new line across all four packages.** A new file without tests drags the PR under 80%. Files in `sonar.coverage.exclusions` don't count either way.
|
||||
- **Ratings are about new issues.** One new bug drops reliability; one new vulnerability drops security. Don't introduce hotspot patterns (`Math.random` in anything security-adjacent, `child_process` through PATH, backtracking regexes, secret-looking strings). Path-wide suppressions live only in `sonar-project.properties` with a written justification; one-off findings are resolved on SonarCloud, never with an inline `// NOSONAR`.
|
||||
- **Check it yourself.** The `sonarqube` MCP is connected: `list_pull_requests` → `get_project_quality_gate_status` shows each condition's value; `search_duplicated_files` + `get_duplications` show what tripped duplication; `search_sonar_issues_in_projects` lists new issues. Test files are excluded from analysis — when duplication fires, the source is the problem.
|
||||
|
||||
## Reference docs
|
||||
|
||||
- `MCP.md` — MCP server/tools/scopes. `README.md` — deployment, env vars, reverse-proxy setup. `server/src/nest/README.md` — per-module blueprint and test layout (unit / parity / e2e).
|
||||
- `MCP.md` (MCP server, tools, scopes) · `README.md` (deployment, env vars, reverse proxy) · `server/src/nest/README.md` (module blueprint, test layout) · `server/src/app-config/README.md` (env parsing quirks) · `plugin-sdk/README.md` (plugin author guide).
|
||||
- `wiki/` — end-user documentation. `Dockerfile` / `docker-compose*.yml` — the deployment path and the local S3 test stack.
|
||||
|
||||
+2
-1
@@ -8,7 +8,7 @@ This is a minimal Helm chart for deploying the TREK app.
|
||||
- Optional persistent storage for `/app/data` and `/app/uploads`
|
||||
- Configurable environment variables and secrets
|
||||
- Optional generic Ingress support
|
||||
- Health checks on `/api/health`
|
||||
- Configurable health checks (liveness/readiness probes) on `/api/health`
|
||||
|
||||
## Helm Repository
|
||||
|
||||
@@ -46,6 +46,7 @@ See `values.yaml` for more options.
|
||||
- With `persistence.enabled: false`, the data and uploads volumes use an `emptyDir` — storage is ephemeral and lost on pod restart. Intended for testing only.
|
||||
- `JWT_SECRET` is managed entirely by the server — auto-generated into the data PVC on first start and rotatable via the admin panel (Settings → Danger Zone). No Helm configuration needed.
|
||||
- `ENCRYPTION_KEY` encrypts stored secrets (API keys, MFA, SMTP, OIDC) at rest. Recommended: set via `secretEnv.ENCRYPTION_KEY` or `existingSecret`. If left empty, the server falls back automatically: existing installs use `data/.jwt_secret` (no action needed on upgrade); fresh installs auto-generate a key persisted to the data PVC.
|
||||
- Liveness and readiness probes are configurable under `probes`. Overrides are merged over the defaults, so `--set probes.liveness.initialDelaySeconds=60` keeps the `httpGet` handler. When switching to `exec` or `tcpSocket`, clear `httpGet` in the same override. Set `probes.liveness: null` to remove a probe.
|
||||
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||
- Set `env.ALLOW_INTERNAL_NETWORK: "true"` if Immich or other integrated services are hosted on a private/RFC-1918 address (e.g. a pod on the same cluster or a NAS on your LAN). Loopback (`127.x`) and link-local/metadata addresses (`169.254.x`) remain blocked regardless.
|
||||
- `FORCE_HTTPS` is optional. Set `env.FORCE_HTTPS: "true"` only when ingress (or another proxy) terminates TLS. It enables HTTPS redirects, HSTS, CSP `upgrade-insecure-requests`, and forces the session cookie `secure` flag. Requires `TRUST_PROXY` to be set.
|
||||
|
||||
@@ -80,18 +80,14 @@ spec:
|
||||
mountPath: /app/data
|
||||
- name: uploads
|
||||
mountPath: /app/uploads
|
||||
{{- with (default dict .Values.probes).liveness }}
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- with (default dict .Values.probes).readiness }}
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: data
|
||||
{{- if .Values.persistence.enabled }}
|
||||
|
||||
@@ -134,6 +134,25 @@ persistence:
|
||||
existingClaim: ""
|
||||
annotations: {}
|
||||
|
||||
# Readiness and liveness probes. Overrides are merged over these defaults, so a key you
|
||||
# do not set stays as shipped: --set probes.liveness.initialDelaySeconds=60 keeps the
|
||||
# httpGet handler. Switching handler means clearing the old one in the same override
|
||||
# (httpGet: null together with tcpSocket), otherwise the API rejects the pod with
|
||||
# "may not specify more than 1 handler type". Set probes.liveness to null to drop a probe.
|
||||
probes:
|
||||
liveness:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 30
|
||||
readiness:
|
||||
httpGet:
|
||||
path: /api/health
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
|
||||
+45
-53
@@ -1,77 +1,69 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and `server/CLAUDE.md` for the API. This file covers client internals only.
|
||||
Scope: the **`@trek/client`** workspace (React 19 + Vite + Zustand + Tailwind PWA). See the repo-root `CLAUDE.md` for the monorepo picture and the philosophy, `server/CLAUDE.md` for the API. This file holds the client's commands, invariants and pitfalls; `ls` the directories for the inventory.
|
||||
|
||||
## Commands (run from `client/`)
|
||||
|
||||
```bash
|
||||
npm run dev # Vite dev server; proxies /api,/ws,/uploads,/oauth → http://localhost:3001
|
||||
npm run dev # Vite dev server; proxies the API/ws/uploads/MCP/OAuth paths → http://localhost:3001
|
||||
npm run build # prebuild generates PWA icons, then vite build
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run lint # eslint .
|
||||
npm run lint:pages # enforce the Page pattern (scripts/check-page-pattern.mjs)
|
||||
npm run test # vitest run (tests/** + src/**/*.test.{ts,tsx})
|
||||
npm run test:unit # tests/unit
|
||||
npm run test:integration # tests/integration + co-located src tests
|
||||
npm run test:coverage # v8 coverage
|
||||
npm run e2e # Playwright
|
||||
npm run e2e:report # open last Playwright report
|
||||
npm run shots # Playwright screenshots project (shots:promote to accept)
|
||||
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1 on findings; not a CI gate)
|
||||
npm run typecheck # tsc --noEmit (CI)
|
||||
npm run lint # eslint . (lint:check in CI)
|
||||
npm run lint:pages # enforce the Page pattern (CI gate)
|
||||
npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage
|
||||
npm run e2e # Playwright (local only; e2e:report opens the last report)
|
||||
npm run shots # Playwright screenshot project (shots:promote to accept)
|
||||
npm run theme:lint # theme conformance audit (theme:lint:strict exits 1; local only)
|
||||
npm run check:gl-split # after build: fails if one chunk bundles both mapbox-gl and maplibre-gl (local only)
|
||||
```
|
||||
|
||||
Single test:
|
||||
Single test: `npx vitest run src/store/slices/budgetSlice.test.ts`, or `npx vitest run -t "optimistically adds the place"`.
|
||||
|
||||
```bash
|
||||
npx vitest run src/store/slices/budgetSlice.test.ts
|
||||
npx vitest run -t "optimistically adds the place"
|
||||
npx vitest watch src/pages/DashboardPage.test.tsx
|
||||
```
|
||||
The dev server needs the API on **:3001** — run `npm run dev` at the repo root to start both.
|
||||
|
||||
The dev server needs the API on **:3001** — run the server (`npm run dev` from root or `server/`) alongside, or `npm run dev` at the repo root to start both.
|
||||
## Page pattern (enforced — `lint:pages` fails otherwise)
|
||||
|
||||
## Page pattern (enforced — `lint:pages` will fail otherwise)
|
||||
|
||||
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `<page>Model.ts` holds pure (React-free) types/helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep rendered JSX byte-identical — it's a refactor of where logic lives.
|
||||
Every `src/pages/*Page.tsx` is a thin **wiring container**; all state/effects/handlers live in a co-located **`use<Page>()` hook** under `src/pages/<page>/`. The page body must **not** call React state/effect/memo/ref hooks — only context hooks like `useTranslation()`. An optional `<page>Model.ts` holds pure, React-free types and helpers. Full spec in `src/pages/PATTERN.md`. When extracting, keep the rendered JSX byte-identical — it is a refactor of where logic lives.
|
||||
|
||||
## Data flow — offline-first
|
||||
|
||||
The layering is **store → repo → api | Dexie**, and writes go through a mutation queue:
|
||||
The layering is **component → feature hook → store/slice → `repo/` → `api/` | Dexie**, and writes go through a mutation queue:
|
||||
|
||||
- **`src/store/`** — Zustand. `tripStore.ts` is composed from **slices** in `store/slices/` (places, days, packing, todo, budget, reservations, files, assignments, dayNotes). Other stores: auth, settings, addon, permissions, inAppNotification, journey, vacay, systemNotice, collection, saveToCollection, plugin, backgroundTasks. `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
|
||||
- **`src/repo/`** — per-entity repositories that mediate between the network and the offline cache. The pattern: if `navigator.onLine`, call the REST API and upsert into Dexie; if offline, read from Dexie. Always go through a repo for trip data, not the API directly.
|
||||
- **`src/api/client.ts`** — single Axios instance; request/response types imported from `@trek/shared` Zod schemas. `api/websocket.ts` manages the `/ws` connection.
|
||||
- **`src/db/offlineDb.ts`** — Dexie/IndexedDB schema: cached entities + the `QueuedMutation` table + `SyncMeta` (per-trip last-sync, prefetched-tiles bbox) + a `BlobCache` for offline file/photo access.
|
||||
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (with a temporary **negative id** for creates), then on reconnect `flush()` replays REST with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the Dexie row. `tripSyncManager.ts` / `syncTriggers.ts` / `connectivity.ts` drive reconnection sync; `tilePrefetcher.ts` pre-downloads map tiles.
|
||||
- **`src/store/`** — Zustand. `tripStore.ts` is composed from slices in `store/slices/`; `slices/remoteEventHandler.ts` applies inbound WebSocket events to local state.
|
||||
- **`src/repo/`** — per-entity repositories mediating between the network and the offline cache: online, call REST and upsert into Dexie; offline, read Dexie. Always go through a repo for trip data.
|
||||
- **`src/api/client.ts`** — the single Axios instance, typed from `@trek/shared`. New API surface goes in a per-domain `api/<domain>.ts`, not the god module.
|
||||
- **`src/db/offlineDb.ts`** — the Dexie schema. Database and table names are the on-device contract — renaming one orphans every user's cache.
|
||||
- **`src/sync/`** — `mutationQueue.ts`: offline writes do an optimistic Dexie write (temporary **negative id** for creates), then `flush()` replays REST on reconnect with the mutation's UUID as the **`X-Idempotency-Key`** header (matching the server interceptor) and reconciles the row. The rest of the directory drives reconnection sync, the auth gate that stops background loops before logout swaps the DB, offline preferences, and map pre-download (raster and vector prefetchers).
|
||||
|
||||
## Direction (from the 2026 audit)
|
||||
## Rules for new code
|
||||
|
||||
The offline core is flagship work surrounded by a periphery that ignores it. New code must extend the core's discipline, not the periphery's shortcuts:
|
||||
The offline core is flagship work surrounded by a periphery that ignores it. New code extends the core's discipline, not the periphery's shortcuts:
|
||||
|
||||
- **One data path, no layer skips**: component → feature hook → store/slice → `repo/` → `api/` | Dexie. **Never import `api/client` (or raw fetch/axios) from a component or modal** — especially never *write* from one. A long tail of existing files (~190 at last count) bypasses the layering and no lint ratchet holds the line yet; that's debt, not precedent. New API surface goes in a per-domain `api/<domain>.ts`, not the `client.ts` god module.
|
||||
- **Offline-first is the product promise, not a trip-planner feature.** New domains should get a repo with both read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry), extending the existing pattern rather than hand-rolling a variant. If a domain is deliberately online-only, mark it so explicitly.
|
||||
- **No God components or God hooks.** The page pattern is a floor, not a ceiling — a 900-line `use<Page>()` hook that satisfies the linter is still coupling. Decompose hooks by concern, split renders into memoizable sections, and don't pass 50-field prop bags with inline-arrow callbacks (it defeats `React.memo`).
|
||||
- **Prefer React 19 idioms over hand-rolled machinery**: `useOptimistic` for optimistic updates, `useActionState`/`useFormStatus` for forms, `use()` + Suspense for read paths, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference implementation).
|
||||
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`; silently keeping state the server rejected is a data-integrity bug.
|
||||
- **WS remote-event handling must match local slice reducers field-for-field.** If a slice reducer preserves embedded fields on update (e.g. `place_time`/`end_time`), the corresponding `remoteEventHandler` branch must apply the identical merge semantics — divergence is silent, cross-user data loss.
|
||||
- **Subscribe with selectors** (`useShallow` for multi-field reads); never `const s = useStore()` whole-store; no store getters that allocate per call. Use `getState()` for imperative access in effects.
|
||||
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` directly in feature code.
|
||||
- **Rendering security**: never interpolate user content into HTML strings (map tooltips/popups included — build via DOM + `textContent`); untrusted or cross-user markdown gets `rehype-sanitize`; never `rehype-raw` near those surfaces.
|
||||
- **No `window` CustomEvent buses or global mutable `window` state** — use the store or a typed emitter. No `any` at boundaries: WS payloads and map renderer props get real types, validated where they enter.
|
||||
- **Hygiene**: error boundaries around new shells/routes; every async `.then(setState)` needs a cancelled flag or `AbortController`; no `eslint-disable exhaustive-deps` as a routine tool (a missing memo dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops — batch; search for an existing utility/hook before writing a duplicate.
|
||||
- **Theming**: use the semantic Tailwind tokens (`bg-surface*`, `text-content`, `border-edge`, `bg-accent`, status colors) — no raw palette classes, hex literals, or invented CSS vars; only `applyAppearance()` mutates `<html>` styling.
|
||||
- **One data path, no layer skips.** Never import `api/client` (or raw fetch/axios) from a component or modal — especially never *write* from one. A long tail of existing files bypasses the layering and no lint ratchet holds the line yet; that is debt, not precedent.
|
||||
- **Offline-first is the product promise.** New domains get a repo with read-through and offline writes (temp id → optimistic Dexie write → idempotent queue entry). If a domain is deliberately online-only, say so explicitly.
|
||||
- **No god components or god hooks.** The page pattern is a floor, not a ceiling. Decompose hooks by concern, split renders into memoizable sections, and don't pass huge prop bags with inline-arrow callbacks.
|
||||
- **Prefer React 19 idioms**: `useOptimistic`, `useActionState`/`useFormStatus`, `use()` + Suspense, `useSyncExternalStore` for external subscriptions (`hooks/useNetworkMode` is the reference).
|
||||
- **Optimistic writes must reconcile** — rollback + user-visible toast on failure. No `.catch(() => {})`, no bare `catch {}`.
|
||||
- **WS remote-event handling must match local slice reducers field-for-field.** Divergence is silent, cross-user data loss.
|
||||
- **Subscribe with selectors** (`useShallow` for multi-field reads); never whole-store subscriptions; `getState()` for imperative access in effects.
|
||||
- **Connectivity**: `isEffectivelyOffline()` is the single source of truth — never read `navigator.onLine` in feature code.
|
||||
- **Rendering security**: never interpolate user content into HTML strings (map popups included — build via DOM + `textContent`, or `escapeHtml` from `@trek/shared`); untrusted markdown gets `rehype-sanitize`; never `rehype-raw` near it.
|
||||
- **No `window` event buses or global mutable `window` state.** No `any` at boundaries: WS payloads and map renderer props get real types.
|
||||
- **Hygiene**: error boundaries around new shells/routes (`components/shared/ErrorBoundary.tsx`); every async `.then(setState)` needs a cancelled flag or `AbortController`; no routine `eslint-disable exhaustive-deps` (a missing dep has already shipped wrong money on screen); no sequential-await N+1 fetch loops; search for an existing utility before writing a duplicate.
|
||||
- **Theming**: use the semantic Tailwind tokens defined in `tailwind.config.js` (`bg-surface*`, `text-content*`, `border-edge*`, `bg-accent*`, status colors) — no palette classes, hex literals, arbitrary-value color classes, or invented CSS vars. Only `applyAppearance()` in `src/theme/` mutates `<html>` styling (see `src/theme/README.md`). `theme:lint` catches literals and `bg-[#...]`-style classes but not named palette classes — review those by hand.
|
||||
|
||||
## Other big-picture pieces
|
||||
## Big-picture pieces
|
||||
|
||||
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet (`MapView.tsx`) and Mapbox GL (`MapViewGL.tsx`), chosen at runtime by `MapViewAuto.tsx` based on the tile-source setting. GL-specific overlay/marker logic lives in the `*Mapbox.ts` siblings. Keep both renderers in sync when changing map features. **Offline maps**: only Leaflet supports full pre-download (raster tiles via `sync/tilePrefetcher.ts`, cached by the Workbox `map-tiles` rule). Mapbox GL is best-effort offline — its vector tiles are cached opportunistically as you browse online (the `mapbox-tiles` SW rule), not prefetched. Tile requests stay `no-cors` so self-hosted/custom tile providers without CORS headers keep working; `navigator.storage.persist()` (called at app init) keeps cached tiles from being evicted under storage pressure.
|
||||
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled synchronously as the fallback; every other locale is a **dynamic `import('@trek/shared/i18n/<locale>')`** so Vite code-splits one chunk per locale and only the active one is fetched. `t(key)` resolves top-level keys from `@trek/shared/i18n`. Add new strings to the locale files in **`shared/`**, not here. RTL handled via `isRtlLanguage`.
|
||||
- **Mobile shell** (`src/mobile/`): below 768px (`useIsPhone`, reactive via matchMedia) `App.tsx` wraps routes in `MobileShell`, and phone-specific screens under `mobile/screens/<domain>/` (trip, dashboard, atlas, vacay, settings, admin, …) take over; the desktop/tablet experience at ≥768px is untouched. UI changes to a domain that has an `M*` screen usually need both the desktop component and the mobile screen updated.
|
||||
- **Plugins UI** (`src/components/Plugins/`): third-party plugin surfaces render in a sandboxed iframe — `PluginFrame.tsx` owns the postMessage bridge and hands the plugin the current theme's design tokens (live re-sync on theme/accent change). Contribution points: `PluginWidgets.tsx` (dashboard), `PluginDaySchedule.tsx` (itinerary rows), `Map/MapPluginLayers.tsx`/`MapPluginMarkers.tsx` (map). `store/pluginStore.ts` holds installed-plugin state; admin install/manage in `components/Admin/AdminPluginsPanel.tsx`. The server-side runtime is `server/src/nest/plugins/`; plugin authoring is covered by the `trek-plugin-dev` skill.
|
||||
- **PWA** (`vite.config.js`): `VitePWA` with `registerType: 'autoUpdate'` and Workbox `runtimeCaching` for tiles/API/uploads. `prebuild` (`scripts/generate-icons.mjs`) generates app icons before build.
|
||||
- **Native shell** (`client/src/platform/`): the Capacitor apps are not served from the same origin as the API, so `getServerOrigin()` is the single source of truth for where the server lives — `''` in the web build (relative URLs stay relative), the user's chosen server in native. Requests go through `apiFetch` / the `apiClient` interceptor, assets through `resolveAssetUrl` / `<ServerImage>`; ESLint blocks bare `fetch('/api…')` and `/uploads` literals so the seam cannot reopen. Native authenticates with `Authorization: Bearer` because the session cookie is `sameSite: 'lax'` and a webview can never send it cross-site. Saved servers are "profiles" (`serverProfiles.ts`, Capacitor Preferences) with tokens in device secure storage (`serverTokens.ts`) and a per-profile Dexie namespace. **The web build must stay byte-identical — especially Dexie names**; see `offlineDb.naming.test.ts`.
|
||||
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other.
|
||||
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/<locale>')` so Vite emits one chunk per locale. Strings live in `shared/`, never here.
|
||||
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both — and the shared-hook rule below.
|
||||
- **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill.
|
||||
- **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there.
|
||||
|
||||
## Desktop ↔ mobile shells and the SonarQube duplication gate
|
||||
|
||||
Every desktop component with a phone twin is a deliberate copy that is already heavily duplicated. CI's SonarCloud gate allows **≤ 3% duplicated lines on the PR's new code**, so adding the same lines to both shells fails the PR by itself. Put logic in one shared hook/module (`useInstanceSettings`, `useRangeBypass` under `components/Admin/` are the pattern) and keep only markup in each shell. Full gate rules in the root `CLAUDE.md`.
|
||||
|
||||
## Tests
|
||||
|
||||
vitest with `@vitejs/plugin-react`, jsdom (custom `tests/environment/jsdom-native-abort.ts`), `forks` pool. Tests live both in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie in tests. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`). Playwright e2e config separate (`npm run e2e`).
|
||||
vitest with `@vitejs/plugin-react`, a custom jsdom environment (`tests/environment/`), `forks` pool. Tests live in `tests/{unit,integration}/` and co-located as `src/**/*.test.{ts,tsx}`. `msw` mocks HTTP, `fake-indexeddb` backs Dexie. Page tests render JSX against a mocked hook; hook/slice logic is tested in isolation (see `store/slices/budgetSlice.test.ts`).
|
||||
|
||||
@@ -41,6 +41,12 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
|
||||
}
|
||||
await page.reload()
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// An iPad is inside the 768-1023px band, where only one side panel is open at a
|
||||
// time so the map keeps a usable width (#2247). The places list is one tap away.
|
||||
// Located by the tab's own aria-label, not by role+name: the tab bar carries a
|
||||
// "Plan" button of its own.
|
||||
await page.locator('button[aria-label="Places"]').click()
|
||||
await expect(page.getByText('Place 1').first()).toBeVisible({ timeout: 20_000 })
|
||||
|
||||
// The context must really be the one from the bug report: coarse pointer, desktop
|
||||
@@ -74,6 +80,9 @@ test('#1432 iPad: places list is scrollable, not draggable', async ({ page }) =>
|
||||
})
|
||||
expect(['1', 'absent']).toContain(arrowOpacity)
|
||||
|
||||
// 4. The iPad must still get the desktop two-pane layout — isMobile stayed width-based.
|
||||
// 4. The iPad must still get the desktop shell — isMobile stayed width-based. One
|
||||
// panel at a time (#2247), so opening Places closed the day plan and left its
|
||||
// tab as the way back.
|
||||
await expect(page.locator('.leaflet-container')).toBeVisible()
|
||||
await expect(page.locator('button[aria-label="Plan"]')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"@fontsource/museomoderno": "^5.3.0",
|
||||
"@fontsource/playfair-display": "^5.3.0",
|
||||
"@fontsource/poppins": "^5.2.7",
|
||||
"@mapbox/mapbox-gl-rtl-text": "^0.4.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@simplewebauthn/browser": "^13.1.2",
|
||||
"@trek/shared": "*",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* setRTLTextPlugin() takes the URL of a classic script that the GL worker pulls in
|
||||
* with importScripts. mapbox-gl-rtl-text ships exactly that as
|
||||
* dist/mapbox-gl-rtl-text.js, but its "exports" field publishes only the ESM/WASM
|
||||
* entry, so the resolver refuses the subpath. Resolving the entry it does publish
|
||||
* and stepping across to dist keeps this working wherever npm hoists the package.
|
||||
*
|
||||
* Shared by vite.config.js and vitest.config.ts: the build needs it to emit the
|
||||
* asset, and the test run needs it because a lazily imported engine module reaches
|
||||
* the same specifier.
|
||||
*
|
||||
* The pattern is a regex, not a string key: the import carries a `?url` suffix,
|
||||
* which a string alias will not match. It is left unanchored on purpose so the
|
||||
* query survives the rewrite and Vite still emits an asset URL.
|
||||
*/
|
||||
export const rtlTextAlias = {
|
||||
find: /^@mapbox\/mapbox-gl-rtl-text\/dist\/mapbox-gl-rtl-text\.js/,
|
||||
replacement: path.join(
|
||||
path.dirname(createRequire(import.meta.url).resolve('@mapbox/mapbox-gl-rtl-text')),
|
||||
'../dist/mapbox-gl-rtl-text.js',
|
||||
),
|
||||
};
|
||||
@@ -522,6 +522,83 @@ describe('Version cache-busting', () => {
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(reload).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-026: a version bump never deletes the caches or unregisters the worker (#2228)', async () => {
|
||||
// The old handler wiped every Cache Storage bucket and every registration
|
||||
// here, which left the device with no app shell until a fresh ~22 MB
|
||||
// precache finished: anyone who closed the app or lost signal inside that
|
||||
// window could no longer start the PWA offline at all, and it also threw
|
||||
// away the map tiles and documents the user had downloaded on purpose.
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
const cachesDelete = vi.fn().mockResolvedValue(true)
|
||||
const unregister = vi.fn().mockResolvedValue(true)
|
||||
const update = vi.fn().mockResolvedValue(undefined)
|
||||
vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete })
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getRegistration: vi.fn().mockResolvedValue({ update, unregister }),
|
||||
getRegistrations: vi.fn().mockResolvedValue([{ unregister }]),
|
||||
addEventListener: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '2.9.10' })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
|
||||
// It asks the worker to fetch the new build; Workbox swaps the precache
|
||||
// atomically once that install completes.
|
||||
await waitFor(() => expect(update).toHaveBeenCalled())
|
||||
expect(cachesDelete).not.toHaveBeenCalled()
|
||||
expect(unregister).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-028: a version that does not look like a release tag is ignored', async () => {
|
||||
// The value arrives over the wire and is written to this device's storage,
|
||||
// so it is validated rather than trusted. An unusable one must also not be
|
||||
// compared against the stored marker, or every launch would see a mismatch
|
||||
// and try to update.
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
const reload = vi.fn()
|
||||
Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } })
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '<script>alert(1)</script>', managed: false })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('common.loading')).not.toBeInTheDocument())
|
||||
expect(localStorage.getItem('trek_app_version')).toBe('2.9.9')
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-APP-027: the version marker is written even if the update path throws (#2228)', async () => {
|
||||
// The marker used to be written after the purge and outside its try, so a
|
||||
// throwing step left it unwritten and the whole destructive path repeated on
|
||||
// every launch, for good.
|
||||
localStorage.setItem('trek_app_version', '2.9.9')
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: { getRegistration: vi.fn().mockRejectedValue(new Error('no worker')), addEventListener: vi.fn() },
|
||||
})
|
||||
|
||||
server.use(
|
||||
http.get('/api/auth/app-config', () =>
|
||||
HttpResponse.json({ version: '2.9.10' })
|
||||
)
|
||||
)
|
||||
seedAuth()
|
||||
renderApp('/')
|
||||
await waitFor(() => expect(localStorage.getItem('trek_app_version')).toBe('2.9.10'))
|
||||
})
|
||||
})
|
||||
|
||||
// Regression: a device that has never mirrored the preference must not treat
|
||||
|
||||
+50
-18
@@ -19,7 +19,7 @@ import ErrorBoundary from './components/shared/ErrorBoundary'
|
||||
import { lazyWithRetry } from './utils/lazyWithRetry'
|
||||
import { useIsPhone } from './mobile/useIsPhone'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import { authApi } from './api/client'
|
||||
import { authApi, isAuthPublicPath } from './api/client'
|
||||
import { tripRepo } from './repo/tripRepo'
|
||||
import { readStartDestination, tripStartPath, DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB, SETTINGS_WAIT_MS, START_DESTINATION_ROUTE } from './utils/startDestination'
|
||||
import { usePermissionsStore, PermissionLevel } from './store/permissionsStore'
|
||||
@@ -283,7 +283,7 @@ function RouteFallback() {
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled } = useAuthStore()
|
||||
const { loadUser, isAuthenticated, demoMode, setManaged, setDemoMode, setDevMode, setIsPrerelease, setAppVersion, setHasMapsKey, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore()
|
||||
const { loadSettings } = useSettingsStore()
|
||||
const { loadAddons } = useAddonStore()
|
||||
const { loadPlugins } = usePluginStore()
|
||||
@@ -300,7 +300,7 @@ export default function App() {
|
||||
loadUser()
|
||||
}
|
||||
}
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; version?: string; timezone?: string; require_mfa?: boolean; trip_reminders_enabled?: boolean; places_photos_enabled?: boolean; places_autocomplete_enabled?: boolean; places_details_enabled?: boolean; places_enrich_enabled?: boolean; place_shadow_enabled?: boolean; permissions?: Record<string, PermissionLevel> }) => {
|
||||
setManaged(!!config?.managed)
|
||||
setDemoMode(!!config?.demo_mode)
|
||||
if (config?.dev_mode) setDevMode(true)
|
||||
@@ -314,26 +314,49 @@ export default function App() {
|
||||
if (config?.places_autocomplete_enabled !== undefined) setPlacesAutocompleteEnabled(config.places_autocomplete_enabled)
|
||||
if (config?.places_details_enabled !== undefined) setPlacesDetailsEnabled(config.places_details_enabled)
|
||||
if (config?.places_enrich_enabled !== undefined) setPlacesEnrichEnabled(config.places_enrich_enabled)
|
||||
if (config?.place_shadow_enabled !== undefined) setPlaceShadowEnabled(config.place_shadow_enabled)
|
||||
if (config?.permissions) usePermissionsStore.getState().setPermissions(config.permissions)
|
||||
|
||||
if (config?.version) {
|
||||
// A version is a short release tag and nothing else. It arrives over the
|
||||
// wire and is written to this device's storage, so it is rebuilt from the
|
||||
// characters a tag may contain and kept only when nothing had to be
|
||||
// stripped. Anything else is ignored, which also keeps a malformed value
|
||||
// from being compared against the stored marker and starting an update on
|
||||
// every launch.
|
||||
const reportedVersion = typeof config?.version === 'string' ? config.version : ''
|
||||
const version = reportedVersion.replace(/[^\w.+-]/g, '').slice(0, 64)
|
||||
if (version && version === reportedVersion) {
|
||||
const storedVersion = localStorage.getItem('trek_app_version')
|
||||
if (storedVersion && storedVersion !== config.version) {
|
||||
// Record the version BEFORE acting on it. The old code wrote the marker
|
||||
// after the purge and outside its try, so a throwing setItem (private
|
||||
// mode, blocked site data, quota) left the caches deleted, the marker
|
||||
// unwritten and the reload unreached, and the purge then repeated on
|
||||
// every single launch, permanently (#2228).
|
||||
try { localStorage.setItem('trek_app_version', version) } catch { /* site data blocked */ }
|
||||
if (storedVersion && storedVersion !== version) {
|
||||
// A newer build is deployed. Ask the service worker to fetch it and
|
||||
// hand over; Workbox ('autoUpdate' + skipWaiting + clientsClaim)
|
||||
// installs the new precache and only then drops the outdated one, so
|
||||
// there is never a moment without an app shell.
|
||||
//
|
||||
// This used to delete EVERY Cache Storage bucket and unregister EVERY
|
||||
// worker instead. That left the device with no shell and no worker
|
||||
// until a fresh ~22 MB precache finished (minutes on mobile data),
|
||||
// and anyone who closed the app or lost signal in that window was
|
||||
// left with a PWA that could no longer start offline at all. It also
|
||||
// threw away the map tiles and file blobs the user had deliberately
|
||||
// downloaded for offline use (#2228).
|
||||
try {
|
||||
if ('caches' in window) {
|
||||
const names = await caches.keys()
|
||||
await Promise.all(names.map(n => caches.delete(n)))
|
||||
const reg = 'serviceWorker' in navigator ? await navigator.serviceWorker.getRegistration() : undefined
|
||||
if (reg) {
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => window.location.reload(), { once: true })
|
||||
await reg.update()
|
||||
return
|
||||
}
|
||||
if ('serviceWorker' in navigator) {
|
||||
const regs = await navigator.serviceWorker.getRegistrations()
|
||||
await Promise.all(regs.map(r => r.unregister()))
|
||||
}
|
||||
} catch {}
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
} catch { /* fall through to a plain reload */ }
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
localStorage.setItem('trek_app_version', config.version)
|
||||
}
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
@@ -380,13 +403,22 @@ export default function App() {
|
||||
|| location.pathname.startsWith('/register')
|
||||
|| location.pathname.startsWith('/forgot-password')
|
||||
|| location.pathname.startsWith('/reset-password')
|
||||
// No session on these, so authenticated-only widgets (system notices,
|
||||
// background tasks, save-to-collection) have nothing to do and would only fire
|
||||
// a doomed authenticated request on mount.
|
||||
//
|
||||
// Off isAuthPublicPath rather than a second hand-kept list: the two had already
|
||||
// drifted, this one naming only /public/journey/ while the response
|
||||
// interceptor's covers all of /public/. A route that is public to one and not
|
||||
// to the other is exactly the seam that puts a 401 back.
|
||||
const hideAuthedWidgets = isAuthPage || isAuthPublicPath(location.pathname)
|
||||
|
||||
return (
|
||||
<TranslationProvider>
|
||||
{!isAuthPage && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:system-notice" fallback={null}><SystemNoticeHost /></ErrorBoundary>}
|
||||
<ErrorBoundary boundaryId="widget:toast" fallback={null}><ToastContainer /></ErrorBoundary>
|
||||
{!isAuthPage && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||
{!isAuthPage && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
{!hideAuthedWidgets && <ErrorBoundary boundaryId="widget:background-tasks" fallback={null}><BackgroundTasksWidget /></ErrorBoundary>}
|
||||
{!hideAuthedWidgets && (isPhone ? <MSaveToCollectionSheet /> : <SaveToCollectionModal />)}
|
||||
<ErrorBoundary boundaryId="widget:offline-banner" fallback={null}><OfflineBanner /></ErrorBoundary>
|
||||
{/* One boundary for all route chunks, above <Routes> so it stays mounted
|
||||
across navigations. react-router runs location updates inside a transition,
|
||||
|
||||
@@ -230,7 +230,8 @@ describe('client > proxy auth challenges', () => {
|
||||
return getRegistration
|
||||
}
|
||||
|
||||
it('FE-APIWIRE-011: an HTML 401 unregisters the service worker and reloads', async () => {
|
||||
it('FE-APIWIRE-011: an HTML 401 behind a confirmed proxy wall unregisters the service worker and reloads', async () => {
|
||||
probeNow.mockResolvedValue('proxy-wall')
|
||||
const unregister = vi.fn(async () => true)
|
||||
installServiceWorker(unregister)
|
||||
server.use(http.get('/api/auth/me', () =>
|
||||
@@ -243,6 +244,25 @@ describe('client > proxy auth challenges', () => {
|
||||
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBe('1')
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-016: an HTML 401 from TREK itself keeps the service worker (#2228)', async () => {
|
||||
// text/html is not proof of a proxy: several of TREK's own routes answer
|
||||
// res.status(401).send('Authentication required'), which Express labels
|
||||
// text/html. Tearing the worker down for one of those costs the user
|
||||
// offline mode for a wall that is not there, so confirm reachability first.
|
||||
probeNow.mockResolvedValue('online')
|
||||
const unregister = vi.fn(async () => true)
|
||||
installServiceWorker(unregister)
|
||||
server.use(http.get('/api/auth/me', () =>
|
||||
new HttpResponse('Authentication required', { status: 401, headers: { 'Content-Type': 'text/html' } })))
|
||||
|
||||
await captureError(() => apiClient.get('/auth/me'))
|
||||
|
||||
expect(probeNow).toHaveBeenCalled()
|
||||
expect(unregister).not.toHaveBeenCalled()
|
||||
expect(reload).not.toHaveBeenCalled()
|
||||
expect(sessionStorage.getItem('proxy_reauth_attempted')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-012: the reauth reload only fires once per session', async () => {
|
||||
installServiceWorker(vi.fn(async () => true))
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
|
||||
+219
-12
@@ -1,6 +1,8 @@
|
||||
import { isEffectivelyOffline } from '../sync/networkMode'
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { Place } from '../types'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
import { randomId } from '../utils/randomId'
|
||||
import {
|
||||
weatherResultSchema, type WeatherResult,
|
||||
@@ -59,6 +61,12 @@ import {
|
||||
type PluginActionDescriptor,
|
||||
type PluginActionResult,
|
||||
type PluginInstallRequest,
|
||||
RoadtripDayTrack,
|
||||
RoadtripVia,
|
||||
RoadtripViaBatchRequest,
|
||||
RoadtripViaCreateRequest,
|
||||
RoadtripViaReanchorRequest,
|
||||
RoadtripViaUpdateRequest,
|
||||
} from '@trek/shared'
|
||||
import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
@@ -214,16 +222,24 @@ apiClient.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
// Pangolin header-auth extended compatibility mode: returns 401 with an
|
||||
// HTML body (a JS redirect page) instead of a 302. TREK's own 401s are
|
||||
// always application/json, so checking for text/html is unambiguous.
|
||||
// HTML body (a JS redirect page) instead of a 302.
|
||||
//
|
||||
// A text/html 401 is NOT unambiguous on its own: several of TREK's own
|
||||
// routes answer with res.status(401).send('Authentication required'),
|
||||
// which Express labels text/html. Tearing the service worker down for one
|
||||
// of those would cost the user offline mode for a proxy wall that isn't
|
||||
// there, so confirm a reachable proxy first, exactly like the no-response
|
||||
// branch above (#2228).
|
||||
if (error.response?.status === 401) {
|
||||
const ct = (error.response.headers?.['content-type'] as string | undefined) ?? ''
|
||||
if (ct.includes('text/html')) {
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname) && !sessionStorage.getItem('proxy_reauth_attempted')) {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
if (await probeNow() === 'proxy-wall') {
|
||||
sessionStorage.setItem('proxy_reauth_attempted', '1')
|
||||
await unregisterSWAndReload()
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,7 +440,10 @@ export const daysApi = {
|
||||
|
||||
export const placesApi = {
|
||||
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
|
||||
create: (tripId: number | string, data: PlaceCreateRequest) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||
// Typed: an untyped `r.data` is what let `{ place }` be read as a bare place,
|
||||
// so every hotel booking minted an unlinked duplicate (#2243).
|
||||
create: (tripId: number | string, data: PlaceCreateRequest): Promise<{ place: Place }> =>
|
||||
apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
update: (tripId: number | string, id: number | string, data: PlaceUpdateRequest) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
@@ -453,8 +472,15 @@ export const placesApi = {
|
||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||
return postMultipart(`/trips/${tripId}/places/import/map`, fd)
|
||||
},
|
||||
// A longer timeout than the shared 8 s, like the other routes here that wait
|
||||
// on somebody else's service. A directions link whose stops are only named
|
||||
// has to be geocoded one at a time behind a 1.1 s throttle, so a route with
|
||||
// eight stops needs about ten seconds. Giving up at eight left the server
|
||||
// finishing the import and writing the places while the browser reported a
|
||||
// failure, and a retry then spent the whole geocoding budget again only to
|
||||
// have the dedupe skip every stop.
|
||||
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest, { timeout: 60000 }).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
@@ -635,12 +661,18 @@ export const adminApi = {
|
||||
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||
getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data),
|
||||
updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data),
|
||||
getTrekPlaces: () => apiClient.get('/admin/trek-places').then(r => r.data),
|
||||
updateTrekPlaces: (enabled: boolean) => apiClient.put('/admin/trek-places', { enabled }).then(r => r.data),
|
||||
getPlaceShadow: () => apiClient.get('/admin/place-shadow').then(r => r.data),
|
||||
updatePlaceShadow: (enabled: boolean) => apiClient.put('/admin/place-shadow', { enabled }).then(r => r.data),
|
||||
getPlacesAutocomplete: () => apiClient.get('/admin/places-autocomplete').then(r => r.data),
|
||||
updatePlacesAutocomplete: (enabled: boolean) => apiClient.put('/admin/places-autocomplete', { enabled }).then(r => r.data),
|
||||
getPlacesDetails: () => apiClient.get('/admin/places-details').then(r => r.data),
|
||||
updatePlacesDetails: (enabled: boolean) => apiClient.put('/admin/places-details', { enabled }).then(r => r.data),
|
||||
getPlacesEnrich: () => apiClient.get('/admin/places-enrich').then(r => r.data),
|
||||
updatePlacesEnrich: (enabled: boolean) => apiClient.put('/admin/places-enrich', { enabled }).then(r => r.data),
|
||||
getTransitProvider: () => apiClient.get('/admin/transit-provider').then(r => r.data),
|
||||
updateTransitProvider: (provider: TransitProvider) => apiClient.put('/admin/transit-provider', { provider }).then(r => r.data),
|
||||
getCollabFeatures: () => apiClient.get('/admin/collab-features').then(r => r.data),
|
||||
updateCollabFeatures: (features: Record<string, boolean>) => apiClient.put('/admin/collab-features', features).then(r => r.data),
|
||||
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||
@@ -1022,11 +1054,134 @@ export const memoriesApi = {
|
||||
}).then(r => r.data),
|
||||
}
|
||||
|
||||
/**
|
||||
* Place search that still answers with no network.
|
||||
*
|
||||
* The cache is what `sync/placePrefetcher` stored for the trip's area, and this
|
||||
* is the one place it is read from, so all ten call sites got the offline path
|
||||
* without any of them learning about it. `source` says 'offline-cache' rather
|
||||
* than pretending to be the index — several screens show it, and a stale answer
|
||||
* that claims to be live is worse than one that says what it is.
|
||||
*
|
||||
* Only network-level failures fall through. A 4xx or 5xx means the server did
|
||||
* answer and the caller has to see it.
|
||||
*/
|
||||
/**
|
||||
* The same offline treatment as `withCachedPlaces`, for the single-place lookup.
|
||||
*
|
||||
* Separate because the shape is different: this one is keyed by id rather than
|
||||
* by a query, and a miss has to stay a miss — a place the cache does not hold
|
||||
* must fail the way it did before, not answer with somebody else's record.
|
||||
*/
|
||||
/**
|
||||
* The rejection both search surfaces already know to swallow.
|
||||
*
|
||||
* They filter on the axios cancel code and on the DOMException name, so an
|
||||
* abort from the cache path has to look like one or it surfaces as a toast.
|
||||
*/
|
||||
function abortedError(): Error & { code: string } {
|
||||
const err = new Error('canceled') as Error & { code: string }
|
||||
err.name = 'CanceledError'
|
||||
err.code = 'ERR_CANCELED'
|
||||
return err
|
||||
}
|
||||
|
||||
async function withCachedPlace(
|
||||
placeId: string,
|
||||
online: () => Promise<{ place: Record<string, unknown> | null }>,
|
||||
): Promise<{ place: Record<string, unknown> | null }> {
|
||||
const fromCache = async (): Promise<{ place: Record<string, unknown> | null } | null> => {
|
||||
const { getCachedPlace, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
|
||||
const hit = await getCachedPlace(placeId)
|
||||
return hit ? { place: cachedToPlaceRecord(hit) } : null
|
||||
}
|
||||
|
||||
if (isEffectivelyOffline()) {
|
||||
const cached = await fromCache()
|
||||
if (cached) return cached
|
||||
return online()
|
||||
}
|
||||
try {
|
||||
return await online()
|
||||
} catch (err) {
|
||||
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
|
||||
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
|
||||
if (!neverArrived) throw err
|
||||
const cached = await fromCache()
|
||||
if (!cached) throw err
|
||||
return cached
|
||||
}
|
||||
}
|
||||
|
||||
async function withCachedPlaces<T>(
|
||||
query: string,
|
||||
shape: (places: Record<string, unknown>[]) => T,
|
||||
online: () => Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const fromCache = async (): Promise<Record<string, unknown>[]> => {
|
||||
const { searchCachedPlaces, cachedToPlaceRecord } = await import('../sync/placePrefetcher')
|
||||
const found = (await searchCachedPlaces(query)).map(cachedToPlaceRecord)
|
||||
// The keystroke this answers may already be two keystrokes old. Both search
|
||||
// surfaces order their suggestions by aborting the previous request and
|
||||
// discarding the rejection — there is no request counter — so an offline
|
||||
// answer that resolves regardless of the signal can overwrite a newer list.
|
||||
// The two cache reads are also very unequal: a prefix hit comes off the
|
||||
// index, the substring pass walks every cached place of every trip.
|
||||
if (signal?.aborted) throw abortedError()
|
||||
return found
|
||||
}
|
||||
|
||||
if (isEffectivelyOffline()) return shape(await fromCache())
|
||||
try {
|
||||
return await online()
|
||||
} catch (err) {
|
||||
const e = err as { isAxiosError?: boolean; response?: unknown; code?: string } | null
|
||||
const neverArrived = !!e && e.isAxiosError === true && e.response == null && e.code !== 'ERR_CANCELED'
|
||||
if (!neverArrived) throw err
|
||||
const cached = await fromCache()
|
||||
if (!cached.length) throw err
|
||||
return shape(cached)
|
||||
}
|
||||
}
|
||||
|
||||
export const mapsApi = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
/**
|
||||
* `locationBias` is what tells the search which "Hase-dera" is meant, and it
|
||||
* is the difference between finding the temple the user stands next to and
|
||||
* one 400km away. The route has always accepted it; nothing passed it.
|
||||
*
|
||||
* It also decides whether the index answers at all: a common single word
|
||||
* without coordinates is refused upstream as too expensive, and the search
|
||||
* then falls back to Nominatim alone.
|
||||
*/
|
||||
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
|
||||
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), () =>
|
||||
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search'))),
|
||||
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal, sessionToken?: string) =>
|
||||
apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
details: (placeId: string, lang?: string, sessionToken?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } }).then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details')),
|
||||
withCachedPlaces(
|
||||
input,
|
||||
(places) => ({
|
||||
suggestions: places.map((p) => ({
|
||||
placeId: String(p.osm_id),
|
||||
mainText: String(p.name),
|
||||
secondaryText: String(p.address || ''),
|
||||
})),
|
||||
source: 'offline-cache',
|
||||
}),
|
||||
() => apiClient.post('/maps/autocomplete', { input, lang, locationBias, sessionToken }, { signal }).then(r => checkInDev(mapsAutocompleteResultSchema, r.data, 'maps.autocomplete')),
|
||||
signal,
|
||||
),
|
||||
// Answered from the cache when the network is not there, for the ids the
|
||||
// offline suggestion list hands out. Without it, picking an offline
|
||||
// suggestion failed here and the callers fell back to searching for
|
||||
// "name, address" — which the cache matches on the folded NAME alone, so it
|
||||
// found nothing and the user got an error toast for a place that was sitting
|
||||
// in the cache all along.
|
||||
details: (placeId: string, lang?: string, sessionToken?: string) =>
|
||||
withCachedPlace(placeId, () =>
|
||||
apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang, sessionToken } })
|
||||
.then(r => checkInDev(mapsPlaceDetailsResultSchema, r.data, 'maps.details'))),
|
||||
// Pictures and a description for a place that is being looked at but not yet
|
||||
// saved. Fans out to several providers server-side, so it takes a signal and
|
||||
// the caller is expected to abort it when the selection changes, and a longer
|
||||
@@ -1046,10 +1201,60 @@ export const mapsApi = {
|
||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
||||
// timeout than the global default instead of aborting at 8s and showing nothing.
|
||||
/**
|
||||
* Every place in a box, for the offline cache. One call per trip area, not
|
||||
* per keystroke, so the timeout is generous where the search ones are short.
|
||||
*/
|
||||
area: (
|
||||
bbox: { minLat: number; minLng: number; maxLat: number; maxLng: number },
|
||||
limit?: number,
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
apiClient
|
||||
.get('/maps/area', { params: { ...bbox, limit }, signal, timeout: 30000 })
|
||||
.then(
|
||||
(r) =>
|
||||
r.data as {
|
||||
results: Record<string, unknown>[]
|
||||
truncated: boolean
|
||||
unavailable?: boolean
|
||||
},
|
||||
),
|
||||
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, lang?: string, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox, lang }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
||||
}
|
||||
|
||||
/**
|
||||
* Road-trip via points (#1797): the places a day's drive is routed through without
|
||||
* stopping. Separate from places on purpose — a via bends the route, a stop is somewhere
|
||||
* you go.
|
||||
*/
|
||||
export const roadtripApi = {
|
||||
/** Every via of the trip, so all days can be routed without a request per day. */
|
||||
listVias: (tripId: number | string) =>
|
||||
apiClient.get(`/trips/${tripId}/roadtrip/vias`).then(r => r.data as { vias: RoadtripVia[]; tracks: RoadtripDayTrack[] }),
|
||||
addVia: (tripId: number | string, dayId: number | string, body: RoadtripViaCreateRequest) =>
|
||||
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { via: RoadtripVia }),
|
||||
/**
|
||||
* Lay a chain of vias on one day in one write. Anything that derives its anchors from a
|
||||
* line produces dozens of them, and one request each would re-route the whole trip once
|
||||
* per point at better than a second apart.
|
||||
*/
|
||||
addVias: (tripId: number | string, dayId: number | string, body: RoadtripViaBatchRequest) =>
|
||||
apiClient.post(`/trips/${tripId}/roadtrip/days/${dayId}/vias/batch`, body).then(r => r.data as { vias: RoadtripVia[] }),
|
||||
/**
|
||||
* Re-pin a day's vias in one write, after its stops changed shape. One request, not one
|
||||
* per via: the anchors are only correct as a set.
|
||||
*/
|
||||
reanchorVias: (tripId: number | string, dayId: number | string, body: RoadtripViaReanchorRequest) =>
|
||||
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias`, body).then(r => r.data as { vias: RoadtripVia[] }),
|
||||
moveVia: (tripId: number | string, dayId: number | string, id: number, body: RoadtripViaUpdateRequest) =>
|
||||
apiClient.put(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`, body).then(r => r.data as { via: RoadtripVia }),
|
||||
removeVia: (tripId: number | string, dayId: number | string, id: number) =>
|
||||
apiClient.delete(`/trips/${tripId}/roadtrip/days/${dayId}/vias/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
search: (q: string, signal?: AbortSignal) => apiClient.get('/airports/search', { params: { q }, signal }).then(r => r.data),
|
||||
byIata: (iata: string) => apiClient.get(`/airports/${encodeURIComponent(iata)}`).then(r => r.data),
|
||||
@@ -1219,11 +1424,13 @@ export const shareApi = {
|
||||
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
|
||||
}
|
||||
|
||||
// Public transit routing (#1065) — Transitous/MOTIS proxied through the server.
|
||||
// Public transit routing (#1065) — proxied through the server, which picks the
|
||||
// backend (Transitous/MOTIS, or Google since #1699). `lang` reaches the Google
|
||||
// backend as the languageCode, so station names come back in the user's script.
|
||||
export const transitApi = {
|
||||
geocode: (q: string, opts?: { lang?: string; near?: string }) =>
|
||||
apiClient.get('/transit/geocode', { params: { q, lang: opts?.lang, near: opts?.near } }).then(r => r.data),
|
||||
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number }) =>
|
||||
plan: (params: { from: string; to: string; time?: string; arriveBy?: boolean; modes?: string; maxTransfers?: number; lang?: string }) =>
|
||||
apiClient.get('/transit/plan', { params }).then(r => r.data),
|
||||
}
|
||||
|
||||
|
||||
@@ -59,23 +59,26 @@ describe('collectionsApi', () => {
|
||||
expect(res.collection.name).toBe('Tokyo')
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-003: create() posts the create payload', async () => {
|
||||
server.use(http.post(BASE, record({ collection })))
|
||||
// Both handlers answer with the BARE collection, the way the controller does —
|
||||
// stubbing a { collection } envelope here is what let the client keep reading
|
||||
// `.collection` off a response that never had one.
|
||||
it('FE-API-COLLECTIONS-003: create() posts the create payload and returns the list itself', async () => {
|
||||
server.use(http.post(BASE, record(collection)))
|
||||
|
||||
const res = await collectionsApi.create({ name: 'Tokyo', color: '#111827' })
|
||||
|
||||
expect(requestBody).toEqual({ name: 'Tokyo', color: '#111827' })
|
||||
expect(res.collection.id).toBe(1)
|
||||
expect(res.id).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-004: update() patches the list by id', async () => {
|
||||
server.use(http.patch(`${BASE}/:id`, record({ collection })))
|
||||
server.use(http.patch(`${BASE}/:id`, record(collection)))
|
||||
|
||||
const res = await collectionsApi.update(1, { name: 'Tokyo 2026' })
|
||||
|
||||
expect(requestUrl).toContain(`${BASE}/1`)
|
||||
expect(requestBody).toEqual({ name: 'Tokyo 2026' })
|
||||
expect(res.collection).toEqual(collection)
|
||||
expect(res).toEqual(collection)
|
||||
})
|
||||
|
||||
it('FE-API-COLLECTIONS-005: uploadCover() posts multipart to the cover endpoint', async () => {
|
||||
|
||||
@@ -53,9 +53,13 @@ export const collectionsApi = {
|
||||
ax.get(base).then((r: AxiosResponse) => r.data),
|
||||
get: (id: number): Promise<CollectionDetailResponse> =>
|
||||
ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data),
|
||||
create: (body: CollectionCreateRequest): Promise<{ collection: Collection }> =>
|
||||
// Both answer with the bare collection, not a { collection } envelope — the
|
||||
// controller returns the service's `Collection` straight through, and the e2e
|
||||
// suite pins that shape. Declaring the envelope made createCollection() resolve
|
||||
// undefined for every caller.
|
||||
create: (body: CollectionCreateRequest): Promise<Collection> =>
|
||||
ax.post(base, body satisfies CollectionCreateRequest).then((r: AxiosResponse) => r.data),
|
||||
update: (id: number, body: CollectionUpdateRequest): Promise<{ collection: Collection }> =>
|
||||
update: (id: number, body: CollectionUpdateRequest): Promise<Collection> =>
|
||||
ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data),
|
||||
uploadCover: (id: number, formData: FormData): Promise<Collection> =>
|
||||
postMultipart(`${base}/${id}/cover`, formData),
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../tests/helpers/msw/server'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { recordPlacePick } from './placeShadow'
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The point of this module is that it never gets in the way: it must not fire
|
||||
* when the instance has the log switched off, must not throw when the request
|
||||
* fails, and must not send coordinates finer than the ranking needs.
|
||||
*/
|
||||
|
||||
const PICK: PlaceShadowPickRequest = {
|
||||
query: ' losteria rostock ',
|
||||
lang: 'de',
|
||||
biasLat: 54.088712,
|
||||
biasLng: 12.140493,
|
||||
source: 'search:nominatim',
|
||||
liveRank: 0,
|
||||
liveCount: 8,
|
||||
pickedName: "L'Osteria",
|
||||
pickedLat: 54.0891234,
|
||||
pickedLng: 12.1372987,
|
||||
pickedPlaceId: 'osm:node/1',
|
||||
}
|
||||
|
||||
/** Resolves with the body the module posted, or null if it never posted. */
|
||||
function capture(status = 200) {
|
||||
let seen: unknown = null
|
||||
let resolve: (v: unknown) => void = () => {}
|
||||
const posted = new Promise<unknown>(r => { resolve = r })
|
||||
server.use(
|
||||
http.post('/api/place-shadow/pick', async ({ request }) => {
|
||||
seen = await request.json()
|
||||
resolve(seen)
|
||||
return status === 200
|
||||
? HttpResponse.json({ recorded: true })
|
||||
: HttpResponse.json({ error: 'nope' }, { status })
|
||||
}),
|
||||
)
|
||||
return { posted, seen: () => seen }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState({ placeShadowEnabled: true })
|
||||
})
|
||||
|
||||
describe('recordPlacePick', () => {
|
||||
it('sends nothing at all when the instance has the log switched off', async () => {
|
||||
useAuthStore.setState({ placeShadowEnabled: false })
|
||||
const cap = capture()
|
||||
recordPlacePick(PICK)
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(cap.seen()).toBeNull()
|
||||
})
|
||||
|
||||
it('trims the query and rounds every coordinate to three decimals', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick(PICK)
|
||||
expect(await cap.posted).toMatchObject({
|
||||
query: 'losteria rostock',
|
||||
pickedLat: 54.089,
|
||||
pickedLng: 12.137,
|
||||
biasLat: 54.089,
|
||||
biasLng: 12.14,
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves an absent bias absent rather than sending a zero', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick({ ...PICK, biasLat: undefined, biasLng: undefined })
|
||||
const body = (await cap.posted) as Record<string, unknown>
|
||||
expect(body.biasLat).toBeUndefined()
|
||||
expect(body.biasLng).toBeUndefined()
|
||||
})
|
||||
|
||||
it('drops a pick with no query, no name or an impossible rank before sending', async () => {
|
||||
const cap = capture()
|
||||
recordPlacePick({ ...PICK, query: ' ' })
|
||||
recordPlacePick({ ...PICK, pickedName: '' })
|
||||
recordPlacePick({ ...PICK, liveRank: 8, liveCount: 8 })
|
||||
recordPlacePick({ ...PICK, pickedLat: Number.NaN })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
expect(cap.seen()).toBeNull()
|
||||
})
|
||||
|
||||
it('does not record a pick made out of the offline cache', async () => {
|
||||
// Such a row describes the local cache's ordering, not a provider's, and
|
||||
// the two figures this corpus exists for — how often the live answer was
|
||||
// first, and how often it was in the top five — are averaged over every
|
||||
// row. Mixing these in moves exactly the number a candidate index is later
|
||||
// judged against. Not merely an offline concern either: offline is a
|
||||
// setting, so the network is usually reachable and the POST would land.
|
||||
let posted = false
|
||||
server.use(http.post('/api/place-shadow/pick', () => { posted = true; return HttpResponse.json({ recorded: true }) }))
|
||||
|
||||
recordPlacePick({ ...PICK, source: 'search:offline-cache' })
|
||||
recordPlacePick({ ...PICK, source: 'autocomplete:offline-cache' })
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
|
||||
expect(posted).toBe(false)
|
||||
})
|
||||
|
||||
it('swallows a server error instead of surfacing it to the pick that triggered it', async () => {
|
||||
const cap = capture(500)
|
||||
const onUnhandled = vi.fn()
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
expect(() => recordPlacePick(PICK)).not.toThrow()
|
||||
await cap.posted
|
||||
await new Promise(r => setTimeout(r, 20))
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
expect(onUnhandled).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { PlaceShadowPickRequest } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
|
||||
/**
|
||||
* Records which search result the user picked, for the local shadow corpus.
|
||||
*
|
||||
* Fire and forget in the strongest sense: it never throws, never blocks the
|
||||
* pick it is describing, and does nothing at all unless the instance switched
|
||||
* the log on. Adding a place must not get slower or more fragile because
|
||||
* somebody is collecting a measurement.
|
||||
*
|
||||
* The store flag is checked first so a switched-off instance makes no request
|
||||
* at all; the server checks it again, because a client is not a permission.
|
||||
*/
|
||||
|
||||
/** About 100 m. Enough to reproduce a ranking, too coarse to place someone. */
|
||||
function coarse(value: number): number {
|
||||
return Math.round(value * 1000) / 1000
|
||||
}
|
||||
|
||||
export function recordPlacePick(pick: PlaceShadowPickRequest): void {
|
||||
if (!useAuthStore.getState().placeShadowEnabled) return
|
||||
if (!Number.isFinite(pick.pickedLat) || !Number.isFinite(pick.pickedLng)) return
|
||||
if (pick.liveRank >= pick.liveCount) return
|
||||
// A pick out of the offline cache describes the cache's ordering, not a
|
||||
// provider's, and the two numbers this corpus exists for — how often the live
|
||||
// answer was first, and how often it was in the top five — are averaged over
|
||||
// every row. Mixing local rows in would move exactly the figure a candidate
|
||||
// index is later judged against.
|
||||
//
|
||||
// Not merely an offline concern: the offline switch is a setting, so the
|
||||
// network is usually reachable and the POST would go through.
|
||||
if (pick.source.endsWith('offline-cache')) return
|
||||
|
||||
const body: PlaceShadowPickRequest = {
|
||||
...pick,
|
||||
query: pick.query.trim().slice(0, 200),
|
||||
pickedName: pick.pickedName.slice(0, 300),
|
||||
pickedLat: coarse(pick.pickedLat),
|
||||
pickedLng: coarse(pick.pickedLng),
|
||||
biasLat: pick.biasLat === undefined ? undefined : coarse(pick.biasLat),
|
||||
biasLng: pick.biasLng === undefined ? undefined : coarse(pick.biasLng),
|
||||
}
|
||||
// The contract requires both; an unnamed result is not worth a row anyway.
|
||||
if (!body.query || !body.pickedName) return
|
||||
|
||||
void apiClient.post('/place-shadow/pick', body).catch(() => {
|
||||
// Deliberately silent. The user was adding a place, not running a study.
|
||||
})
|
||||
}
|
||||
@@ -2459,3 +2459,85 @@ describe('AdminPluginsPanel — instance settings', () => {
|
||||
expect(save.className).not.toContain('text-white')
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* TREK_PLUGINS_IGNORE_TREK_RANGE. The server turns its version gates into warnings; the
|
||||
* panel's job is to make sure the admin actually SEES the warning — before a registry
|
||||
* install, after a sideload/link that could not be asked first, and on the row for as
|
||||
* long as the plugin keeps running outside the range its author vouched for.
|
||||
*/
|
||||
describe('AdminPluginsPanel — TREK-range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
|
||||
function bypassPanel(plugins: Record<string, unknown>[], entry: Record<string, unknown> | null) {
|
||||
server.use(
|
||||
http.get('*/api/admin/plugins', () => HttpResponse.json({ enabled: true, devLink: false, ignoreTrekRange: true, plugins })),
|
||||
http.get('*/api/admin/plugins/registry', () => HttpResponse.json(entry ? [entry] : [])),
|
||||
)
|
||||
}
|
||||
const incompatibleEntry = () => registryEntry({ trek: '>=4.0.0', hostVersion: '3.3.0', compatible: false, latestCompatible: null })
|
||||
|
||||
it('FE-PLG-BYPASS-001: says so in the header', async () => {
|
||||
bypassPanel([plugin()], null)
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText('Version checks off')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLG-BYPASS-002: offers "Install anyway" for an incompatible entry and asks before installing', async () => {
|
||||
let posted: unknown = null
|
||||
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
|
||||
server.use(http.post('*/api/admin/plugins/install', async ({ request }) => {
|
||||
posted = await request.json()
|
||||
return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: { trekRange: '>=4.0.0', hostVersion: '3.3.0' } })
|
||||
}))
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
|
||||
const btn = await screen.findByRole('button', { name: /^install anyway$/i })
|
||||
expect(btn).toBeEnabled()
|
||||
fireEvent.click(btn)
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
|
||||
expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument()
|
||||
expect(within(dialog).getByText(/corrupt trek data/i)).toBeInTheDocument()
|
||||
expect(posted).toBeNull() // nothing sent until the admin accepts the risk
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /^install anyway$/i }))
|
||||
await waitFor(() => expect(posted).toEqual({ id: 'trek-gotify' }))
|
||||
})
|
||||
|
||||
it('FE-PLG-BYPASS-003: cancelling the warning installs nothing', async () => {
|
||||
let posted = false
|
||||
bypassPanel([plugin({ id: 'something-else' })], incompatibleEntry())
|
||||
server.use(http.post('*/api/admin/plugins/install', () => { posted = true; return HttpResponse.json({ id: 'trek-gotify', version: '2.0.0', trekRangeBypassed: null }) }))
|
||||
withToast()
|
||||
fireEvent.click(await screen.findByText('Discover'))
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^install anyway$/i }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /outside its supported trek versions/i })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /cancel/i }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog', { name: /outside its supported trek versions/i })).not.toBeInTheDocument())
|
||||
expect(posted).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-PLG-BYPASS-004: a sideload that landed outside its range is warned about afterwards', async () => {
|
||||
bypassPanel([plugin()], null)
|
||||
server.use(http.post('*/api/admin/plugins/upload', () =>
|
||||
HttpResponse.json({ id: 'trek-new', version: '1.0.0', replaced: false, trekRangeBypassed: { trekRange: null, hostVersion: '3.3.0' } })))
|
||||
const { container } = withToast()
|
||||
await screen.findByText('Gotify')
|
||||
|
||||
await userEvent.upload(container.querySelector('input[type="file"]') as HTMLInputElement, new File(['zip'], 'plugin.zip', { type: 'application/zip' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: /installed outside its supported trek versions/i })
|
||||
expect(within(dialog).getByText(/no guarantee/i)).toBeInTheDocument()
|
||||
expect(within(dialog).getByText(/trek-new/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLG-BYPASS-005: a plugin running outside its range keeps a warning chip on its row', async () => {
|
||||
bypassPanel([plugin({
|
||||
dependencyStatus: 'ok', trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0',
|
||||
trekRangeBypassed: { trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' },
|
||||
})], null)
|
||||
render(<AdminPluginsPanel />)
|
||||
expect(await screen.findByText(/outside its trek range \(>=3\.2\.0 <4\.0\.0\)/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import PluginIcon from '../shared/PluginIcon'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useInstanceSettings } from './useInstanceSettings'
|
||||
import { bypassChip, bypassOffer, useRangeBypass, type RangeWarning, type TrekRangeBypass } from './useRangeBypass'
|
||||
import { usePluginStore } from '../../store/pluginStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -64,6 +65,8 @@ interface PluginRow {
|
||||
trekRange?: string | null
|
||||
/** The TREK this server is running — the server does the semver, the client just shows it. */
|
||||
hostVersion?: string
|
||||
/** Non-null while the plugin runs outside its declared range on the operator's say-so. */
|
||||
trekRangeBypassed?: TrekRangeBypass | null
|
||||
/** The author's signature was verified and their key pinned at install. False means the
|
||||
* bytes matched the registry's sha256 and nothing more — one fewer guarantee. */
|
||||
signed?: boolean
|
||||
@@ -273,7 +276,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
return out
|
||||
}
|
||||
|
||||
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean }
|
||||
interface DepChip { icon: React.ComponentType<{ size?: number; className?: string }>; label: string; blocked: boolean; warn?: boolean }
|
||||
|
||||
// A plugin's declared dependencies as chips — a required addon (amber when that
|
||||
// addon is disabled), a plugin dependency (amber when missing / version-mismatched),
|
||||
@@ -291,6 +294,8 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
|
||||
blocked: true,
|
||||
})
|
||||
}
|
||||
const bypassed = bypassChip(p.trekRangeBypassed, t) // TREK_PLUGINS_IGNORE_TREK_RANGE: a warning, not a blocker
|
||||
if (bypassed) out.push(bypassed)
|
||||
for (const a of p.dependencies?.requiredAddons ?? []) {
|
||||
out.push({ icon: Blocks, label: t('admin.plugins.cap.requiresAddon', { addon: a }), blocked: !!issues?.disabledAddons.includes(a) })
|
||||
}
|
||||
@@ -310,11 +315,12 @@ function deriveDeps(p: PluginRow, t: T): DepChip[] {
|
||||
* has outrun this TREK but an older one still fits, offer THAT version rather than a dead
|
||||
* grey button. The plugin is perfectly usable, just not at its newest.
|
||||
*/
|
||||
function installOffer(item: RegistryItem, t: T): { blocked: boolean; version?: string; label: string; title?: string } {
|
||||
function installOffer(item: RegistryItem, t: T, ignoreTrekRange = false): { blocked: boolean; version?: string; label: string; title?: string; warn?: RangeWarning } {
|
||||
if (item.compatible !== false) return { blocked: false, label: t('admin.plugins.install') }
|
||||
const title = item.trek
|
||||
? t('admin.plugins.dep.trekIncompatible', { range: item.trek, host: item.hostVersion ?? '?' })
|
||||
: t('admin.plugins.dep.trekUnknown')
|
||||
if (ignoreTrekRange) return bypassOffer(item, t, title) // TREK_PLUGINS_IGNORE_TREK_RANGE: "Install anyway"
|
||||
if (item.latestCompatible) {
|
||||
return { blocked: false, version: item.latestCompatible, label: t('admin.plugins.installCompatible', { version: item.latestCompatible }), title }
|
||||
}
|
||||
@@ -398,6 +404,8 @@ export default function AdminPluginsPanel() {
|
||||
const toast = useToast()
|
||||
const [runtimeOn, setRuntimeOn] = useState(false)
|
||||
const [devLink, setDevLink] = useState(false) // dev-link enabled server-side (TREK_PLUGINS_DEV_LINK)
|
||||
const [ignoreTrekRange, setIgnoreTrekRange] = useState(false) // TREK_PLUGINS_IGNORE_TREK_RANGE set server-side
|
||||
const bypass = useRangeBypass() // its warning dialog/sheet state — shared with the other shell
|
||||
const [linkPath, setLinkPath] = useState('')
|
||||
const [plugins, setPlugins] = useState<PluginRow[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -472,9 +480,10 @@ export default function AdminPluginsPanel() {
|
||||
// (e.g. the dashboard) reflect an activate/deactivate without a full reload (F5).
|
||||
void usePluginStore.getState().loadPlugins()
|
||||
adminApi.plugins()
|
||||
.then((d: { enabled: boolean; devLink?: boolean; plugins: PluginRow[] }) => {
|
||||
.then((d: { enabled: boolean; devLink?: boolean; ignoreTrekRange?: boolean; plugins: PluginRow[] }) => {
|
||||
setRuntimeOn(!!d.enabled)
|
||||
setDevLink(!!d.devLink)
|
||||
setIgnoreTrekRange(!!d.ignoreTrekRange)
|
||||
setPlugins(d.plugins || [])
|
||||
if ((d.plugins || []).length) {
|
||||
adminApi.pluginBrowse().then(indexRegistry).catch(() => {})
|
||||
@@ -540,6 +549,7 @@ export default function AdminPluginsPanel() {
|
||||
const res = await adminApi.pluginUpload(file)
|
||||
setView('installed')
|
||||
toast.success(t('admin.plugins.uploaded', { name: res.id }))
|
||||
bypass.notice(res.id, res.trekRangeBypassed)
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
|
||||
} finally {
|
||||
@@ -610,7 +620,11 @@ export default function AdminPluginsPanel() {
|
||||
const range = reg.trek ?? (reg.minTrekVersion ? `>=${reg.minTrekVersion}` : null)
|
||||
return range ? { version: reg.latest, range } : null
|
||||
}
|
||||
const install = (id: string, version?: string) => act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined), t('admin.plugins.installed'))
|
||||
// `warn` is the pre-install confirm for an entry the registry already flagged as
|
||||
// incompatible; an artifact whose OWN manifest turns out to be out of range (the index
|
||||
// was only a pre-download filter) is caught by the marker on the response instead.
|
||||
const install = (id: string, version?: string, warn?: RangeWarning) => bypass.guard(warn, () =>
|
||||
act(id, () => adminApi.pluginInstall(id, version ? { version } : undefined).then(r => { if (!warn) bypass.notice(id, r?.trekRangeBypassed) }), t('admin.plugins.installed')))
|
||||
const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted'))
|
||||
// Dev-link: register a plugin from a local built directory (dev only). Reuses the
|
||||
// same busy/toast/refresh loop as uploadPlugin; the server gates it.
|
||||
@@ -623,6 +637,7 @@ export default function AdminPluginsPanel() {
|
||||
setView('installed')
|
||||
setLinkPath('')
|
||||
toast.success(t('admin.plugins.devLinkLinked', { id: res.id }))
|
||||
bypass.notice(res.id, res.trekRangeBypassed)
|
||||
} catch (e) {
|
||||
toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))
|
||||
} finally {
|
||||
@@ -682,9 +697,10 @@ export default function AdminPluginsPanel() {
|
||||
if (busy === parent.id) return
|
||||
setBusy(parent.id)
|
||||
adminApi.pluginInstall(depId, { constraint, withDependencies: true })
|
||||
.then((r: { installed?: string[]; requiredAddons?: string[] }) => {
|
||||
.then((r: { installed?: string[]; requiredAddons?: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
|
||||
toast.success(t('admin.plugins.dep.downloaded', { id: depId }))
|
||||
if (r?.requiredAddons?.length) toast.error(t('admin.plugins.dep.addonDisabledToast', { addons: r.requiredAddons.join(', ') }))
|
||||
bypass.notice(depId, r?.trekRangeBypassed)
|
||||
return attemptActivate(parent)
|
||||
})
|
||||
// The DEPENDENCY is what's being downloaded, so a signature refusal here is about the
|
||||
@@ -702,9 +718,10 @@ export default function AdminPluginsPanel() {
|
||||
const runUpdate = (p: PluginRow, version?: string) => {
|
||||
setBusy(p.id); setMenu(null)
|
||||
return adminApi.pluginUpdate(p.id, version)
|
||||
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => {
|
||||
.then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[]; trekRangeBypassed?: TrekRangeBypass | null }) => {
|
||||
if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated'))
|
||||
else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }])
|
||||
bypass.notice(p.name, r.trekRangeBypassed)
|
||||
})
|
||||
.catch(e => {
|
||||
const { error, code } = errBody(e)
|
||||
@@ -828,11 +845,19 @@ export default function AdminPluginsPanel() {
|
||||
<h2 className="text-lg font-semibold tracking-tight text-content">{t('admin.plugins.title')}</h2>
|
||||
<p className="text-xs mt-1 text-content-muted max-w-xl">{t('admin.plugins.subtitle')}</p>
|
||||
</div>
|
||||
{runtimeOn && (
|
||||
<span className="inline-flex items-center gap-2 shrink-0 text-[11px] font-semibold text-success bg-success-soft px-2.5 py-1.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {t('admin.plugins.runtimeOn')}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{runtimeOn && ignoreTrekRange && (
|
||||
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-warning bg-warning-soft px-2.5 py-1.5 rounded-full"
|
||||
title={t('admin.plugins.rangeBypass.pillHint')}>
|
||||
<AlertTriangle size={12} /> {t('admin.plugins.rangeBypass.pill')}
|
||||
</span>
|
||||
)}
|
||||
{runtimeOn && (
|
||||
<span className="inline-flex items-center gap-2 text-[11px] font-semibold text-success bg-success-soft px-2.5 py-1.5 rounded-full">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success" /> {t('admin.plugins.runtimeOn')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -942,7 +967,7 @@ export default function AdminPluginsPanel() {
|
||||
) : error ? (
|
||||
<div className="py-14 text-center text-sm text-danger">{t('admin.plugins.loadError')}</div>
|
||||
) : !runtimeOn ? null : view === 'discover' ? (
|
||||
<RegistryGrid items={shownRegistry} busy={busy} t={t} installedIds={installedIds}
|
||||
<RegistryGrid items={shownRegistry} busy={busy} t={t} installedIds={installedIds} ignoreTrekRange={ignoreTrekRange}
|
||||
onInstall={install} onOpenDetail={setDetailFor} filtered={anyFilter} />
|
||||
) : plugins.length === 0 ? (
|
||||
<EmptyState t={t} onDiscover={openDiscover} />
|
||||
@@ -988,10 +1013,12 @@ export default function AdminPluginsPanel() {
|
||||
|
||||
{/* Registry detail dialog */}
|
||||
{detailFor && (
|
||||
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy}
|
||||
<PluginDetailModal item={detailFor} t={t} locale={locale} busy={busy} ignoreTrekRange={ignoreTrekRange}
|
||||
installed={installedIds.has(detailFor.id)} onInstall={install} onClose={() => setDetailFor(null)} />
|
||||
)}
|
||||
|
||||
{bypass.copy && <RangeBypassDialog copy={bypass.copy} t={t} onConfirm={bypass.confirm} onClose={bypass.dismiss} />}
|
||||
|
||||
{/* Error-log modal */}
|
||||
{errorsFor && (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={() => setErrorsFor(null)}>
|
||||
@@ -1424,8 +1451,8 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
|
||||
<div className="hidden sm:flex items-center gap-1.5 flex-wrap mt-1.5">
|
||||
{deps.map((d, i) => (
|
||||
<span key={i} className={`inline-flex items-center gap-1.5 text-[11px] font-medium px-2 py-[3px] rounded-md border ${
|
||||
d.blocked ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
|
||||
<d.icon size={12} className={d.blocked ? 'text-warning' : 'text-content-muted'} />{d.label}
|
||||
d.blocked || d.warn ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}>
|
||||
<d.icon size={12} className={d.blocked || d.warn ? 'text-warning' : 'text-content-muted'} />{d.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -1535,14 +1562,15 @@ function Screenshot({ url, className, iconSize = 28 }: { url: string | null; cla
|
||||
)
|
||||
}
|
||||
|
||||
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered }: {
|
||||
function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, filtered, ignoreTrekRange }: {
|
||||
items: RegistryItem[] | null
|
||||
onInstall: (id: string, version?: string) => void
|
||||
onInstall: (id: string, version?: string, warn?: RangeWarning) => void
|
||||
onOpenDetail: (item: RegistryItem) => void
|
||||
busy: string | null
|
||||
t: T
|
||||
installedIds: Set<string>
|
||||
filtered: boolean
|
||||
ignoreTrekRange: boolean
|
||||
}) {
|
||||
if (!items) return <div className="py-14 text-center text-sm text-content-faint">{t('common.loading')}</div>
|
||||
if (items.length === 0) return (
|
||||
@@ -1555,7 +1583,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3.5 sm:gap-4 px-4 sm:px-6 pb-5 pt-1">
|
||||
{items.map(item => {
|
||||
const installed = installedIds.has(item.id)
|
||||
const offer = installOffer(item, t)
|
||||
const offer = installOffer(item, t, ignoreTrekRange)
|
||||
return (
|
||||
<div key={item.id} role="button" tabIndex={0} onClick={() => onOpenDetail(item)}
|
||||
// No press-scale on the card: shrinking it mid-click slides the Install
|
||||
@@ -1590,7 +1618,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
|
||||
<Download size={11} /> {formatCompactCount(item.downloadCount)}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version) }}
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onInstall(item.id, offer.version, offer.warn) }}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className="ml-auto text-xs font-semibold px-3.5 py-1.5 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors">
|
||||
@@ -1620,9 +1648,54 @@ function PermLabel({ perm, t }: { perm: string; t: T }) {
|
||||
: <code className="font-mono text-[11px] bg-surface-tertiary px-1.5 py-0.5 rounded">{perm}</code>
|
||||
}
|
||||
|
||||
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale }: {
|
||||
item: RegistryItem; installed: boolean; busy: string | null
|
||||
onInstall: (id: string, version?: string) => void; onClose: () => void; t: T; locale: string
|
||||
/**
|
||||
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning (copy and modes from useRangeBypass). Its own
|
||||
* component rather than ConfirmDialog because the notice mode has no cancel button.
|
||||
*/
|
||||
function RangeBypassDialog({ copy: { title, body, confirm }, onConfirm, onClose, t }: {
|
||||
copy: { title: string; body: string; confirm: boolean }; onConfirm: () => void; onClose: () => void; t: T
|
||||
}) {
|
||||
return createPortal(
|
||||
<div role="presentation" className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
<div role="dialog" aria-modal="true" aria-label={title}
|
||||
className="bg-surface-card border border-edge rounded-2xl w-full max-w-md p-6 shadow-modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="shrink-0 w-10 h-10 rounded-full bg-warning-soft grid place-items-center">
|
||||
<AlertTriangle size={20} className="text-warning" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-content">{title}</h3>
|
||||
<p className="mt-1.5 text-sm text-content-secondary leading-relaxed">{body}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
{confirm ? (
|
||||
<>
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg text-content-secondary border border-edge-secondary transition-colors">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" onClick={onConfirm}
|
||||
className="px-4 py-2 text-sm font-semibold rounded-lg bg-warning text-white hover:opacity-90 transition-opacity">
|
||||
{t('admin.plugins.installAnyway')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-semibold rounded-lg bg-accent text-accent-text hover:bg-accent-hover transition-colors">
|
||||
{t('common.ok')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, locale, ignoreTrekRange }: {
|
||||
item: RegistryItem; installed: boolean; busy: string | null; ignoreTrekRange: boolean
|
||||
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
|
||||
}) {
|
||||
const [detail, setDetail] = useState<RegistryDetail | null>(null)
|
||||
const [failed, setFailed] = useState(false)
|
||||
@@ -1645,7 +1718,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
const sizeKb = detail?.size ? Math.max(1, Math.round(detail.size / 1024)) : null
|
||||
// The detail fetch carries the same compat verdict as the browse list; prefer it once
|
||||
// it lands (it is keyed to the same entry) and fall back to the grid item until then.
|
||||
const offer = installOffer(detail ?? item, t)
|
||||
const offer = installOffer(detail ?? item, t, ignoreTrekRange)
|
||||
|
||||
return (
|
||||
<div role="presentation" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
||||
@@ -1667,7 +1740,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
|
||||
</div>
|
||||
<p className="text-[12.5px] text-content-faint mt-0.5">{item.author}{item.latest ? ` · v${item.latest}` : ''}</p>
|
||||
</div>
|
||||
<button type="button" onClick={() => onInstall(item.id, offer.version)}
|
||||
<button type="button" onClick={() => onInstall(item.id, offer.version, offer.warn)}
|
||||
disabled={busy === item.id || installed || offer.blocked}
|
||||
title={installed ? undefined : offer.title}
|
||||
className="self-end text-[13px] font-semibold px-3 sm:px-4 py-2 rounded-lg bg-accent text-accent-text hover:bg-accent-hover disabled:opacity-50 disabled:bg-surface-tertiary disabled:text-content-faint transition-colors shrink-0">
|
||||
|
||||
@@ -43,6 +43,7 @@ type Defaults = {
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
routing_base_url?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -119,6 +120,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const managed = useAuthStore((s) => s.managed)
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [cartoKey, setCartoKey] = useState('')
|
||||
const [routingBase, setRoutingBase] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -128,6 +130,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
setMapTileUrl(normalizeTileUrl(data.map_tile_url || ''))
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setCartoKey(data.carto_api_key || '')
|
||||
setRoutingBase(data.routing_base_url || '')
|
||||
setMapboxStyle(provider === 'leaflet' ? (data.mapbox_style || '') : styleForProvider(provider, provider === 'maplibre-gl' ? data.maplibre_style : data.mapbox_style))
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
@@ -150,6 +153,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'carto_api_key') setCartoKey('')
|
||||
if (key === 'routing_base_url') setRoutingBase('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -365,6 +369,32 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.cartoKeyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
{/* Instance configuration, not a taste, which is why it sits here rather
|
||||
than in a user's own Map settings. The origin has to appear in the CSP
|
||||
`connect-src` the server emits at boot, and that list is built from
|
||||
this default — a value on a personal settings row is read by the route
|
||||
calculator and then refused by the browser, so the whole app stops
|
||||
routing with no error it could report. The write route refuses one
|
||||
from a non-admin for the same reason. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.routingBase')}
|
||||
<ResetButton field="routing_base_url" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={routingBase}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setRoutingBase(e.target.value)}
|
||||
onBlur={() => save({ routing_base_url: routingBase.trim() })}
|
||||
placeholder="https://osrm.example.org"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.routingBaseHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: '200px', width: '100%', marginTop: 12 }}>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{React.createElement(MapView as any, {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { bypassChip, bypassOffer, rangeWarningCopy, useRangeBypass } from './useRangeBypass'
|
||||
|
||||
const t = (k: string, p?: Record<string, unknown>) => (p ? `${k}:${JSON.stringify(p)}` : k)
|
||||
|
||||
/**
|
||||
* The ONE logic path behind the TREK_PLUGINS_IGNORE_TREK_RANGE warning in both admin
|
||||
* shells — the desktop panel and the phone panel render their own markup over this.
|
||||
*/
|
||||
describe('rangeBypass helpers', () => {
|
||||
it('bypassChip: a warning chip (not a blocker) only for a bypassed row', () => {
|
||||
expect(bypassChip(null, t)).toBeNull()
|
||||
expect(bypassChip(undefined, t)).toBeNull()
|
||||
expect(bypassChip({ trekRange: '>=3.2.0 <4.0.0', hostVersion: '4.0.0' }, t)).toMatchObject({
|
||||
blocked: false, warn: true, label: 'admin.plugins.dep.trekBypassed:{"range":">=3.2.0 <4.0.0","host":"4.0.0"}',
|
||||
})
|
||||
expect(bypassChip({ trekRange: null, hostVersion: '4.0.0' }, t)).toMatchObject({ label: 'admin.plugins.dep.trekBypassedUnknown' })
|
||||
})
|
||||
|
||||
it('bypassOffer: an enabled "Install anyway" carrying what the dialog must say', () => {
|
||||
const offer = bypassOffer({ name: 'Gotify', trek: '>=4.0.0', hostVersion: '3.3.0' }, t, 'why')
|
||||
expect(offer).toEqual({
|
||||
blocked: false, label: 'admin.plugins.installAnyway', title: 'why',
|
||||
warn: { name: 'Gotify', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null },
|
||||
})
|
||||
expect(bypassOffer({ name: 'X' }, t, 'why').warn).toMatchObject({ trekRange: null, hostVersion: '?' })
|
||||
})
|
||||
|
||||
it('rangeWarningCopy: confirm vs notice title, ranged vs rangeless body', () => {
|
||||
const ranged = { name: 'G', trekRange: '>=4.0.0', hostVersion: '3.3.0' }
|
||||
expect(rangeWarningCopy({ ...ranged, onConfirm: () => {} }, t)).toEqual({
|
||||
confirm: true, title: 'admin.plugins.rangeBypass.title',
|
||||
body: 'admin.plugins.rangeBypass.body:{"name":"G","range":">=4.0.0","host":"3.3.0"}',
|
||||
})
|
||||
expect(rangeWarningCopy({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, t)).toEqual({
|
||||
confirm: false, title: 'admin.plugins.rangeBypass.noticeTitle',
|
||||
body: 'admin.plugins.rangeBypass.bodyUnknown:{"name":"G","host":"3.3.0"}',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('useRangeBypass', () => {
|
||||
it('guard runs straight through without a warning, and parks the run behind one otherwise', () => {
|
||||
const { result } = renderHook(() => useRangeBypass())
|
||||
let ran = 0
|
||||
act(() => result.current.guard(undefined, () => { ran++ }))
|
||||
expect(ran).toBe(1)
|
||||
expect(result.current.warning).toBeNull()
|
||||
|
||||
act(() => result.current.guard({ name: 'G', trekRange: '>=4.0.0', hostVersion: '3.3.0', onConfirm: null }, () => { ran++ }))
|
||||
expect(ran).toBe(1) // nothing sent until the admin accepts
|
||||
expect(result.current.copy?.confirm).toBe(true)
|
||||
|
||||
act(() => result.current.confirm())
|
||||
expect(ran).toBe(2)
|
||||
expect(result.current.warning).toBeNull()
|
||||
})
|
||||
|
||||
it('dismiss drops a parked run; notice opens a plain notice only for a real marker', () => {
|
||||
const { result } = renderHook(() => useRangeBypass())
|
||||
let ran = 0
|
||||
act(() => result.current.guard({ name: 'G', trekRange: null, hostVersion: '3.3.0', onConfirm: null }, () => { ran++ }))
|
||||
act(() => result.current.dismiss())
|
||||
expect(ran).toBe(0)
|
||||
expect(result.current.warning).toBeNull()
|
||||
|
||||
act(() => result.current.notice('trek-new', null))
|
||||
expect(result.current.warning).toBeNull()
|
||||
act(() => result.current.notice('trek-new', { trekRange: null, hostVersion: '3.3.0' }))
|
||||
expect(result.current.warning).toMatchObject({ name: 'trek-new', onConfirm: null })
|
||||
expect(result.current.copy?.confirm).toBe(false)
|
||||
act(() => result.current.confirm()) // a notice's confirm is just a close
|
||||
expect(result.current.warning).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
type T = (k: string, p?: Record<string, unknown>) => string
|
||||
|
||||
/** The server's "this only went through because TREK_PLUGINS_IGNORE_TREK_RANGE is set" marker. */
|
||||
export interface TrekRangeBypass { trekRange: string | null; hostVersion: string }
|
||||
/** What the range-bypass warning needs to say, and what to do if the admin accepts (null = a notice). */
|
||||
export interface RangeWarning extends TrekRangeBypass { name: string; onConfirm: (() => void) | null }
|
||||
|
||||
/**
|
||||
* The chip a row carries while it runs outside its declared range on the operator's
|
||||
* say-so. Not a blocker — the toggle works — but a warning that must outlive the install
|
||||
* dialog for as long as the plugin runs here. Null when nothing was bypassed.
|
||||
*/
|
||||
export function bypassChip(b: TrekRangeBypass | null | undefined, t: T) {
|
||||
if (!b) return null
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
label: b.trekRange
|
||||
? t('admin.plugins.dep.trekBypassed', { range: b.trekRange, host: b.hostVersion })
|
||||
: t('admin.plugins.dep.trekBypassedUnknown'),
|
||||
blocked: false,
|
||||
warn: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Install button for a registry entry the server flagged incompatible, once the
|
||||
* operator set TREK_PLUGINS_IGNORE_TREK_RANGE: the server will take the newest version
|
||||
* regardless, so the button says exactly that — and carries what the warning has to say
|
||||
* first. `title` is the shell's existing "why" line (needs TREK X — this server runs Y).
|
||||
*/
|
||||
export function bypassOffer(item: { name: string; trek?: string | null; hostVersion?: string }, t: T, title: string) {
|
||||
return {
|
||||
blocked: false,
|
||||
label: t('admin.plugins.installAnyway'),
|
||||
title,
|
||||
warn: { name: item.name, trekRange: item.trek ?? null, hostVersion: item.hostVersion ?? '?', onConfirm: null } as RangeWarning,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Title + body of the warning, for either mode: a CONFIRM step before a registry install
|
||||
* the server would otherwise refuse (`onConfirm` set), or a plain NOTICE after a path that
|
||||
* could not ask first — sideload, dev-link, update, dependency download. The copy is the
|
||||
* whole point: the admin is accepting that the author never vouched for this TREK and
|
||||
* that, in rare cases, that can cost them data.
|
||||
*/
|
||||
export function rangeWarningCopy(w: RangeWarning, t: T): { title: string; body: string; confirm: boolean } {
|
||||
const confirm = w.onConfirm !== null
|
||||
return {
|
||||
confirm,
|
||||
title: confirm ? t('admin.plugins.rangeBypass.title') : t('admin.plugins.rangeBypass.noticeTitle'),
|
||||
body: w.trekRange
|
||||
? t('admin.plugins.rangeBypass.body', { name: w.name, range: w.trekRange, host: w.hostVersion })
|
||||
: t('admin.plugins.rangeBypass.bodyUnknown', { name: w.name, host: w.hostVersion }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The TREK_PLUGINS_IGNORE_TREK_RANGE warning state behind BOTH admin shells (the desktop
|
||||
* panel and the phone panel render their own dialog/sheet over it). `guard` parks a
|
||||
* registry install behind the confirm step when the entry was already flagged
|
||||
* incompatible; `notice` shows the after-the-fact warning for a response that carries the
|
||||
* server's marker. Whether the switch is ON stays a plain `useState` in each shell: its
|
||||
* setter is read inside the mount-time refresh, and only a useState setter is something
|
||||
* the exhaustive-deps rule knows to be stable.
|
||||
*/
|
||||
export function useRangeBypass() {
|
||||
const { t } = useTranslation()
|
||||
const [warning, setWarning] = useState<RangeWarning | null>(null)
|
||||
|
||||
const notice = (name: string, b: TrekRangeBypass | null | undefined) => {
|
||||
if (b) setWarning({ name, trekRange: b.trekRange, hostVersion: b.hostVersion, onConfirm: null })
|
||||
}
|
||||
const guard = (warn: RangeWarning | undefined, run: () => void) => {
|
||||
if (warn) setWarning({ ...warn, onConfirm: run })
|
||||
else run()
|
||||
}
|
||||
const confirm = () => { const w = warning; setWarning(null); w?.onConfirm?.() }
|
||||
const dismiss = () => setWarning(null)
|
||||
|
||||
return {
|
||||
warning,
|
||||
copy: warning ? rangeWarningCopy(warning, t) : null,
|
||||
notice, guard, confirm, dismiss,
|
||||
}
|
||||
}
|
||||
@@ -295,6 +295,29 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
expect(card).toHaveTextContent('120') // 120,00 € (locale separator), i.e. 90 + 30
|
||||
})
|
||||
|
||||
// #2225 follow-up — the ledger stopped charging payer-less expenses, but this
|
||||
// tile kept counting them, so it contradicted the balances right beside it.
|
||||
it('keeps a payer-less expense out of Your share, like the balances do', async () => {
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
const paid = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 60, payers: [{ user_id: 1, amount: 60, username: 'alice' }], members: [{ user_id: 1, username: 'alice', paid: 1 }] }
|
||||
const noPayer = { ...buildBudgetItem({ trip_id: 1, category: 'transport', name: 'Taxi' }), total_price: 40, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [paid, noPayer] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Taxi')
|
||||
// "Your share" is the first of the two bold figures in the Total spend card's
|
||||
// footer; the second is "You paid". 60 from the paid expense only — the 40
|
||||
// nobody has paid for is not a share, though it still counts as spend.
|
||||
const card = screen.getByText('Total trip spend').closest('div[style*="border-radius: 22"]')!
|
||||
const figures = [...card.querySelectorAll('b')].map(b => b.textContent ?? '')
|
||||
expect(figures[0]).toContain('60')
|
||||
expect(figures[0]).not.toContain('100')
|
||||
})
|
||||
|
||||
it('records a recorded-total expense with nobody to split with (#1286)', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
let posted: Record<string, unknown> | null = null
|
||||
|
||||
@@ -123,7 +123,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
// ── derived expense maths (everything converted to the base currency) ────
|
||||
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
|
||||
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in. A negative
|
||||
// total (a refund, #2176) is just as unfinished until its recipient is named.
|
||||
const isUnfinished = (e: BudgetItem) => baseTotal(e) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0
|
||||
const myShareOf = (e: BudgetItem) => {
|
||||
// Nobody paid, so nobody owes: the ledger skips these entirely (#2225), and
|
||||
// counting them here left the tile contradicting the balances right beside it.
|
||||
if (isUnfinished(e)) return 0
|
||||
const myMember = (e.members || []).find(m => m.user_id === me)
|
||||
if (!myMember) return 0
|
||||
if (myMember.amount !== null && myMember.amount !== undefined) {
|
||||
@@ -133,10 +140,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const myShare = shares[me] || 0
|
||||
return convert(myShare, curOf(e))
|
||||
}
|
||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
||||
// total but stays out of settlements until who-paid is filled in. A negative
|
||||
// total (a refund, #2176) is just as unfinished until its recipient is named.
|
||||
const isUnfinished = (e: BudgetItem) => baseTotal(e) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalSpend = budgetItems.reduce((a, e) => a + baseTotal(e), 0)
|
||||
|
||||
@@ -1275,7 +1275,7 @@ describe('CollabNotes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// FE-W5CNT-001 to FE-W5CNT-029
|
||||
// FE-W5CNT-001 to FE-W5CNT-036
|
||||
// Fills in the load/error/attachment/category branches of useCollabNotes and the
|
||||
// view modal that the smoke tests above do not reach.
|
||||
|
||||
@@ -1947,4 +1947,32 @@ describe('CollabNotes details', () => {
|
||||
expect(full.querySelector(`[id="${href.slice(1)}"]`)).not.toBeNull();
|
||||
expect(ref).not.toHaveAttribute('target');
|
||||
});
|
||||
|
||||
it('FE-W5CNT-035: the expanded note links its website out to a new tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 9, title: 'Hotel', website: 'www.hotel.test' })] });
|
||||
server.use(http.get('/api/trips/1/collab/link-preview', () => HttpResponse.json({ title: 'Hotel', image: null })));
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Hotel');
|
||||
await user.click(screen.getByTitle('collab.notes.expand'));
|
||||
const full = await openedNoteBody();
|
||||
|
||||
// A member without collab_edit never opens the edit modal, so this is the
|
||||
// only place the link is readable for them (#2222).
|
||||
const link = within(full).getByRole('link');
|
||||
expect(link).toHaveAttribute('href', 'https://www.hotel.test');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(link).toHaveTextContent('hotel.test');
|
||||
});
|
||||
|
||||
it('FE-W5CNT-036: a javascript: website stays unlinked in the expanded note', async () => {
|
||||
const user = userEvent.setup();
|
||||
serveNotes({ notes: [buildNote({ id: 10, title: 'Hotel', website: 'javascript:alert(1)' })] });
|
||||
render(<CollabNotes {...defaultProps} />);
|
||||
await screen.findByText('Hotel');
|
||||
await user.click(screen.getByTitle('collab.notes.expand'));
|
||||
const full = await openedNoteBody();
|
||||
|
||||
expect(within(full).queryAllByRole('link')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import remarkGfm from 'remark-gfm'
|
||||
import remarkBreaks from 'remark-breaks'
|
||||
import { sanitizedMarkdownComponents, sanitizedMarkdownPlugins } from '../shared/markdownSanitize'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Plus, Pencil, X, StickyNote, Settings } from 'lucide-react'
|
||||
import { Plus, Pencil, X, StickyNote, Settings, ExternalLink } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
@@ -21,6 +21,7 @@ import { CategorySettingsModal } from './CollabNotesCategorySettingsModal'
|
||||
import { NoteCard } from './CollabNotesCard'
|
||||
import { FilePreviewPortal } from './CollabNotesFilePreviewPortal'
|
||||
import { AuthedImg } from './CollabNotesAuthedImg'
|
||||
import { safeExternalHref } from '../../utils/safeUrl'
|
||||
|
||||
// ── Main Component ──────────────────────────────────────────────────────────
|
||||
interface CollabNotesProps {
|
||||
@@ -393,9 +394,18 @@ function CollabNotesGrid(S: NotesState) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Label for the link chip: the host is what people recognise, "www." is noise. */
|
||||
function linkHost(url: string): string {
|
||||
try { return new URL(url).hostname.replace(/^www\./, '') } catch { return url }
|
||||
}
|
||||
|
||||
function ViewNoteModal(S: NotesState) {
|
||||
const { viewingNote, setViewingNote, canEdit, setEditingNote, getCategoryColor, t, setPreviewFile } = S
|
||||
if (!viewingNote) return null
|
||||
// A member without collab_edit only ever gets this modal, so the link has to
|
||||
// be here too (#2222). Allow-listed like the card tile: the field takes any
|
||||
// string, and a javascript: one would run in this origin.
|
||||
const websiteHref = safeExternalHref(viewingNote.website)
|
||||
return createPortal(
|
||||
<div
|
||||
style={{
|
||||
@@ -447,6 +457,17 @@ function ViewNoteModal(S: NotesState) {
|
||||
</div>
|
||||
<div className="collab-note-md-full" style={{ padding: '16px 20px', overflowY: 'auto', fontSize: 'calc(14px * var(--fs-scale-body, 1))', color: 'var(--text-primary)', lineHeight: 1.7 }}>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} rehypePlugins={sanitizedMarkdownPlugins} components={sanitizedMarkdownComponents}>{viewingNote.content || ''}</Markdown>
|
||||
{websiteHref && (
|
||||
<a href={websiteHref} target="_blank" rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 12,
|
||||
padding: '6px 12px', borderRadius: 999, border: '1px solid var(--border-primary)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600,
|
||||
color: 'var(--text-primary)', textDecoration: 'none',
|
||||
}}>
|
||||
<ExternalLink size={13} color="var(--text-muted)" /> {linkHost(websiteHref)}
|
||||
</a>
|
||||
)}
|
||||
{(viewingNote.attachments || []).length > 0 && (
|
||||
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid var(--border-primary)' }}>
|
||||
<div style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 10 }}>{t('files.title')}</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-049
|
||||
// FE-COMP-JOURNEYMAP-001 to FE-COMP-JOURNEYMAP-050
|
||||
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
connect: vi.fn(),
|
||||
@@ -27,6 +27,10 @@ vi.mock('leaflet', () => {
|
||||
setView: vi.fn(),
|
||||
flyTo: vi.fn(),
|
||||
getZoom: vi.fn(() => 10),
|
||||
// Leaflet throws "Set map center and zoom first." out of these until the map
|
||||
// has a view; the loaded map is the default here, FE-COMP-JOURNEYMAP-050
|
||||
// takes it away for the window that #2254 fell into.
|
||||
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
zoomIn: vi.fn(),
|
||||
zoomOut: vi.fn(),
|
||||
// The photo layer subscribes to zoom/pan and clusters in screen space (#1614).
|
||||
@@ -58,6 +62,7 @@ import { render, act, fireEvent, waitFor } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { buildSettings } from '../../../tests/helpers/factories';
|
||||
import type { Mock } from 'vitest';
|
||||
import L from 'leaflet';
|
||||
import JourneyMap from './JourneyMap';
|
||||
import type { JourneyMapHandle } from './JourneyMap';
|
||||
@@ -693,4 +698,47 @@ describe('JourneyMap', () => {
|
||||
expect(html).toHaveLength(1);
|
||||
expect(html[0]).not.toMatch(/>\d+</);
|
||||
});
|
||||
|
||||
// #2254 — the map effect defers its opening fitBounds/setView to a rAF, so this
|
||||
// effect can draw while the map still has no center or zoom. Clustering
|
||||
// projects to container pixels, which throws in that window and took the whole
|
||||
// public journey page down with it.
|
||||
it('FE-COMP-JOURNEYMAP-050: waits out a map that has no view yet, then draws on moveend', () => {
|
||||
const noView = () => { throw new Error('Set map center and zoom first.'); };
|
||||
let hasView = false;
|
||||
const photoHtml = () =>
|
||||
vi.mocked(L.divIcon).mock.calls.map(c => String(c[0]?.html)).filter(h => h.includes('/uploads/'));
|
||||
|
||||
// The mock hands out one shared map instance, so it can be reached — and
|
||||
// stripped of its view — before the component ever builds one. Typed down to
|
||||
// the three spies this needs: L.map's signature promises a real Leaflet map.
|
||||
type MapProbe = { getCenter: Mock; latLngToContainerPoint: Mock; on: Mock };
|
||||
L.map(document.createElement('div'));
|
||||
const map = mockedMap() as MapProbe;
|
||||
vi.mocked(L.map).mockClear();
|
||||
map.getCenter.mockImplementation(() => (hasView ? { lat: 0, lng: 0 } : noView()));
|
||||
map.latLngToContainerPoint.mockImplementation(() => (hasView ? { x: 0, y: 0 } : noView()));
|
||||
|
||||
try {
|
||||
// Rendering must survive the viewless window, and draw nothing in it.
|
||||
expect(() =>
|
||||
render(
|
||||
<JourneyMap checkins={[]} entries={[]} photos={[{ id: 'p1', lat: 1, lng: 2, thumbUrl: '/uploads/late.jpg' }]} />,
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(photoHtml()).toHaveLength(0);
|
||||
|
||||
// The deferred fitBounds lands: the listener this effect registered redraws.
|
||||
hasView = true;
|
||||
const redraw = map.on.mock.calls.find(c => c[0] === 'moveend')?.[1] as () => void;
|
||||
expect(redraw).toBeTypeOf('function');
|
||||
act(() => { redraw(); });
|
||||
|
||||
expect(photoHtml()).toHaveLength(1);
|
||||
expect(photoHtml()[0]).toContain('/uploads/late.jpg');
|
||||
} finally {
|
||||
map.getCenter.mockImplementation(() => ({ lat: 0, lng: 0 }));
|
||||
map.latLngToContainerPoint.mockImplementation(() => ({ x: 0, y: 0 }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -405,6 +405,16 @@ function JourneyMap(
|
||||
photoLayerRef.current = null
|
||||
if (!photos?.length) return
|
||||
|
||||
// The initial view (setView/fitBounds) is set on a deferred rAF in the
|
||||
// map-build effect above, so this can run before the map has a center/zoom
|
||||
// — latLngToContainerPoint throws in that window. Skip; the moveend/zoomend
|
||||
// listener below redraws once the deferred view lands.
|
||||
try {
|
||||
map.getCenter()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const group = L.layerGroup()
|
||||
for (const cluster of clusterPhotos(map, photos)) {
|
||||
const marker = L.marker([cluster.lat, cluster.lng], {
|
||||
|
||||
@@ -3,7 +3,34 @@ import DOM from 'react-dom'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import RoadtripViaMarkers from './RoadtripViaMarkers'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
|
||||
|
||||
/**
|
||||
* The drive-time pill for one offered route.
|
||||
*
|
||||
* A divIcon rather than a tooltip: it has to be clickable, it has to sit exactly on the
|
||||
* road, and it is the same shape the GL renderers build by hand — one look across all
|
||||
* three maps.
|
||||
*/
|
||||
function alternativeLabelIcon(label: string, note: string, background: string, active: boolean) {
|
||||
const outline = active ? 'outline:2px solid #fff;outline-offset:1px;' : ''
|
||||
const second = note
|
||||
? `<span style="font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2">${escapeHtml(note)}</span>`
|
||||
: ''
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
// `font-family` spelled out: a divIcon sits inside `.leaflet-container`, whose own
|
||||
// stylesheet sets Helvetica/Arial on everything in it. Without this the label is the
|
||||
// one piece of TREK chrome on the map that is not in the app's typeface.
|
||||
html: `<span style="display:inline-flex;flex-direction:column;align-items:flex-start;white-space:nowrap;background:${background};color:${ALT_LABEL_TEXT};border-radius:11px;padding:6px 11px;font-family:var(--font-system);font-size:13px;font-weight:600;line-height:1.25;box-shadow:0 2px 10px rgba(0,0,0,.35);cursor:pointer;transition:none;${outline}"><span>${escapeHtml(label)}</span>${second}</span>`,
|
||||
iconSize: [0, 0],
|
||||
iconAnchor: [0, 0],
|
||||
})
|
||||
}
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
@@ -76,9 +103,21 @@ function formatViaDwell(seconds: number): string {
|
||||
* Shows image_url if available, otherwise category icon in colored circle.
|
||||
*/
|
||||
function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${orderNumbers?.join(',') || ''}`
|
||||
const cacheKey = `${place.id}:${isSelected}:${place.image_url || ''}:${place.category_color || ''}:${place.category_icon || ''}:${place.stop_type || ''}:${orderNumbers?.join(',') || ''}`
|
||||
const cached = iconCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
// A stop that interrupts the drive is drawn as its own small disc, before the photo
|
||||
// branch below ever gets a look at it — the brand logo a fuel search comes back with
|
||||
// is exactly what this replaces. No number badge either, for the same reason the rail
|
||||
// gives it none: it is part of the drive, not one of the day's stops.
|
||||
const service = serviceMarkerHtml(place.stop_type, isSelected)
|
||||
if (service) {
|
||||
const outer = serviceMarkerOuter(isSelected)
|
||||
const icon = L.divIcon({ className: '', html: service, iconSize: [outer, outer], iconAnchor: [outer / 2, outer / 2] })
|
||||
iconCache.set(cacheKey, icon)
|
||||
return icon
|
||||
}
|
||||
const size = isSelected ? 44 : 36
|
||||
// Allow-listed, not escaped: the value lands in style="…" of a divIcon, where
|
||||
// escaping stops the attribute breakout but still permits a CSS url().
|
||||
@@ -159,7 +198,13 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
|
||||
// markers of planned places; the colour matches its pill category.
|
||||
const poiIconCache = new Map<string, L.DivIcon>()
|
||||
function createPoiIcon(category: string) {
|
||||
function createPoiIcon(category: string, brandWikidata?: string | null) {
|
||||
// One flat disc in the category's colour with its icon, and never the chain's logo.
|
||||
// The brands turned a corridor full of petrol stations into a row of advertisements,
|
||||
// they were unreadable at pin size, and a brand with no logo on file fell back to a
|
||||
// different picture entirely — so no two pins looked alike. `brandWikidata` is kept in
|
||||
// the signature because the callers still have it; it simply no longer changes anything.
|
||||
void brandWikidata
|
||||
const cached = poiIconCache.get(category)
|
||||
if (cached) return cached
|
||||
const cat = POI_CATEGORY_BY_KEY[category]
|
||||
@@ -167,7 +212,7 @@ function createPoiIcon(category: string) {
|
||||
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;">${svg}</div>`,
|
||||
html: `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;overflow:hidden;">${svg}</div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 13],
|
||||
tooltipAnchor: [0, -14],
|
||||
@@ -193,6 +238,41 @@ function CameraHoverGuard({ movingRef, onMoveStart }: { movingRef: { current: bo
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a corridor hit dropped anywhere on the map and reports where it landed.
|
||||
*
|
||||
* The container rather than the drawn route: a polyline is a real element here but not in
|
||||
* the GL renderers, and the drop coordinate answers "where on the drive" just as well
|
||||
* once the caller projects it onto the routed geometry — one behaviour for all three.
|
||||
*/
|
||||
function PoiDropTarget({ onPoiDropOnRoute }: { onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (!onPoiDropOnRoute) return
|
||||
const container = map.getContainer()
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!draggedPoiId(e)) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const osmId = draggedPoiId(e)
|
||||
if (!osmId) return
|
||||
e.preventDefault()
|
||||
const rect = container.getBoundingClientRect()
|
||||
const at = map.containerPointToLatLng([e.clientX - rect.left, e.clientY - rect.top])
|
||||
onPoiDropOnRoute(osmId, at.lat, at.lng)
|
||||
}
|
||||
container.addEventListener('dragover', onDragOver)
|
||||
container.addEventListener('drop', onDrop)
|
||||
return () => {
|
||||
container.removeEventListener('dragover', onDragOver)
|
||||
container.removeEventListener('drop', onDrop)
|
||||
}
|
||||
}, [map, onPoiDropOnRoute])
|
||||
return null
|
||||
}
|
||||
|
||||
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
||||
// OSM places for the visible area.
|
||||
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
|
||||
@@ -277,9 +357,17 @@ interface BoundsControllerProps {
|
||||
paddingOpts: L.FitBoundsOptions
|
||||
/** The map was built already framed on these places, so the opening fit has nothing to do. */
|
||||
framedOnMount?: boolean
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
|
||||
* for itself that it means "the selected day". Weighing the ways of driving one leg
|
||||
* needs that leg on screen, which is neither the day nor the trip.
|
||||
*/
|
||||
focusPoints?: [number, number][]
|
||||
}
|
||||
|
||||
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false }: BoundsControllerProps) {
|
||||
function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMount = false, focusPoints }: BoundsControllerProps) {
|
||||
const map = useMap()
|
||||
const prevFitKey = useRef(-1)
|
||||
const awaitingRoute = useRef(false)
|
||||
@@ -336,6 +424,15 @@ function BoundsController({ places, routeCoords, fitKey, paddingOpts, framedOnMo
|
||||
fitTo([...places.map(p => [p.lat, p.lng] as [number, number]), ...routeCoords])
|
||||
}, [routeCoords]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Frame whatever was handed over. Nothing happens when it empties, so closing the
|
||||
// picker leaves the map where the user left it rather than snapping back.
|
||||
useEffect(() => {
|
||||
if (!focusPoints?.length) return
|
||||
// A day fit that has not run yet must not overwrite this a moment later.
|
||||
awaitingRoute.current = false
|
||||
fitTo(focusPoints)
|
||||
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -538,6 +635,18 @@ export const MapView = memo(function MapView({
|
||||
onViewportChange,
|
||||
tripId,
|
||||
routeVias = [],
|
||||
accessLines = [],
|
||||
onPoiDropOnRoute,
|
||||
onRouteClick,
|
||||
roadtripVias,
|
||||
onMoveVia,
|
||||
onRemoveVia,
|
||||
alternativeRoutes,
|
||||
focusPoints,
|
||||
clusterLoosely = false,
|
||||
activeAlternative,
|
||||
onChooseAlternative,
|
||||
onHighlightAlternative,
|
||||
}: any) {
|
||||
// The caller hands over whatever the user configured; what kind of basemap
|
||||
// that is decides which layer draws it. A saved raster template still wins,
|
||||
@@ -547,13 +656,21 @@ export const MapView = memo(function MapView({
|
||||
<Marker
|
||||
key={`poi-${poi.osm_id}`}
|
||||
position={[poi.lat, poi.lng]}
|
||||
icon={createPoiIcon(poi.category)}
|
||||
icon={createPoiIcon(poi.category, poi.brand_wikidata)}
|
||||
zIndexOffset={500}
|
||||
eventHandlers={{ click: () => onPoiClick?.(poi) }}
|
||||
eventHandlers={{
|
||||
click: () => onPoiClick?.(poi),
|
||||
// Wired on add rather than in a layout effect: Leaflet builds the icon element
|
||||
// itself, and this is the first moment there is one to attach to.
|
||||
add: (e: { target: { getElement: () => HTMLElement | undefined } }) => {
|
||||
const el = e.target.getElement()
|
||||
if (el && onPoiDropOnRoute) makePoiDraggable(el, poi.osm_id)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
|
||||
</Marker>
|
||||
)), [pois, onPoiClick])
|
||||
)), [pois, onPoiClick, onPoiDropOnRoute])
|
||||
const visibleReservations = useMemo(() => (
|
||||
visibleRouteReservations(reservations, { visibleConnectionIds, showTransitRoutes, selectedDayId, days })
|
||||
), [reservations, visibleConnectionIds, showTransitRoutes, selectedDayId, days])
|
||||
@@ -872,43 +989,127 @@ export const MapView = memo(function MapView({
|
||||
)}
|
||||
|
||||
<MapController center={center} zoom={zoom} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} />
|
||||
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} routeCoords={dayPlaces.length > 0 ? routeCoords : []} fitKey={fitKey} paddingOpts={paddingOpts} framedOnMount={initialView.framed} focusPoints={focusPoints} />
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<CameraHoverGuard movingRef={mapMovingRef} onMoveStart={clearHover} />
|
||||
<ViewportController onViewportChange={onViewportChange} />
|
||||
<PoiDropTarget onPoiDropOnRoute={onPoiDropOnRoute} />
|
||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
{/* Road trip mode does not cluster at all: the whole drive has to stay readable
|
||||
zoomed right out, and a day's stops merging into one dot is exactly the shape
|
||||
it is being looked at for. Rendered outside the group rather than with the
|
||||
clustering disabled by option, so nothing is left to a plugin's own idea of
|
||||
what "disabled" means. */}
|
||||
{clusterLoosely ? markers : (
|
||||
<MarkerClusterGroup
|
||||
chunkedLoading
|
||||
chunkInterval={30}
|
||||
chunkDelay={0}
|
||||
maxClusterRadius={30}
|
||||
disableClusteringAtZoom={11}
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
animate={false}
|
||||
iconCreateFunction={clusterIconCreateFunction}
|
||||
>
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
)}
|
||||
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded. */}
|
||||
{/* Apple-Maps style: darker-blue casing under a bright-blue core, rounded.
|
||||
The casing carries the click when the route can be reshaped: it is the wider of
|
||||
the two, so it is the one a pointer actually lands on. */}
|
||||
{route && route.length > 0 && route.flatMap((seg, i) => seg.length > 1 ? [
|
||||
<Polyline
|
||||
key={`${i}-casing`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a5cc2', weight: 8, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={!!onRouteClick}
|
||||
eventHandlers={onRouteClick ? {
|
||||
click: (e: { latlng: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
|
||||
// Stops the map's own click, which would otherwise open the add-place menu
|
||||
// underneath the new via.
|
||||
e.originalEvent.stopPropagation()
|
||||
onRouteClick(e.latlng.lat, e.latlng.lng)
|
||||
},
|
||||
} : undefined}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`${i}-core`}
|
||||
positions={seg}
|
||||
pathOptions={{ color: '#0a84ff', weight: 5, opacity: 1, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
] : [])}
|
||||
|
||||
{/* The last bit to a place the road does not reach.
|
||||
Dashed and thin, over the route rather than under it, because it is the one
|
||||
piece of the line that is not driving: the router snapped the stop to the
|
||||
nearest road and the drive really ends there. Same blue as the route, so it
|
||||
reads as the end of that route and not as a second one. */}
|
||||
{(accessLines ?? []).map((spur, i) => (
|
||||
<Polyline
|
||||
key={`access-${i}`}
|
||||
positions={spur.line}
|
||||
pathOptions={{ color: '#0a84ff', weight: 3, opacity: 0.85, dashArray: '2 7', lineCap: 'round' }}
|
||||
interactive={false}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* The offered ways of driving one leg, over the route they replace. Clicking one
|
||||
takes it, which is the same choice the bar above the map offers. */}
|
||||
{/* Every option in blue over a white casing, after Apple Maps: they are all real
|
||||
roads, so the difference is emphasis, not category. Grey was the first attempt
|
||||
and it disappeared on Positron, which is almost entirely greys. */}
|
||||
{(alternativeRoutes ?? []).flatMap((alt: AlternativeOverlay) => {
|
||||
const active = activeAlternative === alt.index
|
||||
return [
|
||||
<Polyline
|
||||
key={`alt-${alt.index}-casing`}
|
||||
positions={alt.coordinates}
|
||||
pathOptions={{ color: ALT_CASING, weight: active ? 9 : 8, opacity: 0.9, lineCap: 'round', lineJoin: 'round' }}
|
||||
interactive={false}
|
||||
/>,
|
||||
<Polyline
|
||||
key={`alt-${alt.index}`}
|
||||
positions={alt.coordinates}
|
||||
pathOptions={{
|
||||
color: alt.color,
|
||||
weight: active ? 6 : 4,
|
||||
opacity: active ? 1 : 0.9,
|
||||
lineCap: 'round',
|
||||
lineJoin: 'round',
|
||||
}}
|
||||
eventHandlers={onChooseAlternative ? {
|
||||
click: (e: { originalEvent: MouseEvent }) => { e.originalEvent.stopPropagation(); onChooseAlternative(alt.index) },
|
||||
mouseover: () => onHighlightAlternative?.(alt.index),
|
||||
mouseout: () => onHighlightAlternative?.(null),
|
||||
} : undefined}
|
||||
/>,
|
||||
// The drive time on the road itself, the way Apple labels them.
|
||||
<Marker
|
||||
key={`alt-${alt.index}-label`}
|
||||
position={[alt.at.lat, alt.at.lng]}
|
||||
zIndexOffset={600}
|
||||
icon={alternativeLabelIcon(alt.label, alt.note, alt.labelBg, active)}
|
||||
eventHandlers={onChooseAlternative ? {
|
||||
click: () => onChooseAlternative(alt.index),
|
||||
mouseover: () => onHighlightAlternative?.(alt.index),
|
||||
mouseout: () => onHighlightAlternative?.(null),
|
||||
} : undefined}
|
||||
/>,
|
||||
]
|
||||
})}
|
||||
|
||||
{/* The handles that shape the drive. After the route so they sit on top of it. */}
|
||||
{roadtripVias ? (
|
||||
<RoadtripViaMarkers viasByDay={roadtripVias} onMoveVia={onMoveVia} onRemoveVia={onRemoveVia} />
|
||||
) : null}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
<TrackCasingPane onReady={setHasCasingPane} />
|
||||
{gpxPolylines}
|
||||
|
||||
@@ -1855,4 +1855,104 @@ describe('MapViewGL', () => {
|
||||
expect(glMap.off).toHaveBeenCalledWith('moveend', expect.any(Function))
|
||||
expect(glMap.off).toHaveBeenCalledWith('zoomend', expect.any(Function))
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-071: POI suggestions ride the map render clock instead of the pointer', async () => {
|
||||
// Same drift the planned-place pins had: a library Marker repositions on every
|
||||
// `move` event — one per pointer sample — while the canvas draws once a frame, so
|
||||
// the suggestions swam over the map during a drag and snapped back on release.
|
||||
loadOnAttach()
|
||||
glCanvasContainer.replaceChildren()
|
||||
glMap.project.mockReturnValue({ x: 100, y: 80 })
|
||||
|
||||
render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
pois={[{ osm_id: 'node:1', name: 'Aral', lat: 48.1, lng: 2.1, category: 'fuel', source: 'openstreetmap' } as never]}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
const layer = glCanvasContainer.firstElementChild as HTMLElement
|
||||
expect(layer.children.length).toBe(1)
|
||||
const pin = layer.firstElementChild as HTMLElement
|
||||
expect(pin.style.transform).toContain('translate(100px, 80px)')
|
||||
|
||||
glMap.project.mockReturnValue({ x: 140, y: 60 })
|
||||
act(() => { mapHandler('render')() })
|
||||
|
||||
expect(pin.style.transform).toContain('translate(140px, 60px)')
|
||||
})
|
||||
|
||||
// A via handle is a DOM element, and a pointerdown belongs to the element it happened
|
||||
// on. Rebuilding the handles on an unrelated render pulled that element out from under
|
||||
// the pointer, so a freshly placed via lost its first drag — the re-route it triggered
|
||||
// landed about a second later, which is exactly when somebody reaches for it.
|
||||
it('FE-COMP-MAPVIEWGL-VIA-001: a via handle survives a render it has nothing to do with', async () => {
|
||||
loadOnAttach()
|
||||
glCanvasContainer.replaceChildren()
|
||||
glMap.project.mockReturnValue({ x: 100, y: 80 })
|
||||
|
||||
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
|
||||
const { rerender } = render(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
roadtripVias={{ 1: [stored] }}
|
||||
onMoveVia={() => {}}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
const layer = glCanvasContainer.firstElementChild as HTMLElement
|
||||
const handle = layer.firstElementChild
|
||||
expect(handle).toBeTruthy()
|
||||
|
||||
// The same via in a fresh object, with a fresh callback: what every render of the
|
||||
// planner hands down while the day re-routes.
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
roadtripVias={{ 1: [{ ...stored }] }}
|
||||
onMoveVia={() => {}}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
expect(layer.firstElementChild).toBe(handle)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-VIA-002: a via that actually moved is drawn again', async () => {
|
||||
loadOnAttach()
|
||||
glCanvasContainer.replaceChildren()
|
||||
glMap.project.mockReturnValue({ x: 100, y: 80 })
|
||||
|
||||
const stored = { id: 5, day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1 }
|
||||
const { rerender } = render(
|
||||
<MapViewGL places={[]} fitKey={1} glProvider="maplibre-gl" roadtripVias={{ 1: [stored] }} onMoveVia={() => {}} />,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
const layer = glCanvasContainer.firstElementChild as HTMLElement
|
||||
const handle = layer.firstElementChild
|
||||
|
||||
rerender(
|
||||
<MapViewGL
|
||||
places={[]}
|
||||
fitKey={1}
|
||||
glProvider="maplibre-gl"
|
||||
roadtripVias={{ 1: [{ ...stored, lat: 48.9 }] }}
|
||||
onMoveVia={() => {}}
|
||||
/>,
|
||||
)
|
||||
await flushFrames()
|
||||
|
||||
// The other half of the contract: holding the element still must not mean holding it
|
||||
// in the wrong place.
|
||||
expect(layer.firstElementChild).not.toBe(handle)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { useEffect, useRef, useMemo, useState, createElement, useCallback } from 'react'
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import { ALT_CASING, ALT_LABEL_TEXT } from '../Roadtrip/alternativeColors'
|
||||
import type { AlternativeOverlay } from '../Roadtrip/alternativeOverlays'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter } from '../Roadtrip/serviceMarker'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -81,6 +86,8 @@ interface RouteSegment {
|
||||
// mouseleave never fires" case (#1404).
|
||||
const NO_PLACES: Place[] = []
|
||||
const NO_ROUTE_VIAS: RouteVia[] = []
|
||||
/** Stable empty default: a fresh array each render would refire the spur effect. */
|
||||
const NO_ACCESS_LINES: { line: [[number, number], [number, number]]; meters: number }[] = []
|
||||
const NO_ROUTE_SEGMENTS: RouteSegment[] = []
|
||||
const NO_DAY_ORDER: Record<number, number[] | null> = {}
|
||||
const NO_RESERVATIONS: Reservation[] = []
|
||||
@@ -97,6 +104,8 @@ interface Props {
|
||||
tripId?: number | string
|
||||
// Charging stops / rest areas a plugin route places on the drawn day route.
|
||||
routeVias?: RouteVia[]
|
||||
/** The dashed last bit to a place the road network does not reach. */
|
||||
accessLines?: { line: [[number, number], [number, number]]; meters: number }[]
|
||||
route?: [number, number][][] | null
|
||||
routeSegments?: RouteSegment[]
|
||||
selectedPlaceId?: number | null
|
||||
@@ -121,6 +130,39 @@ interface Props {
|
||||
onReservationClick?: (reservationId: number) => void
|
||||
pois?: Poi[]
|
||||
onPoiClick?: (poi: Poi) => void
|
||||
/**
|
||||
* A corridor hit dropped somewhere on the map, with the coordinate it landed on.
|
||||
* The caller decides whether that point is near enough to the drive to mean anything.
|
||||
*/
|
||||
onPoiDropOnRoute?: (osmId: string, lat: number, lng: number) => void
|
||||
/** A click on the drawn route, for putting a via point there (#1797). */
|
||||
onRouteClick?: (lat: number, lng: number) => void
|
||||
/** The ways of driving one leg, drawn while the picker is open. */
|
||||
alternativeRoutes?: AlternativeOverlay[]
|
||||
/** Which option is being considered, so it can be lit up in its own colour. */
|
||||
activeAlternative?: number | null
|
||||
onChooseAlternative?: (index: number) => void
|
||||
/** Reports which option the pointer is over, so the list and the map agree. */
|
||||
onHighlightAlternative?: (index: number | null) => void
|
||||
/**
|
||||
* An explicit stretch of map to frame, independent of the day being shown.
|
||||
*
|
||||
* `fitKey` cannot express this: it carries no coordinates, and each renderer decides
|
||||
* for itself that it means "the selected day". Weighing the ways of driving one leg
|
||||
* needs that leg on screen, which is neither the day nor the trip.
|
||||
*/
|
||||
focusPoints?: [number, number][]
|
||||
/**
|
||||
* Let markers stay apart longer than usual.
|
||||
*
|
||||
* A road trip is read along a line: two stops fifty kilometres apart on the same
|
||||
* motorway are the shape of the day, and merging them into one dot hides it.
|
||||
*/
|
||||
clusterLoosely?: boolean
|
||||
/** Via points to draw as draggable handles, keyed by day (#1797). */
|
||||
roadtripVias?: Record<number, RoadtripVia[]>
|
||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
|
||||
onRemoveVia?: (dayId: number, id: number) => void
|
||||
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
|
||||
glProvider?: GlMapProvider
|
||||
/**
|
||||
@@ -134,7 +176,76 @@ interface Props {
|
||||
onMapReady?: (map: any | null) => void
|
||||
}
|
||||
|
||||
/** How eagerly place markers merge into a cluster, outside road trip mode. */
|
||||
const CLUSTER_RADIUS = 30
|
||||
const CLUSTER_MAX_ZOOM = 10
|
||||
|
||||
/** The cluster source and the three layers over it. Extracted so the rebuild on a mode
|
||||
* change and the initial build cannot drift apart. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function addPlaceClusterLayers(map: any): void {
|
||||
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
clusterRadius: CLUSTER_RADIUS,
|
||||
clusterMaxZoom: CLUSTER_MAX_ZOOM,
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': '#111827',
|
||||
'circle-opacity': 0.97,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
||||
'circle-stroke-width': 2.5,
|
||||
'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
||||
type: 'symbol',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-size': 12,
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(17,24,39,0.35)',
|
||||
'text-halo-width': 1,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_UNCLUSTERED_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 24,
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
// A stop that interrupts the drive gets its own small disc, decided before the photo
|
||||
// branch: the brand logo a fuel search comes back with is exactly what this replaces.
|
||||
// No number badge either, for the same reason the rail gives it none.
|
||||
const service = serviceMarkerHtml(place.stop_type, selected)
|
||||
if (service) {
|
||||
const outer = serviceMarkerOuter(selected)
|
||||
const wrap = document.createElement('div')
|
||||
wrap.style.cssText = `width:${outer}px;height:${outer}px;display:flex;align-items:center;justify-content:center`
|
||||
wrap.innerHTML = service
|
||||
return wrap
|
||||
}
|
||||
|
||||
const size = selected ? 44 : 36
|
||||
// See MapView: allow-listed rather than escaped, because this is a CSS context.
|
||||
const borderColor = selected ? '#111827' : safeHexColor(place.category_color, 'white')
|
||||
@@ -356,6 +467,20 @@ function makePlacePin(map: any, layer: HTMLElement, el: HTMLElement, lng: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts an element on the map as a pin.
|
||||
*
|
||||
* `layer` is only ever set under MapLibre (that is where it gets created), so passing it
|
||||
* along is the whole provider decision: with a layer we position the element ourselves on
|
||||
* the map's render clock, without one the library's own Marker does it.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function attachPin(map: any, gl: any, layer: HTMLElement | null, el: HTMLElement, lng: number, lat: number): PlacePin {
|
||||
return layer
|
||||
? makePlacePin(map, layer, el, lng, lat)
|
||||
: libraryPin(new gl.Marker({ element: el, anchor: 'center' }).setLngLat([lng, lat]).addTo(map))
|
||||
}
|
||||
|
||||
// Tone dot for a plugin marker — visual twin of MapPluginMarkers' divIcon.
|
||||
function createPluginMarkerElement(tone: PluginMapMarker['tone']): HTMLDivElement {
|
||||
const color = PLUGIN_TONE_COLORS[tone] ?? PLUGIN_TONE_COLORS.default
|
||||
@@ -395,21 +520,37 @@ function buildPluginMarkerPopup(mk: PluginMapMarker): HTMLDivElement {
|
||||
}
|
||||
|
||||
// Small coloured pin for an OSM "explore" POI (matches the pill category colour).
|
||||
function createPoiMarkerElement(category: string): HTMLDivElement {
|
||||
// A chain shows its logo instead of the category icon: on a corridor full of petrol
|
||||
// stations the brand is what the eye is looking for, and the server proxies it so the
|
||||
// browser never asks Wikimedia which ones are on screen.
|
||||
function createPoiMarkerElement(category: string, brandWikidata?: string | null): HTMLDivElement {
|
||||
const cat = POI_CATEGORY_BY_KEY[category]
|
||||
const color = cat?.color || '#6b7280'
|
||||
const svg = cat ? renderIconMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = 'width:26px;height:26px;cursor:pointer;'
|
||||
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;">${svg}</div>`
|
||||
el.style.cssText = 'width:26px;height:26px;cursor:pointer;will-change:transform;'
|
||||
el.innerHTML = `<div style="position:relative;width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;overflow:hidden;">${svg}${brandLogoMarkup(brandWikidata)}</div>`
|
||||
return el
|
||||
}
|
||||
|
||||
/**
|
||||
* Kept as the one place that decides a POI pin carries no brand mark: nothing.
|
||||
*
|
||||
* The logos turned a corridor full of petrol stations into a row of advertisements, were
|
||||
* unreadable at pin size, and a brand with no logo on file fell back to a different
|
||||
* picture entirely — so no two pins looked alike. A flat disc in the category's colour
|
||||
* with its icon is what the rail, the corridor list and the map all show now.
|
||||
*/
|
||||
export function brandLogoMarkup(_brandWikidata?: string | null): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
export function MapViewGL({
|
||||
places = NO_PLACES,
|
||||
dayPlaces = NO_PLACES,
|
||||
tripId,
|
||||
routeVias = NO_ROUTE_VIAS,
|
||||
accessLines = NO_ACCESS_LINES,
|
||||
route = null,
|
||||
routeSegments = NO_ROUTE_SEGMENTS,
|
||||
selectedPlaceId = null,
|
||||
@@ -420,6 +561,8 @@ export function MapViewGL({
|
||||
center = DEFAULT_MAP_CENTER,
|
||||
zoom = DEFAULT_MAP_ZOOM,
|
||||
fitKey = 0,
|
||||
focusPoints,
|
||||
clusterLoosely = false,
|
||||
dayOrderMap = NO_DAY_ORDER,
|
||||
leftWidth = 0,
|
||||
rightWidth = 0,
|
||||
@@ -434,6 +577,15 @@ export function MapViewGL({
|
||||
onReservationClick,
|
||||
pois = NO_POIS,
|
||||
onPoiClick,
|
||||
onPoiDropOnRoute,
|
||||
onRouteClick,
|
||||
alternativeRoutes,
|
||||
activeAlternative,
|
||||
onChooseAlternative,
|
||||
onHighlightAlternative,
|
||||
roadtripVias,
|
||||
onMoveVia,
|
||||
onRemoveVia,
|
||||
onViewportChange,
|
||||
glProvider = 'mapbox-gl',
|
||||
gl,
|
||||
@@ -480,31 +632,336 @@ export function MapViewGL({
|
||||
const markersRef = useRef<Map<number, PlacePin>>(new Map())
|
||||
// Own layer for the hand-positioned place pins (MapLibre path, see makePlacePin).
|
||||
const pinLayerRef = useRef<HTMLDivElement | null>(null)
|
||||
const repositionPins = useCallback(() => {
|
||||
markersRef.current.forEach(pin => pin.reposition())
|
||||
}, [])
|
||||
const locationMarkerRef = useRef<LocationMarkerHandle | null>(null)
|
||||
const reservationOverlayRef = useRef<ReservationMapboxOverlay | null>(null)
|
||||
// Refs so the reservation overlay always sees the latest callback /
|
||||
// options without forcing a full overlay rebuild on every prop change.
|
||||
const onReservationClickRef = useRef(onReservationClick)
|
||||
onReservationClickRef.current = onReservationClick
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const poiMarkersRef = useRef<any[]>([])
|
||||
const poiMarkersRef = useRef<PlacePin[]>([])
|
||||
/** Undoes the drag wiring on each POI pin; the pins themselves are rebuilt wholesale. */
|
||||
const poiCleanupRef = useRef<(() => void)[]>([])
|
||||
const onPoiDropRef = useRef(onPoiDropOnRoute)
|
||||
onPoiDropRef.current = onPoiDropOnRoute
|
||||
// Plugin map contributions — data fetched per trip, elements owned imperatively
|
||||
// like the POI markers so they survive the React render cycle.
|
||||
const [pluginMarkers, setPluginMarkers] = useState<PluginMapMarker[]>([])
|
||||
const [pluginLayers, setPluginLayers] = useState<PluginMapLayer[]>([])
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pluginMarkersRef = useRef<any[]>([])
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const routeViaMarkersRef = useRef<any[]>([])
|
||||
const pluginMarkersRef = useRef<PlacePin[]>([])
|
||||
const routeViaMarkersRef = useRef<PlacePin[]>([])
|
||||
/** The road-trip via handles (#1797) — hand-positioned like the rest, so listed here. */
|
||||
const viaPinsRef = useRef<PlacePin[]>([])
|
||||
/** The drive-time pills on the offered routes; same treatment. */
|
||||
const altLabelsRef = useRef<PlacePin[]>([])
|
||||
// Every hand-positioned pin, whichever set it belongs to. They all have to be written
|
||||
// in the same frame the canvas draws, or the ones left out swim against the ones in.
|
||||
const repositionPins = useCallback(() => {
|
||||
markersRef.current.forEach(pin => pin.reposition())
|
||||
poiMarkersRef.current.forEach(pin => pin.reposition())
|
||||
pluginMarkersRef.current.forEach(pin => pin.reposition())
|
||||
routeViaMarkersRef.current.forEach(pin => pin.reposition())
|
||||
viaPinsRef.current.forEach(pin => pin.reposition())
|
||||
altLabelsRef.current.forEach(pin => pin.reposition())
|
||||
}, [])
|
||||
// Single reusable hover popup for POI markers. Planned places use the
|
||||
// cursor-following React tooltip below so they match the Leaflet map.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const popupRef = useRef<any | null>(null)
|
||||
const onPoiClickRef = useRef(onPoiClick)
|
||||
onPoiClickRef.current = onPoiClick
|
||||
|
||||
/**
|
||||
* The via handles.
|
||||
*
|
||||
* Built as plain elements and attached with the same `attachPin` the place pins use,
|
||||
* rather than the library's own marker: under MapLibre those float above the map during
|
||||
* a drag instead of sticking to the ground (the reason `attachPin` exists at all). The
|
||||
* drag is therefore hand-rolled — pointer events on the element, unproject on move.
|
||||
*/
|
||||
const viaCleanupRef = useRef<(() => void)[]>([])
|
||||
/**
|
||||
* The list only changes when a via does, and the callbacks are read through a ref.
|
||||
*
|
||||
* Both matter for the same reason: this effect destroys every handle and builds new DOM
|
||||
* elements. It used to re-run on every render of the planner — the vias arrived as a
|
||||
* fresh object each time and `onMoveVia` as a fresh closure — so the render that landed
|
||||
* when a freshly placed via finished re-routing pulled the element out from under the
|
||||
* pointer, and the first drag went nowhere.
|
||||
*/
|
||||
const vias = useStableVias(roadtripVias)
|
||||
const viaHandlersRef = useRef({ onMoveVia, onRemoveVia })
|
||||
viaHandlersRef.current = { onMoveVia, onRemoveVia }
|
||||
const viasDraggable = !!onMoveVia
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
viaCleanupRef.current.forEach(off => off())
|
||||
viaCleanupRef.current = []
|
||||
viaPinsRef.current.forEach(p => p.remove())
|
||||
viaPinsRef.current = []
|
||||
for (const via of vias) {
|
||||
const el = document.createElement('span')
|
||||
el.style.cssText = 'display:block;width:12px;height:12px;border-radius:9999px;background:#0a84ff;border:2.5px solid #fff;box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;touch-action:none'
|
||||
const pin = attachPin(map, gl, pinLayerRef.current, el, via.lng, via.lat)
|
||||
viaPinsRef.current.push(pin)
|
||||
if (!viasDraggable) continue
|
||||
|
||||
let dragging = false
|
||||
// The map starts panning on mousedown/touchstart anywhere on its surface, and a pin
|
||||
// sits on that surface. Stopping only pointerdown left both running at once: the
|
||||
// handle moved AND the map slid out from under it.
|
||||
const swallow = (e: Event) => e.stopPropagation()
|
||||
// Where the gesture began, so a click can be told from a drag. Without it
|
||||
// every pointerup committed a move to wherever inside the 17px handle the
|
||||
// pointer happened to be: at a low zoom that is kilometres, and the write
|
||||
// re-routes the whole trip. Leaflet gets this for free from `dragend`,
|
||||
// which is why the two renderers behaved differently for one gesture.
|
||||
let startedAt: { x: number; y: number } | null = null
|
||||
const MOVED_ENOUGH = 4
|
||||
const onDown = (e: PointerEvent) => {
|
||||
// Primary button only. Right-clicking ran onDown, then contextmenu ->
|
||||
// remove AND pointerup -> move, so one gesture sent two writes; on the
|
||||
// platforms where contextmenu comes first, the move landed on an id that
|
||||
// no longer existed and reported a failure for a deletion that worked.
|
||||
if (e.button !== 0) return
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
dragging = true
|
||||
startedAt = { x: e.clientX, y: e.clientY }
|
||||
el.setPointerCapture(e.pointerId)
|
||||
el.style.cursor = 'grabbing'
|
||||
}
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!dragging) return
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
|
||||
pin.setLngLat([at.lng, at.lat])
|
||||
}
|
||||
const onUp = (e: PointerEvent) => {
|
||||
if (!dragging) return
|
||||
dragging = false
|
||||
el.style.cursor = 'grab'
|
||||
const from = startedAt
|
||||
startedAt = null
|
||||
const travelled = from ? Math.hypot(e.clientX - from.x, e.clientY - from.y) : 0
|
||||
if (travelled < MOVED_ENOUGH) {
|
||||
// A click, not a drag. Put the pin back where the via actually is: the
|
||||
// move handler has been following the pointer across the handle.
|
||||
pin.setLngLat([via.lng, via.lat])
|
||||
return
|
||||
}
|
||||
const rect = map.getContainer().getBoundingClientRect()
|
||||
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
|
||||
viaHandlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
|
||||
}
|
||||
// The browser fires this when the captured element is torn out of the DOM,
|
||||
// which is exactly what a delete does. Committing a position there wrote a
|
||||
// move for a via that was being removed.
|
||||
const onCancel = () => {
|
||||
if (!dragging) return
|
||||
dragging = false
|
||||
startedAt = null
|
||||
el.style.cursor = 'grab'
|
||||
pin.setLngLat([via.lng, via.lat])
|
||||
}
|
||||
const onContext = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
viaHandlersRef.current.onRemoveVia?.(via.day_id, via.id)
|
||||
}
|
||||
el.addEventListener('pointerdown', onDown)
|
||||
el.addEventListener('pointermove', onMove)
|
||||
el.addEventListener('pointerup', onUp)
|
||||
el.addEventListener('pointercancel', onCancel)
|
||||
el.addEventListener('contextmenu', onContext)
|
||||
el.addEventListener('mousedown', swallow)
|
||||
el.addEventListener('touchstart', swallow, { passive: true })
|
||||
el.addEventListener('dblclick', swallow)
|
||||
viaCleanupRef.current.push(() => {
|
||||
el.removeEventListener('pointerdown', onDown)
|
||||
el.removeEventListener('pointermove', onMove)
|
||||
el.removeEventListener('pointerup', onUp)
|
||||
el.removeEventListener('pointercancel', onCancel)
|
||||
el.removeEventListener('contextmenu', onContext)
|
||||
el.removeEventListener('mousedown', swallow)
|
||||
el.removeEventListener('touchstart', swallow)
|
||||
el.removeEventListener('dblclick', swallow)
|
||||
})
|
||||
}
|
||||
}, [vias, viasDraggable, mapReady, glProvider])
|
||||
|
||||
/**
|
||||
* The offered routes, drawn under the current one.
|
||||
*
|
||||
* Grey and dashed rather than a set of bright lines: at any moment one of them IS the
|
||||
* route, drawn in blue on top, and three more solid colours next to it would read as
|
||||
* four equal roads. Hovering an option in the bar lights that one up in its own colour,
|
||||
* which is the only moment a second colour helps.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
const source = map.getSource?.('route-alternatives')
|
||||
if (!source?.setData) return
|
||||
source.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: (alternativeRoutes ?? []).map((alt, i) => ({
|
||||
type: 'Feature',
|
||||
properties: { index: i, active: activeAlternative === i, color: alt.color },
|
||||
geometry: { type: 'LineString', coordinates: alt.coordinates.map(([lat, lng]) => [lng, lat]) },
|
||||
})),
|
||||
})
|
||||
}, [alternativeRoutes, activeAlternative, mapReady, glProvider])
|
||||
|
||||
/**
|
||||
* The drive time on each offered route, the way Apple Maps labels them.
|
||||
*
|
||||
* Hand-positioned pins rather than a symbol layer: a symbol would need the text in the
|
||||
* style's own font stack, which differs between the bundled MapLibre styles and Mapbox,
|
||||
* and the pill has a shape a text layer cannot draw. They join the render clock like
|
||||
* every other pin here, so they stay on their road while the map moves.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
altLabelsRef.current.forEach(p => p.remove())
|
||||
altLabelsRef.current = []
|
||||
for (const alt of alternativeRoutes ?? []) {
|
||||
const el = document.createElement('button')
|
||||
el.type = 'button'
|
||||
const active = activeAlternative === alt.index
|
||||
el.style.cssText = [
|
||||
// A rounded rectangle rather than a pill, and roomy enough for a second line —
|
||||
// the shape Apple gives these, and the reason "1 h 55 min / Fastest" reads as one
|
||||
// label instead of two stacked chips.
|
||||
'display:flex;flex-direction:column;align-items:flex-start;white-space:nowrap',
|
||||
`background:${alt.labelBg}`,
|
||||
`color:${ALT_LABEL_TEXT}`,
|
||||
'border:none;border-radius:11px',
|
||||
// A <button> does not inherit the page's font, so without this the label reads in
|
||||
// the browser's UI face while every other piece of TREK chrome reads in Poppins.
|
||||
'font-family:var(--font-system)',
|
||||
'padding:6px 11px;font-size:13px;font-weight:600;line-height:1.25',
|
||||
'box-shadow:0 2px 10px rgba(0,0,0,.35)',
|
||||
'cursor:pointer;pointer-events:auto',
|
||||
// The pin is positioned by writing `transform` every frame. Any inherited
|
||||
// transition on it makes the label chase the map instead of sticking to the road,
|
||||
// and `transform` is exactly what a button picks up from the app's own styles.
|
||||
'transition:none !important;animation:none !important',
|
||||
'will-change:transform;backface-visibility:hidden',
|
||||
active ? 'outline:2px solid #fff;outline-offset:1px' : '',
|
||||
].filter(Boolean).join(';')
|
||||
const time = document.createElement('span')
|
||||
time.textContent = alt.label
|
||||
el.appendChild(time)
|
||||
if (alt.note) {
|
||||
const note = document.createElement('span')
|
||||
note.textContent = alt.note
|
||||
note.style.cssText = 'font-size:11.5px;font-weight:500;opacity:.85;line-height:1.2'
|
||||
el.appendChild(note)
|
||||
}
|
||||
// Same reason as the via handles: without this the map pans as the label is pressed.
|
||||
el.addEventListener('mousedown', e => e.stopPropagation())
|
||||
el.addEventListener('touchstart', e => e.stopPropagation(), { passive: true })
|
||||
el.addEventListener('click', e => { e.stopPropagation(); onChooseAlternativeRef.current?.(alt.index) })
|
||||
el.addEventListener('mouseenter', () => onHighlightAlternativeRef.current?.(alt.index))
|
||||
el.addEventListener('mouseleave', () => onHighlightAlternativeRef.current?.(null))
|
||||
altLabelsRef.current.push(attachPin(map, gl, pinLayerRef.current, el, alt.at.lng, alt.at.lat))
|
||||
}
|
||||
return () => {
|
||||
altLabelsRef.current.forEach(p => p.remove())
|
||||
altLabelsRef.current = []
|
||||
}
|
||||
}, [alternativeRoutes, activeAlternative, mapReady, glProvider])
|
||||
|
||||
/** Clicking one of the offered routes takes it, the same as clicking its chip. */
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady || !onChooseAlternative) return
|
||||
const onClick = (e: { features?: { properties?: { index?: number } }[] }) => {
|
||||
const index = e.features?.[0]?.properties?.index
|
||||
if (typeof index === 'number') onChooseAlternative(index)
|
||||
}
|
||||
const enter = () => { map.getCanvas().style.cursor = 'pointer' }
|
||||
const leave = () => { map.getCanvas().style.cursor = '' }
|
||||
if (!map.getLayer?.('route-alt-hit')) return
|
||||
map.on('click', 'route-alt-hit', onClick)
|
||||
map.on('mouseenter', 'route-alt-hit', enter)
|
||||
map.on('mouseleave', 'route-alt-hit', leave)
|
||||
return () => {
|
||||
map.off('click', 'route-alt-hit', onClick)
|
||||
map.off('mouseenter', 'route-alt-hit', enter)
|
||||
map.off('mouseleave', 'route-alt-hit', leave)
|
||||
}
|
||||
}, [mapReady, onChooseAlternative])
|
||||
|
||||
/**
|
||||
* A click on the route line, which in a GL renderer is a layer rather than an element.
|
||||
* `map.on(type, layerId, …)` is the one way to hit it, and it fires before the map's own
|
||||
* click, so the handler can keep the add-place menu from opening underneath the via.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady || !onRouteClick) return
|
||||
const onClick = (e: { lngLat: { lat: number; lng: number }; point?: { x: number; y: number }; preventDefault?: () => void }) => {
|
||||
e.preventDefault?.()
|
||||
// Layer handlers are independent: MapLibre evaluates each registration
|
||||
// against the same click, so a point that hits both the offered-route band
|
||||
// and the current-route band fires both. Every alternative starts and ends
|
||||
// at the same two stops, and when the leg already carries vias the current
|
||||
// road is itself one of the offers, so the overlap is the whole leg. One
|
||||
// click then dropped a via AND chose a different road, two writes and two
|
||||
// trip reloads for a gesture the user made once. The picker is a modal
|
||||
// choice about that leg; dropping a via mid-choice is not offered anywhere.
|
||||
if (e.point && map.queryRenderedFeatures?.(e.point, { layers: ['route-alt-hit'] })?.length) return
|
||||
onRouteClick(e.lngLat.lat, e.lngLat.lng)
|
||||
}
|
||||
const enter = () => { map.getCanvas().style.cursor = 'copy' }
|
||||
const leave = () => { map.getCanvas().style.cursor = '' }
|
||||
for (const layer of ['trip-route-hit']) {
|
||||
if (!map.getLayer?.(layer)) continue
|
||||
map.on('click', layer, onClick)
|
||||
map.on('mouseenter', layer, enter)
|
||||
map.on('mouseleave', layer, leave)
|
||||
}
|
||||
return () => {
|
||||
for (const layer of ['trip-route-hit']) {
|
||||
map.off('click', layer, onClick)
|
||||
map.off('mouseenter', layer, enter)
|
||||
map.off('mouseleave', layer, leave)
|
||||
}
|
||||
}
|
||||
}, [mapReady, onRouteClick])
|
||||
|
||||
/**
|
||||
* The map takes the drop, not the drawn route: in a GL renderer the line lives in the
|
||||
* canvas and has no element to aim at. The coordinate under the pointer says just as
|
||||
* much once it is projected onto the routed geometry, and it works the same in all
|
||||
* three renderers.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
const container = map?.getContainer?.()
|
||||
if (!container || !mapReady || !onPoiDropOnRoute) return
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!draggedPoiId(e)) return
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const osmId = draggedPoiId(e)
|
||||
if (!osmId) return
|
||||
e.preventDefault()
|
||||
const rect = container.getBoundingClientRect()
|
||||
const at = map.unproject([e.clientX - rect.left, e.clientY - rect.top])
|
||||
onPoiDropOnRoute(osmId, at.lat, at.lng)
|
||||
}
|
||||
container.addEventListener('dragover', onDragOver)
|
||||
container.addEventListener('drop', onDrop)
|
||||
return () => {
|
||||
container.removeEventListener('dragover', onDragOver)
|
||||
container.removeEventListener('drop', onDrop)
|
||||
}
|
||||
}, [mapReady, onPoiDropOnRoute])
|
||||
const onViewportChangeRef = useRef(onViewportChange)
|
||||
onViewportChangeRef.current = onViewportChange
|
||||
const onMapReadyRef = useRef(onMapReady)
|
||||
@@ -516,6 +973,13 @@ export function MapViewGL({
|
||||
onClickRefs.current.context = onMapContextMenu
|
||||
const hoverDisabledRef = useRef(hoverDisabled)
|
||||
hoverDisabledRef.current = hoverDisabled
|
||||
// Read inside the map's own click handler, which is registered once at setup.
|
||||
const onRouteClickRef = useRef(onRouteClick)
|
||||
onRouteClickRef.current = onRouteClick
|
||||
const onChooseAlternativeRef = useRef(onChooseAlternative)
|
||||
onChooseAlternativeRef.current = onChooseAlternative
|
||||
const onHighlightAlternativeRef = useRef(onHighlightAlternative)
|
||||
onHighlightAlternativeRef.current = onHighlightAlternative
|
||||
// Same gate as the Leaflet renderer: HTML5 drag is a pointer feature, and the
|
||||
// day plan the marker would be dropped on is not on screen on a phone anyway.
|
||||
const markersDraggableRef = useRef(typeof window !== 'undefined' && navigator.maxTouchPoints === 0)
|
||||
@@ -599,6 +1063,49 @@ export function MapViewGL({
|
||||
if (glStyle === MAPBOX_DEFAULT_STYLE) {
|
||||
try { map.setTerrain(null) } catch { /* noop */ }
|
||||
}
|
||||
// The ways of driving one leg, offered while the picker is open (#1797). Added
|
||||
// BEFORE the route source so its layers sit underneath: the current route stays the
|
||||
// brightest thing on the map, and the options read as what they are — suggestions.
|
||||
if (!map.getSource('route-alternatives')) {
|
||||
map.addSource('route-alternatives', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
// A wide invisible band per option, so a pointer can pick one without hitting
|
||||
// the 3px line exactly.
|
||||
map.addLayer({
|
||||
id: 'route-alt-hit',
|
||||
type: 'line',
|
||||
source: 'route-alternatives',
|
||||
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 22 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
// A white casing under the option lines, the same trick the main route uses.
|
||||
// Plain grey disappeared on a grey basemap — Positron is mostly greys, and a
|
||||
// grey dashed line on it is invisible. The casing gives every option an edge, so
|
||||
// it reads on a pale map, a dark one and a satellite tile alike.
|
||||
map.addLayer({
|
||||
id: 'route-alt-casing',
|
||||
type: 'line',
|
||||
source: 'route-alternatives',
|
||||
paint: { 'line-color': ALT_CASING, 'line-width': 8, 'line-opacity': 0.9 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'route-alt-line',
|
||||
type: 'line',
|
||||
source: 'route-alternatives',
|
||||
// Dark and dashed by default so it stays subordinate to the blue route it is an
|
||||
// alternative to; the one being considered takes its own colour and thickens.
|
||||
// Every option in blue, after Apple Maps: they are all real roads, so the
|
||||
// difference between them is emphasis rather than category. The one being
|
||||
// considered thickens; nothing turns grey, because grey vanishes on Positron.
|
||||
paint: {
|
||||
'line-color': ['coalesce', ['get', 'color'], '#7eb8f0'],
|
||||
'line-width': ['case', ['boolean', ['get', 'active'], false], 6, 4],
|
||||
'line-opacity': ['case', ['boolean', ['get', 'active'], false], 1, 0.9],
|
||||
},
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
|
||||
// initial route source — kept around so updates can setData() cheaply
|
||||
if (!map.getSource('trip-route')) {
|
||||
map.addSource('trip-route', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
@@ -611,6 +1118,16 @@ export function MapViewGL({
|
||||
paint: { 'line-color': '#0a5cc2', 'line-width': 8 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
// An invisible band over the route, purely to be clicked. The drawn line is 8px
|
||||
// and a pointer is not that accurate, so aiming at the road was most of why
|
||||
// putting a via there felt like it did not work.
|
||||
map.addLayer({
|
||||
id: 'trip-route-hit',
|
||||
type: 'line',
|
||||
source: 'trip-route',
|
||||
paint: { 'line-color': '#000000', 'line-opacity': 0, 'line-width': 26 },
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
map.addLayer({
|
||||
id: 'trip-route-line',
|
||||
type: 'line',
|
||||
@@ -619,6 +1136,21 @@ export function MapViewGL({
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
})
|
||||
}
|
||||
// The last bit to a place the road does not reach (#1797). Added after the route
|
||||
// so it draws over it: it is the one piece of the line that is not driving, and it
|
||||
// has to be readable exactly where it meets the road it leaves. Same blue, because
|
||||
// it is the end of that route rather than a second one; dashed short and thin,
|
||||
// which is how a map says "on foot from here" without a legend.
|
||||
if (!map.getSource('trip-access')) {
|
||||
map.addSource('trip-access', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
map.addLayer({
|
||||
id: 'trip-access-line',
|
||||
type: 'line',
|
||||
source: 'trip-access',
|
||||
paint: { 'line-color': '#0a84ff', 'line-width': 3, 'line-opacity': 0.85, 'line-dasharray': [1, 2.5] },
|
||||
layout: { 'line-cap': 'round' },
|
||||
})
|
||||
}
|
||||
// gpx geometries source (place.route_geometry)
|
||||
if (!map.getSource('trip-gpx')) {
|
||||
map.addSource('trip-gpx', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
@@ -681,53 +1213,7 @@ export function MapViewGL({
|
||||
map.on('mouseleave', GPX_HIT_LAYER_ID, clearTrackCursor)
|
||||
}
|
||||
if (!map.getSource(PLACE_CLUSTER_SOURCE_ID)) {
|
||||
map.addSource(PLACE_CLUSTER_SOURCE_ID, {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
clusterRadius: 30,
|
||||
clusterMaxZoom: 10,
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_CIRCLE_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
paint: {
|
||||
'circle-color': '#111827',
|
||||
'circle-opacity': 0.97,
|
||||
'circle-radius': ['step', ['get', 'point_count'], 18, 10, 21, 50, 24],
|
||||
'circle-stroke-width': 2.5,
|
||||
'circle-stroke-color': 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_CLUSTER_COUNT_LAYER_ID,
|
||||
type: 'symbol',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['has', 'point_count'],
|
||||
layout: {
|
||||
'text-field': ['get', 'point_count_abbreviated'],
|
||||
'text-size': 12,
|
||||
'text-allow-overlap': true,
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(17,24,39,0.35)',
|
||||
'text-halo-width': 1,
|
||||
},
|
||||
})
|
||||
map.addLayer({
|
||||
id: PLACE_UNCLUSTERED_LAYER_ID,
|
||||
type: 'circle',
|
||||
source: PLACE_CLUSTER_SOURCE_ID,
|
||||
filter: ['!', ['has', 'point_count']],
|
||||
paint: {
|
||||
'circle-radius': 24,
|
||||
'circle-opacity': 0,
|
||||
'circle-stroke-opacity': 0,
|
||||
},
|
||||
})
|
||||
addPlaceClusterLayers(map)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const zoomToCluster = (e: any) => {
|
||||
const features = typeof map.queryRenderedFeatures === 'function'
|
||||
@@ -845,6 +1331,15 @@ export function MapViewGL({
|
||||
&& typeof map.queryRenderedFeatures === 'function'
|
||||
&& map.queryRenderedFeatures(e.point, { layers: [GPX_HIT_LAYER_ID] }).length > 0
|
||||
) return
|
||||
// And for the drive itself while it can be reshaped: that click just put a via
|
||||
// there, and it must not also drop a place on top of it (#1797).
|
||||
if (
|
||||
onRouteClickRef.current
|
||||
&& typeof map.getLayer === 'function'
|
||||
&& map.getLayer('trip-route-hit')
|
||||
&& typeof map.queryRenderedFeatures === 'function'
|
||||
&& map.queryRenderedFeatures(e.point, { layers: ['trip-route-hit'] }).length > 0
|
||||
) return
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
})
|
||||
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
|
||||
@@ -1168,12 +1663,7 @@ export function MapViewGL({
|
||||
// pitch. Tried `pitchAlignment: 'map'` to snap markers onto terrain,
|
||||
// but it rotates the element by the pitch angle and visually offsets
|
||||
// the anchor by ~100px at 45° tilt, which caused the observed drift.
|
||||
const pin = isMapLibre && pinLayerRef.current
|
||||
? makePlacePin(map, pinLayerRef.current, el, place.lng, place.lat)
|
||||
: libraryPin(new gl.Marker({ element: el, anchor: 'center' })
|
||||
.setLngLat([place.lng, place.lat])
|
||||
.addTo(map))
|
||||
markersRef.current.set(place.id, pin)
|
||||
markersRef.current.set(place.id, attachPin(map, gl, pinLayerRef.current, el, place.lng, place.lat))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1185,6 +1675,18 @@ export function MapViewGL({
|
||||
return
|
||||
}
|
||||
|
||||
// Road trip mode does not cluster at all: the whole drive has to stay readable
|
||||
// zoomed right out, and a day's stops merging into one dot is exactly the shape it
|
||||
// is being looked at for. The source is emptied rather than reconfigured — the
|
||||
// cluster options can only be set when a GeoJSON source is created, and dropping and
|
||||
// re-adding a source under three layers to change a radius is not worth it when the
|
||||
// markers are drawn from `places` anyway.
|
||||
if (clusterLoosely) {
|
||||
source.setData({ type: 'FeatureCollection', features: [] })
|
||||
reconcileMarkers(validPlaces)
|
||||
return
|
||||
}
|
||||
|
||||
source.setData(buildPlaceClusterData(places) as any)
|
||||
const placesById = new Map<number, PlaceWithCoords>(validPlaces.map(place => [place.id, place]))
|
||||
let raf: number | null = null
|
||||
@@ -1223,7 +1725,7 @@ export function MapViewGL({
|
||||
map.off('zoomend', scheduleReconcile)
|
||||
map.off('idle', scheduleReconcile)
|
||||
}
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider])
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls, mapReady, glProvider, clusterLoosely])
|
||||
|
||||
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
|
||||
// planned-place markers so they don't cluster or get confused with them).
|
||||
@@ -1233,15 +1735,19 @@ export function MapViewGL({
|
||||
popupRef.current?.remove() // same orphan-popup guard as the place markers
|
||||
poiMarkersRef.current.forEach(m => m.remove())
|
||||
poiMarkersRef.current = []
|
||||
poiCleanupRef.current.forEach(off => off())
|
||||
poiCleanupRef.current = []
|
||||
for (const poi of (pois as Poi[])) {
|
||||
const el = createPoiMarkerElement(poi.category)
|
||||
const el = createPoiMarkerElement(poi.category, poi.brand_wikidata)
|
||||
el.addEventListener('mouseenter', () => {
|
||||
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
|
||||
})
|
||||
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
||||
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
||||
poiMarkersRef.current.push(m)
|
||||
// Dragging a hit onto the drive puts it where it is passed, rather than where the
|
||||
// corridor happened to project it. Only wired when someone is listening for it.
|
||||
if (onPoiDropRef.current) poiCleanupRef.current.push(makePoiDraggable(el, poi.osm_id))
|
||||
poiMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, poi.lng, poi.lat))
|
||||
}
|
||||
}, [pois, mapReady, glProvider])
|
||||
|
||||
@@ -1287,8 +1793,7 @@ export function MapViewGL({
|
||||
popupRef.current?.setLngLat([v.lng, v.lat]).setText(text).addTo(map)
|
||||
})
|
||||
}
|
||||
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([v.lng, v.lat]).addTo(map)
|
||||
routeViaMarkersRef.current.push(m)
|
||||
routeViaMarkersRef.current.push(attachPin(map, gl, pinLayerRef.current, el, v.lng, v.lat))
|
||||
}
|
||||
}, [routeVias, mapReady, glProvider])
|
||||
|
||||
@@ -1306,7 +1811,7 @@ export function MapViewGL({
|
||||
popupRef.current?.setLngLat([mk.lng, mk.lat]).setDOMContent(buildPluginMarkerPopup(mk)).addTo(map)
|
||||
})
|
||||
}
|
||||
const m = new gl.Marker({ element: el, anchor: 'center' }).setLngLat([mk.lng, mk.lat]).addTo(map)
|
||||
const m = attachPin(map, gl, pinLayerRef.current, el, mk.lng, mk.lat)
|
||||
pluginMarkersRef.current.push(m)
|
||||
}
|
||||
}, [pluginMarkers, mapReady, glProvider])
|
||||
@@ -1325,6 +1830,22 @@ export function MapViewGL({
|
||||
src.setData({ type: 'FeatureCollection', features })
|
||||
}, [route, mapReady])
|
||||
|
||||
// Update access-spur geojson
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map) return
|
||||
const src = map.getSource('trip-access') as mapboxgl.GeoJSONSource | undefined
|
||||
if (!src) return
|
||||
src.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: (accessLines || []).map(spur => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { meters: Math.round(spur.meters) },
|
||||
geometry: { type: 'LineString' as const, coordinates: spur.line.map(([lat, lng]) => [lng, lat]) },
|
||||
})),
|
||||
})
|
||||
}, [accessLines, mapReady])
|
||||
|
||||
// Travel times now live in the day sidebar (per-segment connectors), not on the map.
|
||||
|
||||
// Update GPX geometries
|
||||
@@ -1452,6 +1973,25 @@ export function MapViewGL({
|
||||
if (routeArrivedForPendingFit) pendingRouteFitRef.current = null
|
||||
}, [fitKey, routeFitKey]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Frame whatever was handed over. Nothing happens when it empties, so closing the
|
||||
// picker leaves the map where the user left it rather than snapping back.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !focusPoints?.length) return
|
||||
const bounds = new gl.LngLatBounds()
|
||||
focusPoints.forEach(([lat, lng]) => bounds.extend([lng, lat]))
|
||||
// A day fit still waiting on its route must not overwrite this a moment later.
|
||||
pendingRouteFitRef.current = null
|
||||
try {
|
||||
map.fitBounds(bounds, {
|
||||
padding: paddingOpts,
|
||||
maxZoom: 15,
|
||||
pitch: enableMapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}, [focusPoints]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// flyTo selected place
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, { useMemo, useRef } from 'react'
|
||||
import { Marker, Tooltip } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The handles that shape a drive.
|
||||
*
|
||||
* A via is deliberately small and unnumbered: it is not a stop, and anything that looks
|
||||
* like one would read as a place on the itinerary. Dragging it redraws the route through
|
||||
* the new spot, which is the whole interaction — the same one a paper map gets when you
|
||||
* put a finger on the road you actually want to take.
|
||||
*/
|
||||
const VIA_ICON = L.divIcon({
|
||||
className: '',
|
||||
html: `<span style="
|
||||
display:block;width:12px;height:12px;border-radius:9999px;
|
||||
background:#0a84ff;border:2.5px solid #ffffff;
|
||||
box-shadow:0 1px 4px rgba(0,0,0,.45);cursor:grab;
|
||||
"></span>`,
|
||||
iconSize: [12, 12],
|
||||
iconAnchor: [6, 6],
|
||||
})
|
||||
|
||||
interface RoadtripViaMarkersProps {
|
||||
viasByDay: Record<number, RoadtripVia[]>
|
||||
onMoveVia?: (dayId: number, id: number, lat: number, lng: number) => void
|
||||
onRemoveVia?: (dayId: number, id: number) => void
|
||||
}
|
||||
|
||||
/** Leaflet's half; the GL renderers draw their own inside `MapViewGL`. */
|
||||
export default function RoadtripViaMarkers({
|
||||
viasByDay, onMoveVia, onRemoveVia,
|
||||
}: RoadtripViaMarkersProps): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const vias = useStableVias(viasByDay)
|
||||
// Read through a ref so the handlers below can stay the same objects across renders.
|
||||
// Both callbacks are rebuilt on every render of the planner, and react-leaflet compares
|
||||
// its props by identity: a fresh `eventHandlers` unbinds and rebinds every listener,
|
||||
// and a fresh `position` array calls `setLatLng` on every marker. Doing either while a
|
||||
// drag is in flight is how a freshly placed via lost its first drag.
|
||||
const handlersRef = useRef({ onMoveVia, onRemoveVia })
|
||||
handlersRef.current = { onMoveVia, onRemoveVia }
|
||||
const draggable = !!onMoveVia
|
||||
|
||||
const markers = useMemo(() => vias.map(via => ({
|
||||
via,
|
||||
position: [via.lat, via.lng] as [number, number],
|
||||
handlers: {
|
||||
dragend: (e: { target: { getLatLng: () => { lat: number; lng: number } } }) => {
|
||||
const at = e.target.getLatLng()
|
||||
handlersRef.current.onMoveVia?.(via.day_id, via.id, at.lat, at.lng)
|
||||
},
|
||||
// Right-click rather than a delete handle: a 12px dot has no room for one,
|
||||
// and the same gesture removes things elsewhere on the map.
|
||||
contextmenu: (e: { originalEvent: MouseEvent }) => {
|
||||
e.originalEvent.preventDefault()
|
||||
handlersRef.current.onRemoveVia?.(via.day_id, via.id)
|
||||
},
|
||||
},
|
||||
})), [vias])
|
||||
|
||||
if (!markers.length) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{markers.map(({ via, position, handlers }) => (
|
||||
<Marker
|
||||
key={`via-${via.id}`}
|
||||
position={position}
|
||||
icon={VIA_ICON}
|
||||
draggable={draggable}
|
||||
zIndexOffset={400}
|
||||
eventHandlers={handlers}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
{t('roadtrip.via.hint')}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
generateCoMapsUrl,
|
||||
parsePluginProfile,
|
||||
withHotelBookends,
|
||||
calculateAlternatives,
|
||||
} from './RouteCalculator'
|
||||
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
// calculateRouteWithLegs talks to the FOSSGIS per-profile hosts, not the car-only demo.
|
||||
// Every route now goes to the FOSSGIS per-profile hosts. The car-only project-osrm.org
|
||||
// demo ignored the profile in the URL, so walking routes followed the road network.
|
||||
const FOSSGIS = {
|
||||
driving: 'https://routing.openstreetmap.de/routed-car/route/v1/driving',
|
||||
walking: 'https://routing.openstreetmap.de/routed-foot/route/v1/foot',
|
||||
@@ -46,7 +47,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-002: returns parsed coordinates on success', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse())
|
||||
)
|
||||
)
|
||||
@@ -56,7 +57,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-003: returns formatted distance text for >= 1000 m', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(1500, 360))
|
||||
)
|
||||
)
|
||||
@@ -66,7 +67,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-004: returns formatted distance in meters for short routes', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(800, 360))
|
||||
)
|
||||
)
|
||||
@@ -78,7 +79,7 @@ describe('calculateRoute', () => {
|
||||
const distance = 5000
|
||||
const osrmDuration = 999
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/walking/:coords`, () =>
|
||||
http.get(`${FOSSGIS.walking}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(distance, osrmDuration))
|
||||
)
|
||||
)
|
||||
@@ -90,7 +91,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-006: throws when OSRM returns non-ok HTTP status', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({}, { status: 500 })
|
||||
)
|
||||
)
|
||||
@@ -99,7 +100,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-007: throws when OSRM code is not Ok', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({ code: 'NoRoute', routes: [] })
|
||||
)
|
||||
)
|
||||
@@ -108,7 +109,7 @@ describe('calculateRoute', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-008: respects AbortSignal', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse())
|
||||
)
|
||||
)
|
||||
@@ -128,7 +129,7 @@ describe('calculateSegments', () => {
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-010: returns segment midpoints and travel times', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/driving/:coords`, () =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({
|
||||
code: 'Ok',
|
||||
routes: [
|
||||
@@ -325,7 +326,7 @@ describe('parsePluginProfile', () => {
|
||||
describe('calculateRoute profiles', () => {
|
||||
it('FE-COMP-ROUTECALCULATOR-030: cycling overrides the OSRM duration with a 15 km/h estimate', async () => {
|
||||
server.use(
|
||||
http.get(`${OSRM_BASE}/cycling/:coords`, () =>
|
||||
http.get(`${FOSSGIS.cycling}/:coords`, () =>
|
||||
HttpResponse.json(buildOsrmRouteResponse(9000, 4242))
|
||||
)
|
||||
)
|
||||
@@ -340,12 +341,12 @@ describe('calculateRoute profiles', () => {
|
||||
|
||||
describe('calculateSegments failures', () => {
|
||||
it('FE-COMP-ROUTECALCULATOR-031: throws when OSRM answers with an HTTP error', async () => {
|
||||
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({}, { status: 502 })))
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 502 })))
|
||||
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('Route could not be calculated')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-032: throws when OSRM reports no usable route', async () => {
|
||||
server.use(http.get(`${OSRM_BASE}/driving/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'NoRoute', routes: [] })))
|
||||
await expect(calculateSegments([wp1, wp2])).rejects.toThrow('No route found')
|
||||
})
|
||||
})
|
||||
@@ -613,3 +614,251 @@ describe('generateCoMapsUrl', () => {
|
||||
.toBe('https://comaps.at/map?v=1&ll=48.85,2.35&n=48.85%2C2.35')
|
||||
})
|
||||
})
|
||||
|
||||
describe('a self-hosted routing engine', () => {
|
||||
afterEach(() => {
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
|
||||
})
|
||||
|
||||
const useOwnRouter = (base: string) =>
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: base } }))
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-021: a configured instance is asked instead of the public hosts', async () => {
|
||||
useOwnRouter('https://osrm.example.org')
|
||||
let asked = ''
|
||||
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
|
||||
|
||||
expect(asked).toContain('osrm.example.org')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-022: OSRM’s own profile names are used, not TREK’s', async () => {
|
||||
useOwnRouter('https://osrm.example.org')
|
||||
let asked = ''
|
||||
// TREK says "walking" and "cycling"; osrm-routed serves "foot" and "bike".
|
||||
server.use(http.get('https://osrm.example.org/route/v1/foot/:coords', ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'walking')
|
||||
|
||||
expect(asked).toContain('/route/v1/foot/')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-023: a trailing slash in the setting does not double up', async () => {
|
||||
useOwnRouter('https://osrm.example.org///')
|
||||
let asked = ''
|
||||
server.use(http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
|
||||
|
||||
expect(asked).not.toContain('//route')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-024: blank or whitespace falls back to the public hosts', async () => {
|
||||
useOwnRouter(' ')
|
||||
let asked = ''
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(buildOsrmRouteResponse())
|
||||
}))
|
||||
|
||||
await calculateRoute([{ lat: 1, lng: 2 }, { lat: 3, lng: 4 }], 'driving')
|
||||
|
||||
expect(asked).toContain('routing.openstreetmap.de')
|
||||
})
|
||||
})
|
||||
|
||||
describe('calculateAlternatives', () => {
|
||||
const twoRoutes = {
|
||||
code: 'Ok',
|
||||
routes: [
|
||||
// The direct one, straight down the middle.
|
||||
{ geometry: { coordinates: [[10, 53], [10.5, 52.5], [11, 52]] }, distance: 100000, duration: 3600 },
|
||||
// A detour that swings well to the east before rejoining.
|
||||
{ geometry: { coordinates: [[10, 53], [12.5, 52.5], [11, 52]] }, distance: 130000, duration: 4500 },
|
||||
],
|
||||
}
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-025: asks for alternatives between exactly two points', async () => {
|
||||
let asked = ''
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
asked = request.url
|
||||
return HttpResponse.json(twoRoutes)
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(asked).toContain('alternatives=3')
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[0].distance).toBe(100000)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-026: each alternative carries the point that makes it different', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json(twoRoutes)))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
// The first is what the router gives anyway, so it needs no pinning point.
|
||||
expect(routes[0].divergence).toBeNull()
|
||||
// The second diverges at its eastern swing — saving that as a via forces this road.
|
||||
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
|
||||
})
|
||||
|
||||
/** The router's own answer, and whatever it says when a road class is left out. */
|
||||
const withExclusions = (byExclude: Record<string, unknown>) =>
|
||||
http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
const answer = byExclude[exclude]
|
||||
return answer ? HttpResponse.json(answer) : HttpResponse.json({ code: 'NoRoute', routes: [] })
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-027: a road that is the same road either way is not offered twice', async () => {
|
||||
// Leaving the motorway out changed nothing — the leg does not touch one. Offering
|
||||
// the identical line a second time would be a choice that is not a choice.
|
||||
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[0]] }, toll: { code: 'Ok', routes: [twoRoutes.routes[0]] } }))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0].divergence).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-029: one answer from the router still offers the motorway-free way', async () => {
|
||||
// OSRM answers most long legs with exactly one route, which used to mean the picker
|
||||
// said there was no other way — while a perfectly good slower road existed.
|
||||
server.use(withExclusions({ motorway: { code: 'Ok', routes: [twoRoutes.routes[1]] } }))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[1].avoids).toBe('motorway')
|
||||
// Pinnable like any other offer: the point where it leaves the direct line.
|
||||
expect(routes[1].divergence).toEqual({ lat: 52.5, lng: 12.5 })
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-030: tolls are only asked about when the motorway gave nothing', async () => {
|
||||
const asked: string[] = []
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (exclude) asked.push(exclude)
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
if (exclude === 'motorway') return HttpResponse.json({ code: 'NoRoute', routes: [] })
|
||||
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] })
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(asked).toEqual(['motorway', 'toll'])
|
||||
expect(routes[1].avoids).toBe('toll')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-031: walking is never asked to leave out a road class', async () => {
|
||||
// The foot profile has no excludable classes; asking earns an InvalidOptions for
|
||||
// nothing, and a walk has no motorway to avoid in the first place.
|
||||
const asked: string[] = []
|
||||
server.use(http.get(`${FOSSGIS.walking}/:coords`, ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (exclude) asked.push(exclude)
|
||||
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
}))
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }, 'walking')
|
||||
|
||||
expect(asked).toEqual([])
|
||||
expect(routes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-032: a host that rejects exclude is only asked once', async () => {
|
||||
// Both public hosts TREK ships with answer 400 here, so without this every long leg
|
||||
// pays two extra requests against a one-per-second limit to be refused twice.
|
||||
const asked: string[] = []
|
||||
server.use(http.get('*/route/v1/driving/*', ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
asked.push(exclude)
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
}))
|
||||
|
||||
await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 13 }, 'driving')
|
||||
const afterFirst = asked.length
|
||||
await calculateAlternatives({ lat: 51, lng: 9 }, { lat: 50, lng: 12 }, 'driving')
|
||||
|
||||
expect(afterFirst).toBe(1)
|
||||
expect(asked).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-033: the snap positions come back with the route', async () => {
|
||||
// OSRM reports these in every answer and TREK threw them away since the first route
|
||||
// was drawn. Without them a place set back from the road looks like it is on the
|
||||
// route, while the drive really starts somewhere else.
|
||||
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
|
||||
code: 'Ok',
|
||||
waypoints: [
|
||||
{ location: [10.02, 53.51], distance: 812.5 },
|
||||
{ location: [13.0, 52.0], distance: 4 },
|
||||
],
|
||||
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
|
||||
})))
|
||||
|
||||
const r = await calculateRouteWithLegs([{ lat: 53.5, lng: 10.0 }, { lat: 52, lng: 13 }])
|
||||
|
||||
expect(r.snapped).toEqual([
|
||||
{ asked: [53.5, 10.0], at: [53.51, 10.02], meters: 812.5 },
|
||||
{ asked: [52, 13], at: [52.0, 13.0], meters: 4 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-034: a distance the router left out is worked out from the two points', async () => {
|
||||
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
|
||||
code: 'Ok',
|
||||
waypoints: [{ location: [10.0, 53.0] }, { location: [13.0, 52.0] }],
|
||||
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
|
||||
})))
|
||||
|
||||
const r = await calculateRouteWithLegs([{ lat: 53.01, lng: 10.0 }, { lat: 52, lng: 13 }])
|
||||
|
||||
// Roughly 1.1 km for a hundredth of a degree of latitude; the point is that it is a
|
||||
// real number rather than the whole answer being discarded.
|
||||
expect(r.snapped?.[0].meters).toBeGreaterThan(1000)
|
||||
expect(r.snapped?.[0].meters).toBeLessThan(1200)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-035: an answer that does not describe its waypoints changes nothing', async () => {
|
||||
// All-or-nothing: a partial list would have to be indexed by waypoint anyway, and one
|
||||
// bad entry would hang the spur off the wrong stop.
|
||||
server.use(http.get('*/route/v1/driving/*', () => HttpResponse.json({
|
||||
code: 'Ok',
|
||||
waypoints: [{ location: [10.0, 53.0] }],
|
||||
routes: [{ geometry: { coordinates: [[10, 53], [13, 52]] }, distance: 1000, duration: 900, legs: [{ distance: 1000, duration: 900 }] }],
|
||||
})))
|
||||
|
||||
const r = await calculateRouteWithLegs([{ lat: 53, lng: 10 }, { lat: 52, lng: 13 }])
|
||||
|
||||
expect(r.snapped).toBeUndefined()
|
||||
expect(r.coordinates).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-028: no route at all is an empty list, not a throw', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () =>
|
||||
HttpResponse.json({ code: 'NoRoute', routes: [] })))
|
||||
|
||||
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-029: a refused request is a RoutingRefusedError, not a generic one', async () => {
|
||||
server.use(http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({}, { status: 429 })))
|
||||
|
||||
await expect(calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 }))
|
||||
.rejects.toMatchObject({ name: 'RoutingRefusedError', status: 429 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { pluginsApi } from '../../api/client'
|
||||
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
|
||||
import type { DistanceUnit, RouteResult, RouteSegment, RouteWithLegs, SnappedWaypoint, Waypoint, RouteAnchors } from '../../types'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
|
||||
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
|
||||
// the matching profile so walking routes follow footpaths, not the road network.
|
||||
@@ -14,6 +13,74 @@ const OSRM_PROFILE_BASE: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
cycling: 'https://routing.openstreetmap.de/routed-bike/route/v1/bike',
|
||||
}
|
||||
|
||||
/** OSRM's own profile names, which a self-hosted instance serves under its own paths. */
|
||||
const OSRM_PROFILE_PATH: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
driving: 'driving',
|
||||
walking: 'foot',
|
||||
cycling: 'bike',
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to ask for a route.
|
||||
*
|
||||
* The public FOSSGIS hosts allow roughly one request a second, which a road trip — every
|
||||
* leg of every day — runs into immediately. An instance that runs its own OSRM sets
|
||||
* `routing_base_url` and everything routes against that instead; the server has to name
|
||||
* the same origin in its connect-src, or the browser blocks the requests silently.
|
||||
*
|
||||
* A configured base is expected to serve the standard OSRM layout,
|
||||
* `<base>/route/v1/<profile>/…`, which is what `osrm-routed` does out of the box.
|
||||
*/
|
||||
/**
|
||||
* A configured base URL without its trailing slashes.
|
||||
*
|
||||
* Walked rather than matched with `/\/+$/`: that pattern backtracks over a run of
|
||||
* slashes, and the value comes from an instance setting somebody types. Linear either
|
||||
* way in practice, but the regex is the shape a scanner is right to flag.
|
||||
*/
|
||||
function withoutTrailingSlashes(value: string): string {
|
||||
let end = value.length
|
||||
while (end > 0 && value[end - 1] === '/') end -= 1
|
||||
return value.slice(0, end)
|
||||
}
|
||||
|
||||
function routeBaseFor(profile: 'driving' | 'walking' | 'cycling'): string {
|
||||
const configured = useSettingsStore.getState().settings.routing_base_url?.trim()
|
||||
if (!configured) return OSRM_PROFILE_BASE[profile]
|
||||
return `${withoutTrailingSlashes(configured)}/route/v1/${OSRM_PROFILE_PATH[profile]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A routing request the host refused, as opposed to one it answered with "no route".
|
||||
*
|
||||
* The public OSRM instances allow roughly one request a second and answer 429 above that.
|
||||
* Both used to arrive as the same generic Error, so a caller could not tell "back off and
|
||||
* try again" from "these two points are not connected by road" — and a road trip, which
|
||||
* asks for every leg of every day, hits the first case constantly while the second is
|
||||
* rare. `retryAfterMs` carries the host's own `Retry-After` when it sends one.
|
||||
*/
|
||||
export class RoutingRefusedError extends Error {
|
||||
constructor(readonly status: number, readonly retryAfterMs: number | null) {
|
||||
super(status === 429 ? 'Routing rate limit reached' : 'Route could not be calculated')
|
||||
this.name = 'RoutingRefusedError'
|
||||
}
|
||||
|
||||
/** True when waiting and asking again is the right response. */
|
||||
get isRateLimit(): boolean {
|
||||
return this.status === 429 || this.status === 503
|
||||
}
|
||||
}
|
||||
|
||||
/** `Retry-After` is either seconds or an HTTP date; anything else is no answer at all. */
|
||||
function retryAfterMs(response: Response): number | null {
|
||||
const raw = response.headers.get('Retry-After')
|
||||
if (!raw) return null
|
||||
const seconds = Number(raw)
|
||||
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000)
|
||||
const at = Date.parse(raw)
|
||||
return Number.isNaN(at) ? null : Math.max(0, at - Date.now())
|
||||
}
|
||||
|
||||
// Cache route responses keyed by the exact waypoint list. Routes are stable, so
|
||||
// this avoids re-hitting the public OSRM demo server on every day switch / reorder.
|
||||
const routeCache = new Map<string, RouteWithLegs>()
|
||||
@@ -45,11 +112,11 @@ export async function calculateRoute(
|
||||
}
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${OSRM_BASE}/${profile}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
const url = `${routeBaseFor(profile)}/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) {
|
||||
throw new Error('Route could not be calculated')
|
||||
throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
@@ -252,10 +319,10 @@ export async function calculateSegments(
|
||||
if (!waypoints || waypoints.length < 2) return []
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${OSRM_BASE}/driving/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
|
||||
const url = `${routeBaseFor('driving')}/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new Error('Route could not be calculated')
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||
@@ -344,10 +411,13 @@ export async function calculateRouteWithLegs(
|
||||
return result
|
||||
}
|
||||
|
||||
const osrmProfile = (profile === 'walking' || profile === 'cycling') ? profile : 'driving'
|
||||
const url = `${OSRM_PROFILE_BASE[osrmProfile]}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||
// Written as literals rather than narrowing `profile`: its type is an open string union
|
||||
// (plugins name their own modes), which no comparison narrows to the three OSRM knows.
|
||||
const osrmProfile: 'driving' | 'walking' | 'cycling' =
|
||||
profile === 'walking' ? 'walking' : profile === 'cycling' ? 'cycling' : 'driving'
|
||||
const url = `${routeBaseFor(osrmProfile)}/${coords}?overview=full&geometries=geojson&annotations=distance,duration`
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new Error('Route could not be calculated')
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||
@@ -374,7 +444,8 @@ export async function calculateRouteWithLegs(
|
||||
}
|
||||
)
|
||||
|
||||
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
|
||||
const snapped = readSnapped(data, waypoints)
|
||||
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
|
||||
routeCache.set(cacheKey, result)
|
||||
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||
const oldest = routeCache.keys().next().value
|
||||
@@ -383,6 +454,37 @@ export async function calculateRouteWithLegs(
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* The snap positions out of an OSRM answer, or undefined if it did not describe them.
|
||||
*
|
||||
* OSRM reports this in every response and TREK has thrown it away since the first route
|
||||
* was drawn. `distance` is the straight line from the coordinate we sent to the road it
|
||||
* used; it is recomputed from the two points when the field is missing or nonsense, so a
|
||||
* mirror that trims its answers still produces a usable gap rather than a wrong one.
|
||||
*
|
||||
* All-or-nothing on purpose: a partial list would have to be indexed by waypoint anyway,
|
||||
* and one bad entry would put a spur on the wrong stop.
|
||||
*/
|
||||
function readSnapped(data: unknown, waypoints: Waypoint[]): SnappedWaypoint[] | undefined {
|
||||
const raw = (data as { waypoints?: unknown })?.waypoints
|
||||
if (!Array.isArray(raw) || raw.length !== waypoints.length) return undefined
|
||||
const out: SnappedWaypoint[] = []
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
const loc = (raw[i] as { location?: unknown })?.location
|
||||
if (!Array.isArray(loc) || loc.length < 2) return undefined
|
||||
const [lng, lat] = loc as [number, number]
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined
|
||||
const asked: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||
const at: [number, number] = [lat, lng]
|
||||
const reported = (raw[i] as { distance?: unknown })?.distance
|
||||
const meters = typeof reported === 'number' && Number.isFinite(reported) && reported >= 0
|
||||
? reported
|
||||
: haversineKm({ lat: asked[0], lng: asked[1] }, { lat, lng }) * 1000
|
||||
out.push({ asked, at, meters })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function getDistanceUnit(): DistanceUnit {
|
||||
return useSettingsStore.getState().settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
|
||||
}
|
||||
@@ -403,3 +505,176 @@ function formatDuration(seconds: number): string {
|
||||
}
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
/** One way of driving a leg, as OSRM offers it. */
|
||||
export interface RouteAlternative {
|
||||
coordinates: [number, number][]
|
||||
distance: number
|
||||
duration: number
|
||||
/** Where this route differs most from the first one — the point that would pin it. */
|
||||
divergence: { lat: number; lng: number } | null
|
||||
/** Set when this way exists because a road class was left out of it. */
|
||||
avoids?: 'motorway' | 'toll'
|
||||
}
|
||||
|
||||
/**
|
||||
* Road classes worth asking the router to leave out, in the order they are tried.
|
||||
*
|
||||
* OSRM's own alternative search is conservative: it only offers a second road when the
|
||||
* detour is short enough and shares little enough with the first, so on a long motorway
|
||||
* run it usually answers with exactly one route — while a perfectly good, slower way over
|
||||
* the B-roads exists and is what somebody opening "other ways" is looking for. Excluding
|
||||
* a class asks a different question and reliably produces that road.
|
||||
*
|
||||
* Only for driving: the foot and bike profiles have no excludable classes, and asking
|
||||
* anyway earns an `InvalidOptions` for nothing.
|
||||
*/
|
||||
const EXCLUDABLE_CLASSES = ['motorway', 'toll'] as const
|
||||
|
||||
/** Two lines are the same road if their length and their duration agree closely enough. */
|
||||
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
|
||||
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts that have already answered "I do not do exclude", by base URL.
|
||||
*
|
||||
* Both public hosts TREK ships with reject the parameter outright with HTTP 400: the
|
||||
* upstream car profile is built without excludable classes, and only a self-hosted OSRM
|
||||
* built through the MLD pipeline supports it. Without this, every long leg on a default
|
||||
* install pays two extra requests to be told twice that it cannot have what it asked for,
|
||||
* against a host that allows one request per second.
|
||||
*
|
||||
* The first real attempt is the probe; there is no separate one. Only a 400 is recorded,
|
||||
* because only a 400 is about the host rather than about this particular question: a 429
|
||||
* is a rate limit, an abort is the user moving on, and a `NoRoute` means this leg has no
|
||||
* way round the motorway while the next one might.
|
||||
*
|
||||
* Per base URL rather than global, so pointing the instance at an own router that does
|
||||
* support it starts asking again without a reload.
|
||||
*/
|
||||
const excludeUnsupported = new Set<string>()
|
||||
|
||||
/**
|
||||
* One route with a road class left out, or null when the router will not or cannot.
|
||||
*
|
||||
* Every failure is a null: a rate limit answers 429, and a leg with no way round the
|
||||
* motorway answers `NoRoute`. Neither is worth an error on screen — they just mean this
|
||||
* particular question had no answer.
|
||||
*/
|
||||
async function routeExcluding(
|
||||
coords: string,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
const base = routeBaseFor(profile)
|
||||
if (excludeUnsupported.has(base)) return null
|
||||
try {
|
||||
const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson`
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) {
|
||||
// 400 is the router saying the parameter itself is not available here, which is
|
||||
// true of every leg from now on. Anything else is about this request alone.
|
||||
if (response.status === 400) excludeUnsupported.add(base)
|
||||
return null
|
||||
}
|
||||
const data = await response.json()
|
||||
const route = data?.code === 'Ok' && Array.isArray(data.routes) ? data.routes[0] : null
|
||||
if (!route?.geometry?.coordinates?.length) return null
|
||||
return {
|
||||
coordinates: route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng] as [number, number]),
|
||||
distance: route.distance,
|
||||
duration: route.duration,
|
||||
divergence: null,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The ways of driving one leg, not one day.
|
||||
*
|
||||
* Deliberately per leg: OSRM only offers alternatives between exactly two coordinates, so
|
||||
* asking for a whole day would mean splitting it into one request per leg — which is the
|
||||
* bundling `splitIntoRuns` exists to avoid, and the reason a road trip appears at once
|
||||
* instead of trickling in. Asked for a single leg, on demand, it costs one extra request.
|
||||
*
|
||||
* `divergence` is the point on each alternative that lies furthest from the default route.
|
||||
* It is what makes a choice persistable: saving that point as a via forces the router back
|
||||
* onto this road on every future request, without storing a polyline that would go stale
|
||||
* with the next OSM update.
|
||||
*/
|
||||
export async function calculateAlternatives(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling' = 'driving',
|
||||
{ signal, limit = 3 }: { signal?: AbortSignal; limit?: number } = {},
|
||||
): Promise<RouteAlternative[]> {
|
||||
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
|
||||
const url = `${routeBaseFor(profile)}/${coords}?alternatives=${limit}&overview=full&geometries=geojson`
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new RoutingRefusedError(response.status, retryAfterMs(response))
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !Array.isArray(data.routes) || !data.routes.length) return []
|
||||
|
||||
const routes: RouteAlternative[] = data.routes.map((r: { geometry: { coordinates: [number, number][] }; distance: number; duration: number }) => ({
|
||||
coordinates: r.geometry.coordinates.map(([lng, lat]) => [lat, lng] as [number, number]),
|
||||
distance: r.distance,
|
||||
duration: r.duration,
|
||||
divergence: null,
|
||||
}))
|
||||
|
||||
// Nothing but the router's own preference came back, which is what it answers on most
|
||||
// long legs. Rather than reporting "only one sensible way", ask a different question:
|
||||
// the same drive without the motorway, and failing that without the tolls. Tried one at
|
||||
// a time and stopped as soon as one lands, so a leg that does have a second road costs
|
||||
// one extra request rather than three.
|
||||
//
|
||||
// Worth knowing before reading further: on the two public hosts TREK ships with, this
|
||||
// never produces anything. Their car profile is built without excludable classes and
|
||||
// the parameter comes back as HTTP 400, which is why long legs keep reporting a single
|
||||
// way to drive them. It works against a self-hosted OSRM built through the MLD
|
||||
// pipeline, and `excludeUnsupported` makes sure a host that refuses is only asked once.
|
||||
if (routes.length < 2 && profile === 'driving') {
|
||||
for (const exclude of EXCLUDABLE_CLASSES) {
|
||||
if (signal?.aborted) break
|
||||
const detour = await routeExcluding(coords, profile, exclude, signal)
|
||||
if (detour && !routes.some(r => sameRoad(r, detour))) {
|
||||
detour.avoids = exclude
|
||||
routes.push(detour)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The first route is what the router would have given anyway; the others are measured
|
||||
// against it so each one can be pinned by the point that makes it different.
|
||||
const [primary, ...rest] = routes
|
||||
for (const alt of rest) alt.divergence = furthestPointFrom(alt.coordinates, primary.coordinates)
|
||||
return routes
|
||||
}
|
||||
|
||||
/** The point of `line` that lies furthest from `reference`, in plain degrees. */
|
||||
function furthestPointFrom(line: [number, number][], reference: [number, number][]): { lat: number; lng: number } | null {
|
||||
if (!line.length || !reference.length) return null
|
||||
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
|
||||
// full cross product of two thousand-point lines is not worth the milliseconds.
|
||||
const step = Math.max(1, Math.floor(reference.length / 200))
|
||||
let best: { lat: number; lng: number } | null = null
|
||||
let bestDist = -1
|
||||
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
|
||||
const [lat, lng] = line[i]
|
||||
let nearest = Infinity
|
||||
for (let j = 0; j < reference.length; j += step) {
|
||||
const dLat = lat - reference[j][0]
|
||||
const dLng = (lng - reference[j][1]) * Math.cos((lat * Math.PI) / 180)
|
||||
const d = dLat * dLat + dLng * dLng
|
||||
if (d < nearest) nearest = d
|
||||
}
|
||||
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng } }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
|
||||
import { registerRtlTextPlugin } from '../rtlText'
|
||||
|
||||
/**
|
||||
* The only module in the client that pulls mapbox-gl in at runtime.
|
||||
@@ -8,4 +10,8 @@ import 'mapbox-gl/dist/mapbox-gl.css'
|
||||
* a static import from anywhere in the tree drags 1.8 MB into the parent chunk and
|
||||
* quietly undoes the split. `npm run check:gl-split` fails on that.
|
||||
*/
|
||||
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
|
||||
// Mapbox's signature is (url, callback, deferred) — see rtlText.ts.
|
||||
registerRtlTextPlugin(() => mapboxgl.setRTLTextPlugin(rtlTextPluginUrl, null, true))
|
||||
|
||||
export default mapboxgl
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
|
||||
import { registerRtlTextPlugin } from '../rtlText'
|
||||
|
||||
/**
|
||||
* The only module in the client that pulls maplibre-gl in at runtime. Same rule as
|
||||
@@ -10,4 +12,8 @@ import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
* stay engine-agnostic. Where the two genuinely differ, the caller already guards
|
||||
* on the provider (projection, accessToken, aroundCenter).
|
||||
*/
|
||||
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
|
||||
// MapLibre's signature is (url, lazy) and it resolves a promise — see rtlText.ts.
|
||||
registerRtlTextPlugin(() => maplibregl.setRTLTextPlugin(rtlTextPluginUrl, true))
|
||||
|
||||
export default maplibregl
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* The two lines this fix actually adds. Everything else is a helper: the guard in
|
||||
* rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
|
||||
* working fix from the outside. Deleting either registration, swapping an
|
||||
* argument or dropping the deferred flag left the whole suite green.
|
||||
*
|
||||
* The engines are mocked because importing them for real builds a worker pool
|
||||
* under jsdom, and the plugin URL is mocked so the 133 KB payload never loads.
|
||||
*/
|
||||
const { mapboxSet, maplibreSet } = vi.hoisted(() => ({
|
||||
mapboxSet: vi.fn(),
|
||||
maplibreSet: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('mapbox-gl', () => ({ default: { setRTLTextPlugin: mapboxSet } }));
|
||||
vi.mock('maplibre-gl', () => ({ default: { setRTLTextPlugin: maplibreSet } }));
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
vi.mock('@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url', () => ({ default: '/assets/rtl-plugin.js' }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mapboxSet.mockReset();
|
||||
maplibreSet.mockReset();
|
||||
});
|
||||
|
||||
describe('the GL engines register the RTL text plugin (#2235)', () => {
|
||||
it('FE-MAP-RTL-001: importing the mapbox engine registers it deferred, with a null callback', async () => {
|
||||
await import('./mapbox');
|
||||
|
||||
expect(mapboxSet).toHaveBeenCalledTimes(1);
|
||||
// Mapbox GL 3 takes (url, callback, deferred). The third argument is what
|
||||
// keeps the payload off the wire until a map paints RTL text; dropping it
|
||||
// would fetch 133 KB for every user on every map.
|
||||
expect(mapboxSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), null, true);
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-002: importing the maplibre engine registers it lazily', async () => {
|
||||
await import('./maplibre');
|
||||
|
||||
expect(maplibreSet).toHaveBeenCalledTimes(1);
|
||||
// MapLibre 5 takes (url, lazy) and returns a promise, which is the reason
|
||||
// the call stays with each engine instead of going through one wrapper.
|
||||
expect(maplibreSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), true);
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-003: an engine still loads when its registration throws', async () => {
|
||||
mapboxSet.mockImplementation(() => { throw new Error('already registered'); });
|
||||
|
||||
const engine = await import('./mapbox');
|
||||
|
||||
expect(engine.default).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-004: a rejected registration promise does not surface as an unhandled rejection', async () => {
|
||||
maplibreSet.mockReturnValue(Promise.reject(new Error('already registered')));
|
||||
|
||||
const engine = await import('./maplibre');
|
||||
|
||||
expect(engine.default).toBeDefined();
|
||||
// Give the rejection a tick to go unhandled if nothing caught it.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { makeMarkerDraggable } from './markerDrag'
|
||||
import { makeMarkerDraggable, makePoiDraggable, draggedPoiId } from './markerDrag'
|
||||
|
||||
/**
|
||||
* jsdom has no drag implementation, so these drive the events the browser would
|
||||
@@ -82,3 +82,93 @@ describe('makeMarkerDraggable', () => {
|
||||
expect(window.__dragData).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('makePoiDraggable', () => {
|
||||
afterEach(() => {
|
||||
window.__dragData = null
|
||||
document.body.classList.remove('dragging-poi')
|
||||
})
|
||||
|
||||
const dragEvent = (type: string) => {
|
||||
const e = new Event(type, { bubbles: true, cancelable: true }) as DragEvent & { dataTransfer: DataTransfer }
|
||||
const store: Record<string, string> = {}
|
||||
Object.defineProperty(e, 'dataTransfer', {
|
||||
value: {
|
||||
setData: (k: string, v: string) => { store[k] = v },
|
||||
getData: (k: string) => store[k] ?? '',
|
||||
effectAllowed: '',
|
||||
dropEffect: '',
|
||||
},
|
||||
})
|
||||
return e
|
||||
}
|
||||
|
||||
it('FE-MAP-POIDRAG-001: a hit carries its OSM id, not a place id — it is not a place yet', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
expect(el.getAttribute('draggable')).toBe('true')
|
||||
|
||||
const e = dragEvent('dragstart')
|
||||
el.dispatchEvent(e)
|
||||
|
||||
expect(e.dataTransfer.getData('poiOsmId')).toBe('node:9')
|
||||
// Mirrored on window because dataTransfer is unreadable during dragover, and the
|
||||
// map has to know what is coming while it is still being hovered.
|
||||
expect(window.__dragData).toEqual({ poiOsmId: 'node:9' })
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-002: the drag marks the body, so the route can light up while it lasts', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
|
||||
el.dispatchEvent(dragEvent('dragstart'))
|
||||
expect(document.body.classList.contains('dragging-poi')).toBe(true)
|
||||
|
||||
el.dispatchEvent(dragEvent('dragend'))
|
||||
expect(document.body.classList.contains('dragging-poi')).toBe(false)
|
||||
expect(window.__dragData).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-003: mousedown is swallowed, or the map pans out from under the drag', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true })
|
||||
const seen = vi.fn()
|
||||
document.body.appendChild(el)
|
||||
document.body.addEventListener('mousedown', seen)
|
||||
|
||||
el.dispatchEvent(down)
|
||||
|
||||
expect(seen).not.toHaveBeenCalled()
|
||||
document.body.removeEventListener('mousedown', seen)
|
||||
el.remove()
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-004: unwiring leaves the element as it was found', () => {
|
||||
const el = document.createElement('div')
|
||||
const off = makePoiDraggable(el, 'node:9')
|
||||
off()
|
||||
|
||||
expect(el.hasAttribute('draggable')).toBe(false)
|
||||
el.dispatchEvent(dragEvent('dragstart'))
|
||||
expect(window.__dragData).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-005: the id is readable from the event, and from the mirror during a hover', () => {
|
||||
const el = document.createElement('div')
|
||||
makePoiDraggable(el, 'node:9')
|
||||
const start = dragEvent('dragstart')
|
||||
el.dispatchEvent(start)
|
||||
|
||||
expect(draggedPoiId(start)).toBe('node:9')
|
||||
|
||||
// A dragover event carries an unreadable dataTransfer; the mirror answers instead.
|
||||
const over = new Event('dragover') as DragEvent
|
||||
expect(draggedPoiId(over)).toBe('node:9')
|
||||
})
|
||||
|
||||
it('FE-MAP-POIDRAG-006: an unrelated drag is not mistaken for a hit', () => {
|
||||
const over = new Event('dragover') as DragEvent
|
||||
expect(draggedPoiId(over)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,3 +48,51 @@ export function makeMarkerDraggable(el: HTMLElement, placeId: number): () => voi
|
||||
el.removeEventListener('mousedown', onMouseDown)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire a corridor hit up as a drag source, so it can be dropped onto the drive.
|
||||
*
|
||||
* The same HTML5 drag as `makeMarkerDraggable`, with the OSM id as payload instead of a
|
||||
* place id — a corridor hit is not a place yet. The drop target is the map itself rather
|
||||
* than the drawn line: a route polyline is a real element only in Leaflet (the GL
|
||||
* renderers draw it into the canvas), and the drop coordinate answers "where on the drive"
|
||||
* just as well once it is projected onto the routed geometry.
|
||||
*/
|
||||
export function makePoiDraggable(el: HTMLElement, osmId: string): () => void {
|
||||
el.setAttribute('draggable', 'true')
|
||||
|
||||
const onDragStart = (e: DragEvent) => {
|
||||
e.dataTransfer?.setData('poiOsmId', osmId)
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
|
||||
window.__dragData = { poiOsmId: osmId }
|
||||
el.classList.add('marker-dragging')
|
||||
document.body.classList.add('dragging-poi')
|
||||
}
|
||||
const onDragEnd = () => {
|
||||
window.__dragData = null
|
||||
el.classList.remove('marker-dragging')
|
||||
document.body.classList.remove('dragging-poi')
|
||||
}
|
||||
// Same reason as above: without this the map pans out from under the pointer as the
|
||||
// drag begins, and the route is impossible to aim at.
|
||||
const onMouseDown = (e: MouseEvent) => e.stopPropagation()
|
||||
|
||||
el.addEventListener('dragstart', onDragStart)
|
||||
el.addEventListener('dragend', onDragEnd)
|
||||
el.addEventListener('mousedown', onMouseDown)
|
||||
|
||||
return () => {
|
||||
el.removeAttribute('draggable')
|
||||
el.removeEventListener('dragstart', onDragStart)
|
||||
el.removeEventListener('dragend', onDragEnd)
|
||||
el.removeEventListener('mousedown', onMouseDown)
|
||||
}
|
||||
}
|
||||
|
||||
/** The OSM id being dragged, when a corridor hit is in flight. */
|
||||
export function draggedPoiId(e: DragEvent): string | null {
|
||||
const fromTransfer = e.dataTransfer?.getData('poiOsmId')
|
||||
if (fromTransfer) return fromTransfer
|
||||
// dataTransfer is unreadable during dragover, so the window mirror answers instead.
|
||||
return window.__dragData?.poiOsmId ?? null
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, type LucideIcon } from 'lucide-react'
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, Fuel, Zap, ParkingSquare, Tent, type LucideIcon } from 'lucide-react'
|
||||
// The road-trip kinds take their colour from the one place that defines it, so the
|
||||
// search result, the map pin and the road-trip rail cannot drift apart.
|
||||
import { SERVICE_COLORS } from '../Roadtrip/roadtripModel'
|
||||
import { CORRIDOR_CATEGORIES, HOTEL_COLOR } from '../Roadtrip/stopKinds'
|
||||
|
||||
// The POI categories shown in the map "explore" pill. The `key` is the contract
|
||||
// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping
|
||||
@@ -15,15 +19,27 @@ export const POI_CATEGORIES: PoiCategory[] = [
|
||||
{ key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' },
|
||||
{ key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' },
|
||||
{ key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' },
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' },
|
||||
// The colour lives with the road-trip categories, because the corridor search offers
|
||||
// the same one and two definitions of a blue is how they drift apart.
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR },
|
||||
{ key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' },
|
||||
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
|
||||
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
|
||||
{ key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' },
|
||||
]
|
||||
|
||||
/**
|
||||
* What a drive needs. Kept out of POI_CATEGORIES on purpose: these are not offered by
|
||||
* the explore pill, they are what the road trip corridor searches for. They are in the
|
||||
* lookup below all the same, so a hit drawn on the map gets its own icon and colour
|
||||
* instead of the nameless grey dot an unknown category falls back to.
|
||||
*/
|
||||
export const ROADTRIP_POI_CATEGORIES: PoiCategory[] = CORRIDOR_CATEGORIES.map(
|
||||
({ key, labelKey, Icon, color }) => ({ key, labelKey, Icon, color }),
|
||||
)
|
||||
|
||||
export const POI_CATEGORY_BY_KEY: Record<string, PoiCategory> = Object.fromEntries(
|
||||
POI_CATEGORIES.map(c => [c.key, c]),
|
||||
[...POI_CATEGORIES, ...ROADTRIP_POI_CATEGORIES].map(c => [c.key, c]),
|
||||
)
|
||||
|
||||
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
|
||||
@@ -34,10 +50,25 @@ export interface Poi {
|
||||
lng: number
|
||||
category: string
|
||||
poi_type: string
|
||||
/** Brand name and its Wikidata id, when OSM carries them (road categories mostly do). */
|
||||
brand?: string | null
|
||||
brand_wikidata?: string | null
|
||||
address: string | null
|
||||
website: string | null
|
||||
phone: string | null
|
||||
opening_hours: string | null
|
||||
cuisine: string | null
|
||||
/**
|
||||
* What a charging station offers, when it is one and OSM says so.
|
||||
*
|
||||
* Optional twice over: only charging hits carry it at all, and OSM knows a socket type
|
||||
* for roughly a third of them, a capacity for about seven in ten. "Not stated" is its
|
||||
* own answer here and must not be read as a no.
|
||||
*/
|
||||
charging?: {
|
||||
sockets: { type: string; count: number | null; kw: number | null }[]
|
||||
capacity: number | null
|
||||
fee: boolean | null
|
||||
} | null
|
||||
source: 'openstreetmap'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { registerRtlTextPlugin } from './rtlText'
|
||||
|
||||
describe('registerRtlTextPlugin', () => {
|
||||
it('runs the engine-supplied registration', () => {
|
||||
const register = vi.fn()
|
||||
|
||||
expect(registerRtlTextPlugin(register)).toBe(true)
|
||||
expect(register).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Registering twice is how the engines report it: Mapbox throws.
|
||||
it('never lets a refused registration break map creation', () => {
|
||||
const register = vi.fn(() => {
|
||||
throw new Error('setRTLTextPlugin cannot be called multiple times.')
|
||||
})
|
||||
|
||||
expect(() => registerRtlTextPlugin(register)).not.toThrow()
|
||||
expect(registerRtlTextPlugin(register)).toBe(false)
|
||||
})
|
||||
|
||||
// MapLibre reports the same by rejecting, and an unhandled rejection would
|
||||
// reach the console for something the user cannot act on.
|
||||
it('swallows a rejected registration promise', async () => {
|
||||
const onUnhandled = vi.fn()
|
||||
process.on('unhandledRejection', onUnhandled)
|
||||
|
||||
expect(registerRtlTextPlugin(() => Promise.reject(new Error('404')))).toBe(true)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
process.off('unhandledRejection', onUnhandled)
|
||||
|
||||
expect(onUnhandled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a resolved promise alone', async () => {
|
||||
expect(registerRtlTextPlugin(() => Promise.resolve())).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Arabic, Hebrew, Persian and Urdu labels need two things a GL engine does not do
|
||||
* on its own: the bidirectional algorithm, and Arabic contextual shaping (a letter
|
||||
* is drawn differently depending on its neighbours). Without them the engine lays
|
||||
* the codepoints out one by one, left to right, in isolated forms — so القاهرة
|
||||
* comes out as ة ر ه ا ق ل ا, unjoined and reversed. Raster basemaps never showed
|
||||
* this because their labels are drawn server-side, which is why it only surfaced
|
||||
* once the default basemap became a vector style.
|
||||
*
|
||||
* Both engines expose the fix as an optional plugin and neither loads it by
|
||||
* default. Registering is global to the engine, so it happens once per engine
|
||||
* module rather than at each map's construction site.
|
||||
*
|
||||
* The call itself stays with the caller: this is one of the places the two engines
|
||||
* genuinely differ. MapLibre 5 takes `(url, lazy)` and returns a promise, Mapbox
|
||||
* GL 3 takes `(url, callback, deferred)` and returns nothing — so each engine
|
||||
* module passes its own invocation rather than a shared wrapper guessing an arity.
|
||||
*
|
||||
* Deliberately no getRTLTextPluginStatus() pre-check: reading it makes MapLibre
|
||||
* build its worker pool, which is an eager side effect for a module that is
|
||||
* imported for its default export, and throws outright under jsdom. Registering
|
||||
* twice is reported — by a throw or a rejected promise depending on the engine —
|
||||
* and both are handled below, so the probe bought nothing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Runs a GL engine's RTL-plugin registration, swallowing the ways it can fail.
|
||||
*
|
||||
* Callers register lazily, so the payload is only fetched once a map actually
|
||||
* paints RTL text and a user who never leaves Latin script downloads nothing.
|
||||
*
|
||||
* @returns whether the registration call completed without throwing.
|
||||
*/
|
||||
export function registerRtlTextPlugin(register: () => void | Promise<unknown>): boolean {
|
||||
try {
|
||||
const pending = register()
|
||||
// MapLibre reports a failed or duplicate registration by rejecting; Mapbox
|
||||
// routes the same through its callback. Neither is worth surfacing: a map
|
||||
// with unshaped labels still renders, and an unhandled rejection would not
|
||||
// give the user anything to act on.
|
||||
if (pending instanceof Promise) pending.catch(() => {})
|
||||
return true
|
||||
} catch {
|
||||
// Already registered, or the engine refused outright. Registering the plugin
|
||||
// must never be able to break map creation.
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { useStableVias } from './viaMarkerState'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The list both map renderers rebuild their via handles from.
|
||||
*
|
||||
* Its identity is the whole contract: the GL renderer replaces the DOM element when this
|
||||
* changes, and a pointerdown belongs to the element it happened on. A list that changed
|
||||
* on every render meant a freshly placed via lost its first drag to the re-route landing
|
||||
* underneath it.
|
||||
*/
|
||||
const via = (over: Partial<RoadtripVia> & { id: number }): RoadtripVia => ({
|
||||
day_id: 1, after_order_index: 0, sequence: 0, lat: 48.1, lng: 2.1, ...over,
|
||||
})
|
||||
|
||||
describe('useStableVias', () => {
|
||||
it('FE-VIASTATE-001: the same vias in a fresh object are the same list', () => {
|
||||
// Exactly what a reload hands down: identical rows, a new object every time.
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
|
||||
)
|
||||
const first = result.current
|
||||
|
||||
rerender({ byDay: { 1: [via({ id: 5 })] } })
|
||||
expect(result.current).toBe(first)
|
||||
})
|
||||
|
||||
it('FE-VIASTATE-002: a via that moved is a new list, so the handle is redrawn where it now is', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
|
||||
)
|
||||
const first = result.current
|
||||
|
||||
rerender({ byDay: { 1: [via({ id: 5, lat: 48.9 })] } })
|
||||
expect(result.current).not.toBe(first)
|
||||
expect(result.current[0].lat).toBe(48.9)
|
||||
})
|
||||
|
||||
it('FE-VIASTATE-003: adding or removing one is a new list too', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })] } } },
|
||||
)
|
||||
const first = result.current
|
||||
|
||||
rerender({ byDay: { 1: [via({ id: 5 }), via({ id: 6, lat: 48.4 })] } })
|
||||
expect(result.current).not.toBe(first)
|
||||
expect(result.current).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-VIASTATE-004: every day of the trip is in one list, and no vias is a stable empty one', () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ byDay }) => useStableVias(byDay),
|
||||
{ initialProps: { byDay: { 1: [via({ id: 5 })], 2: [via({ id: 6, day_id: 2 })] } as Record<number, RoadtripVia[]> | undefined },
|
||||
})
|
||||
expect(result.current.map(v => v.id)).toEqual([5, 6])
|
||||
|
||||
rerender({ byDay: undefined })
|
||||
const empty = result.current
|
||||
expect(empty).toEqual([])
|
||||
rerender({ byDay: {} })
|
||||
expect(result.current).toBe(empty)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useRef } from 'react'
|
||||
import type { RoadtripVia } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The trip's via points as an array that only changes when a via does.
|
||||
*
|
||||
* Both map renderers rebuild their via handles whenever this list changes, and the GL one
|
||||
* replaces the DOM element outright. The list it was given changed on every render: the
|
||||
* hook that loads vias returns a fresh object each time, and a fresh object arrives again
|
||||
* on every reload even when the rows came back identical.
|
||||
*
|
||||
* That made dragging a freshly placed via fail on the first try. Placing one triggers a
|
||||
* re-route, the re-route lands about a second later, the render it causes replaced the
|
||||
* handle under the pointer — and a pointerdown belongs to the element it happened on, so
|
||||
* the drag went to an element that no longer existed. The second attempt worked because
|
||||
* by then nothing was pending. An older via never showed it: nothing was re-routing.
|
||||
*
|
||||
* Keyed by id and position because those are the only fields a handle is built from. A
|
||||
* via that genuinely moved gets a new list, which is what redraws it where it now is.
|
||||
*/
|
||||
export function useStableVias(viasByDay: Record<number, RoadtripVia[]> | undefined): RoadtripVia[] {
|
||||
const flat = Object.values(viasByDay ?? {}).flat()
|
||||
const key = flat.map(v => `${v.id}@${v.lat},${v.lng}`).join('|')
|
||||
// Read and written during render on purpose: this is a cache of the argument, not
|
||||
// state — it never triggers a render of its own, and it has to be settled before the
|
||||
// effects that consume the list run.
|
||||
const held = useRef<{ key: string; list: RoadtripVia[] }>({ key: '', list: [] })
|
||||
if (held.current.key !== key) held.current = { key, list: flat }
|
||||
return held.current.list
|
||||
}
|
||||
@@ -353,6 +353,22 @@ export async function downloadTripPDF({ trip, days, places, assignments = {}, ca
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : '', route].filter(Boolean).join(' · ')
|
||||
}
|
||||
}
|
||||
else if (r.type === 'car') {
|
||||
// A rental with stops is a drive, and the printout is what people take
|
||||
// into the car (#1797). Without this the route reads as pick-up and return
|
||||
// with everything in between missing.
|
||||
//
|
||||
// Consecutive repeats collapse, and a chain that names one place only is
|
||||
// dropped: the ordinary rental is picked up and returned at the same desk,
|
||||
// which satisfies "two endpoints" and would otherwise print the counter's
|
||||
// name twice with an arrow between. A genuine round trip keeps both ends,
|
||||
// because Dresden sits between them.
|
||||
const stops = (r.endpoints || []).slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
.map(e => e.name)
|
||||
.filter((name, i, all) => name && name !== all[i - 1])
|
||||
subtitle = new Set(stops).size >= 2 ? stops.join(' → ') : ''
|
||||
}
|
||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
|
||||
interface Props {
|
||||
value: string
|
||||
@@ -16,6 +17,8 @@ interface Props {
|
||||
// is never lost, and picking a suggestion just replaces the text (#1496).
|
||||
export default function AddressInput({ value, onChange, placeholder, className }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
||||
const { point: locationBias } = useLocationBias()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [results, setResults] = useState<any[]>([])
|
||||
const [highlight, setHighlight] = useState(-1)
|
||||
@@ -47,7 +50,7 @@ export default function AddressInput({ value, onChange, placeholder, className }
|
||||
const myReq = ++reqIdRef.current
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await mapsApi.search(trimmed, locale)
|
||||
const data = await mapsApi.search(trimmed, locale, locationBias)
|
||||
if (myReq !== reqIdRef.current) return
|
||||
setResults(data.places || [])
|
||||
setHighlight(-1)
|
||||
|
||||
@@ -1388,39 +1388,90 @@ describe('DayPlanSidebar', () => {
|
||||
expect(onDeletePlace).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
// ── Note card edit/delete buttons ─────────────────────────────────────
|
||||
// ── Note card editing (#2249) ─────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064: note card edit button calls openEditNote', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-064: clicking a note row opens its edit modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
|
||||
mockDayNotesState.dayNotes = { '10': [note] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
// Find note edit button (Pencil in note-edit-buttons)
|
||||
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
|
||||
if (noteEditBtns.length > 0) {
|
||||
await user.click(noteEditBtns[0] as HTMLElement)
|
||||
expect(mockDayNotesState.openEditNote).toHaveBeenCalled()
|
||||
}
|
||||
await user.click(cardRow(screen.getByText('My note')))
|
||||
expect(mockDayNotesState.openEditNote).toHaveBeenCalledWith(10, expect.objectContaining({ id: 55 }))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-065: deleting a note asks for confirmation before calling deleteNote', async () => {
|
||||
// The bug: an absolutely-positioned pencil/trash pill sat on the note's own
|
||||
// reorder chevrons, and a coarse-pointer `opacity: 1 !important` in index.css
|
||||
// made it permanent on tablets. Nothing may float over that column again.
|
||||
it('FE-PLANNER-DAYPLAN-064b: the note row carries no floating action pill over its reorder buttons', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const row = cardRow(screen.getByText('My note'))
|
||||
expect(document.querySelector('.note-edit-buttons')).toBeNull()
|
||||
expect(row.querySelectorAll('[style*="position: absolute"]')).toHaveLength(0)
|
||||
expect(row.querySelectorAll('.reorder-buttons button')).toHaveLength(2)
|
||||
// Positional, not just by class: the reorder column owns the row's right edge.
|
||||
expect(row.lastElementChild).toHaveClass('reorder-buttons')
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064e: the keyboard reaches the note and opens it with Enter', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const row = cardRow(screen.getByText('My note'))
|
||||
expect(row).toHaveAttribute('tabindex', '0')
|
||||
// A press-scale on a draggable row fights the drag, so the row opts out (#2158).
|
||||
expect(row).toHaveAttribute('data-no-press')
|
||||
fireEvent.keyDown(row, { key: 'Enter' })
|
||||
expect(mockDayNotesState.openEditNote).toHaveBeenCalledWith(10, expect.objectContaining({ id: 55 }))
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064c: a link inside the note body opens the link, not the edit modal', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
|
||||
mockDayNotesState.dayNotes = { '10': [note] }
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note', time: '[docs](https://example.com)' })] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
// Find note delete button (Trash2 in note-edit-buttons)
|
||||
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
|
||||
if (noteEditBtns.length > 1) {
|
||||
await user.click(noteEditBtns[1] as HTMLElement)
|
||||
// Clicking delete opens a confirmation dialog rather than deleting immediately.
|
||||
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Delete note?')).toBeInTheDocument()
|
||||
// Confirming triggers the actual delete.
|
||||
await user.click(screen.getByRole('button', { name: /^delete$/i }))
|
||||
expect(mockDayNotesState.deleteNote).toHaveBeenCalled()
|
||||
}
|
||||
await user.click(screen.getByRole('link', { name: 'docs' }))
|
||||
expect(mockDayNotesState.openEditNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-064d: the reorder chevrons still move the note instead of editing it', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [
|
||||
buildDayNote({ id: 55, day_id: 10, text: 'First note', sort_order: 0 }),
|
||||
buildDayNote({ id: 56, day_id: 10, text: 'Second note', sort_order: 1 }),
|
||||
] }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
const row = cardRow(screen.getByText('Second note'))
|
||||
await user.click(row.querySelectorAll('.reorder-buttons button')[0] as HTMLElement)
|
||||
expect(mockDayNotesState.moveNote).toHaveBeenCalled()
|
||||
expect(mockDayNotesState.openEditNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-065: the note modal deletes only through the confirmation dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [buildDayNote({ id: 55, day_id: 10, text: 'My note' })] }
|
||||
mockDayNotesState.noteUi = { '10': { mode: 'edit', noteId: 55, text: 'My note', time: '', icon: 'FileText', color: null } }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
await user.click(screen.getByRole('button', { name: /^delete$/i }))
|
||||
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
|
||||
expect(screen.getByText('Delete note?')).toBeInTheDocument()
|
||||
const confirm = within(screen.getByText('Delete note?').closest('[class*="z-[10000]"]') as HTMLElement)
|
||||
await user.click(confirm.getByRole('button', { name: /^delete$/i }))
|
||||
expect(mockDayNotesState.deleteNote).toHaveBeenCalledWith(10, 55)
|
||||
// Otherwise the edit modal is left open on a note that no longer exists.
|
||||
expect(mockDayNotesState.cancelNote).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-065b: a note being created offers no delete', () => {
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
mockDayNotesState.dayNotes = { '10': [] }
|
||||
mockDayNotesState.noteUi = { '10': { mode: 'add', text: '', time: '', icon: 'FileText', color: null } }
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day] })} />)
|
||||
expect(screen.queryByRole('button', { name: /^delete$/i })).toBeNull()
|
||||
})
|
||||
|
||||
// ── Drop on assignment: same-day reorder ─────────────────────────────
|
||||
@@ -2940,6 +2991,10 @@ describe('DayPlanSidebar', () => {
|
||||
expect(screen.queryByLabelText('Add Note')).not.toBeInTheDocument()
|
||||
expect(dragRow(screen.getByText('Read only place'))).toBeNull()
|
||||
expect(screen.getByText('A note')).toBeInTheDocument()
|
||||
// A viewer must not reach the edit modal they could never save (#2249).
|
||||
const noteRow = cardRow(screen.getByText('A note'))
|
||||
expect(noteRow).not.toHaveAttribute('role', 'button')
|
||||
expect(noteRow.querySelectorAll('.reorder-buttons')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-132: the read-only chevron still collapses and expands the day', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end'; /** A corridor hit on its way onto the drive (#1797) — not a place yet. */ poiOsmId?: string }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo, useCallback } from 'react'
|
||||
@@ -696,19 +696,13 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const openEditNote = (dayId: number, note: DayNote, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
_openEditNote(dayId, note)
|
||||
}
|
||||
const openEditNote = (dayId: number, note: DayNote) => _openEditNote(dayId, note)
|
||||
|
||||
// Deleting a note asks for confirmation first — the edit/delete icons sit close together and are
|
||||
// easy to mis-tap on touch devices, where an accidental delete was previously unrecoverable.
|
||||
const [pendingDeleteNote, setPendingDeleteNote] = useState<{ dayId: number; noteId: number } | null>(null)
|
||||
|
||||
const deleteNote = async (dayId: number, noteId: number, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation()
|
||||
await _deleteNote(dayId, noteId)
|
||||
}
|
||||
const deleteNote = (dayId: number, noteId: number) => _deleteNote(dayId, noteId)
|
||||
|
||||
// Unified reorder: assigns positions to ALL item types based on new visual order
|
||||
const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => {
|
||||
@@ -2614,17 +2608,33 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
{ divider: true },
|
||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => setPendingDeleteNote({ dayId: day.id, noteId: note.id }) },
|
||||
]) : undefined}
|
||||
{...(canEditDays ? {
|
||||
role: 'button' as const,
|
||||
// No press-scale: index.css shrinks every [role=button] on
|
||||
// :active, which on a draggable row fights the drag (#2158).
|
||||
'data-no-press': '',
|
||||
tabIndex: 0,
|
||||
// The row is the note's edit affordance — the same gesture the
|
||||
// phone shell already uses (#2249). Links inside the rendered
|
||||
// body and the reorder buttons keep their own click.
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('a, button')) return
|
||||
openEditNote(day.id, note)
|
||||
},
|
||||
onKeyDown: (e: React.KeyboardEvent) => {
|
||||
if (e.target !== e.currentTarget) return
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
openEditNote(day.id, note)
|
||||
},
|
||||
} : {})}
|
||||
onMouseEnter={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '1'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '1'
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '0.3'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '0'
|
||||
}}
|
||||
style={{
|
||||
position: 'relative',
|
||||
@@ -2636,7 +2646,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined,
|
||||
background: noteSkin.background,
|
||||
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
|
||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||
transition: 'background 0.1s', cursor: canEditDays ? 'pointer' : 'default', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{canEditDays && !dragDisabled && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
@@ -2658,19 +2668,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Stacked in a capsule of their own, set off from the text:
|
||||
a note card is as tall as its body, so the height is free
|
||||
while the width next to the text is not, and a 10px icon
|
||||
in a 14px box was barely a target. */}
|
||||
{/* Floated over the text rather than sharing the row with
|
||||
it: a note is mostly text, and reserving a column for
|
||||
two buttons that are invisible until you hover costs
|
||||
every note a shorter line for the whole time you are
|
||||
not hovering. */}
|
||||
{canEditDays && <div className="note-edit-buttons bg-surface-card" style={{ position: 'absolute', right: 5, top: '50%', transform: 'translateY(-50%)', display: 'flex', flexDirection: 'column', justifyContent: 'center', gap: 3, padding: 4, borderRadius: 999, border: '1px solid var(--border-faint)', boxShadow: '0 4px 14px rgba(0,0,0,0.10)', opacity: 0, transition: 'opacity 0.15s' }}>
|
||||
<button type="button" onClick={e => openEditNote(day.id, note, e)} aria-label={t('dayplan.noteEdit')} title={t('dayplan.noteEdit')} className="text-content-faint note-edit-btn" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Pencil size={15} /></button>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} aria-label={t('dayplan.noteDelete')} title={t('dayplan.noteDelete')} className="text-content-faint note-edit-btn note-edit-btn-danger" style={{ background: 'none', border: 'none', width: 30, height: 30, borderRadius: 999, cursor: 'pointer', display: 'grid', placeItems: 'center', padding: 0 }}><Trash2 size={15} /></button>
|
||||
</div>}
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} className={noteIdx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
@@ -2877,6 +2874,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
noteInputRef={noteInputRef}
|
||||
cancelNote={cancelNote}
|
||||
saveNote={saveNote}
|
||||
onRequestDelete={(dayId, noteId) => setPendingDeleteNote({ dayId, noteId })}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
@@ -2892,7 +2890,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingDeleteNote}
|
||||
onClose={() => setPendingDeleteNote(null)}
|
||||
onConfirm={() => { if (pendingDeleteNote) deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId) }}
|
||||
onConfirm={() => {
|
||||
if (!pendingDeleteNote) return
|
||||
// Close the edit modal behind the confirm; leaving it open would go on
|
||||
// editing a note that no longer exists.
|
||||
cancelNote(pendingDeleteNote.dayId)
|
||||
deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId)
|
||||
}}
|
||||
title={t('dayplan.confirmDeleteNoteTitle')}
|
||||
message={t('dayplan.confirmDeleteNoteBody')}
|
||||
/>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { markdownLinkComponents } from '../shared/markdownLink'
|
||||
|
||||
interface NoteModalUi {
|
||||
mode: 'add' | 'edit'
|
||||
noteId?: number
|
||||
icon: string
|
||||
text: string
|
||||
time: string
|
||||
@@ -25,6 +26,8 @@ interface DayPlanSidebarNoteModalProps {
|
||||
noteInputRef: React.RefObject<HTMLInputElement>
|
||||
cancelNote: (dayId: number) => void
|
||||
saveNote: (dayId: number) => void
|
||||
/** Asks the planner to confirm and then delete the note being edited (#2249). */
|
||||
onRequestDelete: (dayId: number, noteId: number) => void
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}
|
||||
|
||||
@@ -38,19 +41,19 @@ const BODY_MAX = 2000
|
||||
* right. The preview is the reason the colour picker is worth having — a swatch
|
||||
* row tells you nothing about what a tinted card looks like next to a place.
|
||||
*/
|
||||
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, t }: DayPlanSidebarNoteModalProps) {
|
||||
export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: DayPlanSidebarNoteModalProps) {
|
||||
return (
|
||||
<>
|
||||
{Object.entries(noteUi).map(([dayId, ui]) => ui && createPortal(
|
||||
<NoteDialog key={dayId} dayId={dayId} ui={ui} setNoteUi={setNoteUi} noteInputRef={noteInputRef}
|
||||
cancelNote={cancelNote} saveNote={saveNote} t={t} />,
|
||||
cancelNote={cancelNote} saveNote={saveNote} onRequestDelete={onRequestDelete} t={t} />,
|
||||
document.body
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
|
||||
function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote, onRequestDelete, t }: Omit<DayPlanSidebarNoteModalProps, 'noteUi'> & {
|
||||
dayId: string
|
||||
ui: NoteModalUi
|
||||
}) {
|
||||
@@ -174,7 +177,17 @@ function NoteDialog({ dayId, ui, setNoteUi, noteInputRef, cancelNote, saveNote,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{/* Delete lives here rather than on the row: the row's floating pencil/trash
|
||||
pill covered the reorder chevrons and, on a coarse pointer, never went
|
||||
away (#2249). The planner still confirms before it deletes. */}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', alignItems: 'center' }}>
|
||||
{ui.mode === 'edit' && ui.noteId != null && (
|
||||
<button type="button" onClick={() => onRequestDelete(Number(dayId), ui.noteId!)}
|
||||
className="text-danger"
|
||||
style={{ marginRight: 'auto', fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 15px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||
<button type="button" onClick={() => saveNote(Number(dayId))} disabled={!canSave}
|
||||
className={!canSave ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { MapPin, X } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
|
||||
export interface LocationPoint {
|
||||
name: string
|
||||
@@ -19,6 +20,8 @@ interface Props {
|
||||
|
||||
export default function LocationSelect({ value, onChange, placeholder, style }: Props) {
|
||||
const { t, locale } = useTranslation()
|
||||
// Ohne Reisekontext ist der Hinweis leer, und die Suche laeuft wie bisher.
|
||||
const { point: locationBias } = useLocationBias()
|
||||
const [query, setQuery] = useState(value?.name || '')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [results, setResults] = useState<any[]>([])
|
||||
@@ -49,7 +52,7 @@ export default function LocationSelect({ value, onChange, placeholder, style }:
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await mapsApi.search(trimmed, locale)
|
||||
const data = await mapsApi.search(trimmed, locale, locationBias)
|
||||
setResults(data.places || [])
|
||||
setHighlight(-1)
|
||||
} catch {
|
||||
|
||||
@@ -169,6 +169,28 @@ describe('PlaceDetailsColumn', () => {
|
||||
expect(link.parentElement).toHaveTextContent('CC BY-SA 4.0')
|
||||
})
|
||||
|
||||
it('FE-PDC-010b: credits a website description by host', async () => {
|
||||
// The source labels used to be a ternary chain ending in 'Wikipedia', so a
|
||||
// restaurant's own marketing copy would have been credited to an
|
||||
// encyclopaedia. There is no fixed name for "the place's own website", and
|
||||
// the host is the honest one.
|
||||
placeEnrichment.mockResolvedValue({
|
||||
photos: [],
|
||||
facts: [],
|
||||
description: {
|
||||
text: 'Pizza in Rostock, seit 2015.',
|
||||
source: 'website',
|
||||
sourceUrl: 'https://www.losteria.net/rostock',
|
||||
license: null,
|
||||
},
|
||||
})
|
||||
renderColumn()
|
||||
|
||||
const link = await screen.findByRole('link', { name: /losteria\.net/ })
|
||||
expect(link).toHaveAttribute('href', 'https://www.losteria.net/rostock')
|
||||
expect(screen.queryByText(/Wikipedia/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-011: says so when the admin switched enrichment off', async () => {
|
||||
placeEnrichment.mockResolvedValue({ photos: [], description: null, facts: [], disabled: true })
|
||||
renderColumn()
|
||||
|
||||
@@ -379,6 +379,37 @@ function PhotoTile({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Who the description came from, as the reader would name them.
|
||||
*
|
||||
* `website` has no fixed name — it is the place's own site — so it is credited
|
||||
* by host: "losteria.net" says where the text is from far better than a generic
|
||||
* word would. Deliberately not a ternary chain: the previous one fell through
|
||||
* to 'Wikipedia', which would have credited a restaurant's marketing copy to an
|
||||
* encyclopaedia.
|
||||
*/
|
||||
function descriptionSourceLabel(
|
||||
source: NonNullable<MapsPlaceEnrichmentResult['description']>['source'],
|
||||
sourceUrl: string | null,
|
||||
): string {
|
||||
switch (source) {
|
||||
case 'google':
|
||||
return 'Google'
|
||||
case 'osm':
|
||||
return 'OpenStreetMap'
|
||||
case 'wikivoyage':
|
||||
return 'Wikivoyage'
|
||||
case 'wikipedia':
|
||||
return 'Wikipedia'
|
||||
case 'website':
|
||||
try {
|
||||
return new URL(sourceUrl || '').hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return 'Website'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLabelFor(source: PlacePhotoCandidate['source']): string {
|
||||
if (source === 'google') return 'Google'
|
||||
if (source === 'wikipedia') return 'Wikipedia'
|
||||
@@ -659,14 +690,7 @@ function DescriptionBlock({
|
||||
}): React.ReactElement | null {
|
||||
if (!description) return null
|
||||
|
||||
const sourceLabel =
|
||||
description.source === 'google'
|
||||
? 'Google'
|
||||
: description.source === 'osm'
|
||||
? 'OpenStreetMap'
|
||||
: description.source === 'wikivoyage'
|
||||
? 'Wikivoyage'
|
||||
: 'Wikipedia'
|
||||
const sourceLabel = descriptionSourceLabel(description.source, description.sourceUrl)
|
||||
|
||||
// A description of the chain is not a description of this place, and the
|
||||
// heading is where that gets said. Without it the reader has no way to tell
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
|
||||
export interface PlaceFormData {
|
||||
name: string
|
||||
description: string
|
||||
@@ -24,6 +26,14 @@ export interface PlaceFormData {
|
||||
// submit payload when unchanged, and useTripPlanner strips it off the place
|
||||
// update and PUTs it per assignment instead.
|
||||
assignment_notes?: string
|
||||
// What kind of stop on a drive this is, if any (#1797). Optional and absent from
|
||||
// DEFAULT_FORM for the same reason as image_url: a missing key writes nothing, and a
|
||||
// place created outside a road trip has no opinion about fuel stops.
|
||||
stop_type?: RoadtripStopType | null
|
||||
// Offered only while creating, and only alongside a stop_type: the corridor popup
|
||||
// suggests how long that kind of pause usually takes, and the full form should not
|
||||
// throw the suggestion away on the way through. Editing a stay is the rail's job.
|
||||
duration_minutes?: number
|
||||
}
|
||||
|
||||
export function isGoogleMapsUrl(input: string): boolean {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
import { STOP_KINDS } from '../Roadtrip/stopKinds'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import NoteFormatToolbar from '../shared/NoteFormatToolbar'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { recordPlacePick } from '../../api/placeShadow'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import CollectionPicker from '../Collections/CollectionPicker'
|
||||
import PlaceDetailsColumn, { type PlaceDetailsSelection } from './PlaceDetailsColumn'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -16,6 +19,7 @@ import { useTranslation } from '../../i18n'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { DEFAULT_FORM, isGoogleMapsUrl, mergeResult, type PlaceFormData, type ResultField } from './PlaceFormModal.helpers'
|
||||
import { getApiErrorMessage } from '../../utils/apiError'
|
||||
import { useLocationBias } from '../../hooks/useLocationBias'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import type { Place, Category, Assignment, BudgetItem } from '../../types'
|
||||
@@ -36,7 +40,7 @@ interface PlaceFormModalProps {
|
||||
onClose: () => void
|
||||
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<{ id: number } | void> | void
|
||||
place: Place | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string; stop_type?: RoadtripStopType | null; duration_minutes?: number } | null
|
||||
tripId: number
|
||||
categories: Category[]
|
||||
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
|
||||
@@ -86,9 +90,20 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
onCategoryCreated, assignmentId, dayAssignments = [], isMobile = false,
|
||||
onOpenExpense,
|
||||
} = props
|
||||
// Hidden while the addon is off, because the kinds only mean anything to the road trip
|
||||
// rail: on an instance without it they would be six labels that change nothing.
|
||||
const roadtripEnabled = useAddonStore(s => s.isEnabled('roadtrip'))
|
||||
const [form, setForm] = useState(DEFAULT_FORM)
|
||||
const [mapsSearch, setMapsSearch] = useState('')
|
||||
const [mapsResults, setMapsResults] = useState([])
|
||||
/**
|
||||
* What produced the list currently on screen, kept for the shadow log: the
|
||||
* query as typed and the provider the envelope named. A ref rather than
|
||||
* state because nothing renders from it and a pick must read the value that
|
||||
* belonged to the list, not a value a re-render replaced.
|
||||
*/
|
||||
const searchMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
const acMetaRef = useRef<{ query: string; source: string } | null>(null)
|
||||
const [isSearchingMaps, setIsSearchingMaps] = useState(false)
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||
@@ -143,6 +158,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
notes: place.notes || '',
|
||||
transport_mode: place.transport_mode || 'walking',
|
||||
website: place.website || '',
|
||||
// Carried through every edit. Without it, opening a fuel stop to fix a typo
|
||||
// submits an empty kind and turns it back into a numbered destination.
|
||||
// duration_minutes deliberately stays out: how long a stay takes belongs to the
|
||||
// rail's own dialog, and sending it from here would overwrite what was set there.
|
||||
stop_type: place.stop_type ?? null,
|
||||
// The day-specific note rides only with an assignment in context (#2163);
|
||||
// otherwise the key stays absent so submit never sends a notes write.
|
||||
...(assignment ? { assignment_notes: assignment.notes || '' } : {}),
|
||||
@@ -157,6 +177,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
website: prefillCoords.website || '',
|
||||
phone: prefillCoords.phone || '',
|
||||
osm_id: prefillCoords.osm_id,
|
||||
stop_type: prefillCoords.stop_type ?? null,
|
||||
duration_minutes: prefillCoords.duration_minutes,
|
||||
})
|
||||
} else {
|
||||
setForm(DEFAULT_FORM)
|
||||
@@ -213,32 +235,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Derive location bias bounding box from the trip's existing places
|
||||
const places = useTripStore((s) => s.places)
|
||||
const locationBias = useMemo(() => {
|
||||
const withCoords = (places || []).filter((p) => p.lat != null && p.lng != null)
|
||||
if (withCoords.length === 0) return undefined
|
||||
|
||||
let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity
|
||||
for (const p of withCoords) {
|
||||
const lat = Number(p.lat), lng = Number(p.lng)
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
|
||||
if (lat < minLat) minLat = lat
|
||||
if (lat > maxLat) maxLat = lat
|
||||
if (lng < minLng) minLng = lng
|
||||
if (lng > maxLng) maxLng = lng
|
||||
}
|
||||
if (!Number.isFinite(minLat)) return undefined
|
||||
|
||||
// Skip bias if the bounding box is too large (~500 km diagonal)
|
||||
const dlat = maxLat - minLat
|
||||
const dlng = maxLng - minLng
|
||||
const avgLatRad = ((minLat + maxLat) / 2) * (Math.PI / 180)
|
||||
const diagKm = Math.sqrt((dlat * 111) ** 2 + (dlng * 111 * Math.cos(avgLatRad)) ** 2)
|
||||
if (diagKm > 500) return undefined
|
||||
|
||||
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
|
||||
}, [places])
|
||||
// Where the trip is happening — the hint that tells the search which of a
|
||||
// thousand identically named places is meant. Autocomplete wants a box, the
|
||||
// search wants a point; useLocationBias derives both from the same places.
|
||||
const { box: locationBias, point: locationBiasPoint } = useLocationBias()
|
||||
|
||||
// Autocomplete fetch — aborts any in-flight request before starting a new one
|
||||
const fetchSuggestions = useCallback(async (query: string) => {
|
||||
@@ -252,6 +253,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
acAbortRef.current = controller
|
||||
try {
|
||||
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal, placesSessionRef.current.current())
|
||||
acMetaRef.current = { query, source: result.source || 'unknown' }
|
||||
setAcSuggestions(result.suggestions || [])
|
||||
setAcHighlight(-1)
|
||||
} catch (err: unknown) {
|
||||
@@ -310,7 +312,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
return
|
||||
}
|
||||
}
|
||||
const result = await mapsApi.search(mapsSearch, language)
|
||||
const result = await mapsApi.search(mapsSearch, language, locationBiasPoint)
|
||||
searchMetaRef.current = { query: mapsSearch.trim(), source: result.source || 'unknown' }
|
||||
setMapsResults(result.places || [])
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||
@@ -319,7 +322,13 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectMapsResult = (result) => {
|
||||
/**
|
||||
* `pick` is present only when the click came from a ranked list. The
|
||||
* collection picker and the autocomplete detour reach this function with a
|
||||
* place that was never ranked against a query, and a made-up rank would be
|
||||
* worse than no row at all.
|
||||
*/
|
||||
const handleSelectMapsResult = (result, pick?: { mode: 'search' | 'autocomplete'; rank: number; count: number }) => {
|
||||
setForm(prev => mergeResult(prev, result, autoFilledRef.current))
|
||||
// The one point every pick flows through, so the detail column hangs here.
|
||||
// A new pick drops whatever hero image belonged to the previous place.
|
||||
@@ -336,12 +345,36 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
details: result,
|
||||
})
|
||||
setForm(prev => ({ ...prev, image_url: undefined }))
|
||||
if (pick) {
|
||||
const meta = pick.mode === 'search' ? searchMetaRef.current : acMetaRef.current
|
||||
if (meta) {
|
||||
recordPlacePick({
|
||||
query: meta.query,
|
||||
lang: language,
|
||||
// The bias the search actually ran under is a box around the trip's
|
||||
// existing places; the corpus stores its centre, which is what an
|
||||
// evaluation needs to bias its own index the same way.
|
||||
biasLat: locationBias ? (locationBias.low.lat + locationBias.high.lat) / 2 : undefined,
|
||||
biasLng: locationBias ? (locationBias.low.lng + locationBias.high.lng) / 2 : undefined,
|
||||
source: `${pick.mode}:${meta.source}`,
|
||||
liveRank: pick.rank,
|
||||
liveCount: pick.count,
|
||||
pickedName: result.name || '',
|
||||
pickedLat: lat,
|
||||
pickedLng: lng,
|
||||
pickedPlaceId: result.google_place_id || result.osm_id || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
setMapsResults([])
|
||||
setMapsSearch('')
|
||||
}
|
||||
|
||||
const handleSelectSuggestion = async (suggestion: { placeId: string; mainText: string; secondaryText: string }) => {
|
||||
// Read before the list is cleared: this is the rank the user saw.
|
||||
const acRank = acSuggestions.findIndex(s => s.placeId === suggestion.placeId)
|
||||
const acCount = acSuggestions.length
|
||||
setAcSuggestions([])
|
||||
setAcHighlight(-1)
|
||||
const previousSearch = mapsSearch
|
||||
@@ -368,11 +401,11 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
}
|
||||
if (!place) {
|
||||
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
|
||||
const search = await mapsApi.search(query, language)
|
||||
const search = await mapsApi.search(query, language, locationBiasPoint)
|
||||
place = search.places?.[0] ?? null
|
||||
}
|
||||
if (place) {
|
||||
handleSelectMapsResult(place)
|
||||
handleSelectMapsResult(place, acRank >= 0 ? { mode: 'autocomplete', rank: acRank, count: acCount } : undefined)
|
||||
} else {
|
||||
setMapsSearch(previousSearch)
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
@@ -479,6 +512,15 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
lat: form.lat ? Number.parseFloat(form.lat) : null,
|
||||
lng: form.lng ? Number.parseFloat(form.lng) : null,
|
||||
category_id: form.category_id || null,
|
||||
// An explicit null is how a stop stops being a fuel stop; the service reads it
|
||||
// that way rather than as "leave alone", which is what a missing key means.
|
||||
stop_type: form.stop_type || null,
|
||||
// Only on the way in, and only with a kind: it is the popup's suggestion for how
|
||||
// long that kind of pause takes. On an edit it is left out entirely, because the
|
||||
// stay belongs to the rail's dialog and sending it here would overwrite it.
|
||||
...(!place && form.stop_type && form.duration_minutes
|
||||
? { duration_minutes: form.duration_minutes }
|
||||
: {}),
|
||||
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||
}
|
||||
// #2163: the per-assignment note only travels when an assignment is in
|
||||
@@ -527,6 +569,7 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
dayAssignments,
|
||||
isMobile,
|
||||
collectionsEnabled,
|
||||
roadtripEnabled,
|
||||
form,
|
||||
setForm,
|
||||
mapsSearch,
|
||||
@@ -599,6 +642,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
dayAssignments,
|
||||
isMobile,
|
||||
collectionsEnabled,
|
||||
roadtripEnabled,
|
||||
form,
|
||||
setForm,
|
||||
mapsSearch,
|
||||
@@ -768,7 +812,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => handleSelectMapsResult(result)}
|
||||
onClick={() => handleSelectMapsResult(result, { mode: 'search', rank: idx, count: mapsResults.length })}
|
||||
className="w-full text-left px-3 py-2 hover:bg-surface-hover border-b border-edge-faint last:border-0"
|
||||
>
|
||||
<div className="font-medium text-sm">{result.name}</div>
|
||||
@@ -870,6 +914,30 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What kind of stop this is on a drive.
|
||||
Under the category rather than beside it, because the two answer different
|
||||
questions: a category is the traveller's own label, editable and shared across
|
||||
the whole instance, while this is a fact about the place. Refuelling is not a
|
||||
taste. Shown always, not only in road trip mode: a fuel stop added on a Tuesday
|
||||
is still a fuel stop when the mode is switched on later, and this is the only
|
||||
place in the web UI where the kind can be taken off again. */}
|
||||
{roadtripEnabled ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">{t('roadtrip.stop.kind')}</label>
|
||||
<CustomSelect
|
||||
value={form.stop_type ?? ''}
|
||||
onChange={value => handleChange('stop_type', String(value) as RoadtripStopType | '')}
|
||||
placeholder={t('roadtrip.stop.none')}
|
||||
options={[
|
||||
{ value: '', label: t('roadtrip.stop.none') },
|
||||
...STOP_KINDS.map(k => ({ value: k.key, label: t(k.labelKey) })),
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
<p className="mt-1 text-caption text-content-faint">{t('roadtrip.stop.kindHelp')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-content-secondary mb-1">{t('places.formCategory')}</label>
|
||||
|
||||
@@ -43,6 +43,9 @@ export function ListImportModal(S: SidebarState) {
|
||||
)}
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', marginBottom: 16 }}>
|
||||
{t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
|
||||
{/* Same box, same Share button: which screen the link came from is the URL's
|
||||
business, and until it said so nobody knew a route could be pasted here. */}
|
||||
{listImportProvider === 'google' ? <div style={{ marginTop: 4 }}>{t('places.googleDirHint')}</div> : null}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -254,7 +254,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
// Hotels link a place through the accommodation record; every other type links
|
||||
// the picked trip place/activity directly on the reservation (#1353).
|
||||
place_id: form.type === 'hotel' ? null : (form.place_id || null),
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
// An empty object, not null: null clears the column outright, and that
|
||||
// took the mirrored booking price with it on every edit of a type that
|
||||
// fills no metadata of its own — restaurant, event, tour, parking, other,
|
||||
// a hotel without check-in times (#2233). An object still clears what the
|
||||
// form dropped, and lets the server carry the price across.
|
||||
metadata,
|
||||
// Omitted on an edit: the server replaces the endpoint set whenever the
|
||||
// key is present, and this form never edits endpoints, so sending an
|
||||
// empty list would drop a transit booking's stations (#2216).
|
||||
|
||||
@@ -175,6 +175,29 @@ describe('TransitSearchPanel', () => {
|
||||
await user.click(await screen.findByText(/London Victoria/))
|
||||
}
|
||||
|
||||
/**
|
||||
* An empty result is ambiguous without the backend's name: the fallback to
|
||||
* Transitous is silent, so "no connections" could mean the provider has no
|
||||
* data here or that the provider you picked never ran (#1699).
|
||||
*/
|
||||
it('FE-PLANNER-TRANSIT-006b: the empty state names the backend that answered', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [], provider: 'transitous' })
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
await pickFromAndLondon(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
expect(await screen.findByText(/No connections found via Transitous/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-006c: falls back to the unnamed message when the server sends no provider', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
|
||||
render(<TransitSearchPanel {...makeProps()} />)
|
||||
await pickFromAndLondon(user)
|
||||
await user.click(screen.getByRole('button', { name: /^Search$/ }))
|
||||
expect(await screen.findByText(/^No connections found\./)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-TRANSIT-007: depart-by anchors the entered time to the origin timezone', async () => {
|
||||
const user = userEvent.setup()
|
||||
transitApiMock.plan.mockResolvedValueOnce({ itineraries: [] })
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Day, Place, Accommodation } from '../../types'
|
||||
import type { TransitProvider } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Public transit route search (#1065), backed by Transitous (MOTIS) through the
|
||||
@@ -36,6 +37,14 @@ interface TransitPlaceResult { name: string; lat: number; lng: number; type: str
|
||||
|
||||
export interface PickedPlace { name: string; lat: number; lng: number }
|
||||
|
||||
// Backend names as they are written, not translated — the empty state says
|
||||
// which one answered so "nothing here" can be told apart from "not the one you
|
||||
// picked" (#1699).
|
||||
const PROVIDER_NAMES: Record<TransitProvider, string> = {
|
||||
transitous: 'Transitous',
|
||||
google: 'Google',
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const MODE_GROUPS: { key: string; labelKey: string; Icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; modes: string }[] = [
|
||||
@@ -340,7 +349,7 @@ interface TransitSearchPanelProps {
|
||||
}
|
||||
|
||||
export default function TransitSearchPanel({ day, days, places, accommodations = [], onAdd, initialFrom = null, initialTo = null, initialTime = null }: TransitSearchPanelProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t, language } = useTranslation()
|
||||
const toast = useToast()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||
@@ -355,6 +364,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [expandedIdx, setExpandedIdx] = useState<number | null>(null)
|
||||
const [addingIdx, setAddingIdx] = useState<number | null>(null)
|
||||
const [provider, setProvider] = useState<TransitProvider | null>(null)
|
||||
|
||||
// Quick picks: the day's located places, plus the trip's located accommodations.
|
||||
const quickPicks = useMemo<PickedPlace[]>(() => {
|
||||
@@ -387,6 +397,7 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
setLoading(true)
|
||||
setItineraries(null)
|
||||
setExpandedIdx(null)
|
||||
setProvider(null)
|
||||
try {
|
||||
const tzFrom = tzAt(from.lat, from.lng)
|
||||
const tzTo = tzAt(to.lat, to.lng)
|
||||
@@ -395,7 +406,11 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
const timeIso = localToUtcIso(day.date, time, arriveBy ? tzTo : tzFrom)
|
||||
const allModes = activeModes.size === MODE_GROUPS.length
|
||||
const modes = allModes ? undefined : MODE_GROUPS.filter(m => activeModes.has(m.key)).map(m => m.modes).join(',')
|
||||
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes })
|
||||
const d = await transitApi.plan({ from: `${from.lat},${from.lng}`, to: `${to.lat},${to.lng}`, time: timeIso, arriveBy, modes, lang: language })
|
||||
// Which backend actually answered (#1699). Named in the empty state so
|
||||
// "nothing found" can be told apart from "the provider you picked never
|
||||
// ran" — the fallback to Transitous is silent by design.
|
||||
setProvider(d.provider ?? null)
|
||||
// MOTIS names the request coordinates START/END — swap in the places the
|
||||
// user actually picked so walks read "Walk to Zoologischer Garten".
|
||||
const cleanStop = (n: string) => (n === 'START' ? from.name : n === 'END' ? to.name : n)
|
||||
@@ -606,7 +621,9 @@ export default function TransitSearchPanel({ day, days, places, accommodations =
|
||||
)}
|
||||
{!loading && ranked && ranked.length === 0 && (
|
||||
<div className="text-content-faint" style={{ textAlign: 'center', padding: '24px 0', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
{t('transit.noResults')}
|
||||
{provider
|
||||
? t('transit.noResultsVia', { provider: PROVIDER_NAMES[provider] })
|
||||
: t('transit.noResults')}
|
||||
</div>
|
||||
)}
|
||||
{!loading && ranked && ranked.length > 0 && (
|
||||
|
||||
@@ -1192,6 +1192,93 @@ describe('TransportModal', () => {
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-058: a car booking keeps its stops in order between pick-up and return (#1797)', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen Berlin → Prag');
|
||||
// Pick-up and return are the rental frame; the stops sit between them.
|
||||
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
// Pick-up and return come first in the form, the stops follow — so the saved
|
||||
// route is from · stop · stop · to even though the fields read in another order.
|
||||
const fields = screen.getAllByTestId('location-select');
|
||||
expect(fields).toHaveLength(4);
|
||||
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
||||
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
||||
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
|
||||
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'stop', 'stop', 'to']);
|
||||
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
|
||||
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Dresden', 'Bastei', 'Prag']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-060: the stops of a car booking can be put in another order', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
|
||||
const fields = screen.getAllByTestId('location-select');
|
||||
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
||||
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
||||
fireEvent.change(fields[2], { target: { value: 'Dresden' } });
|
||||
fireEvent.change(fields[3], { target: { value: 'Bastei' } });
|
||||
|
||||
// The order of the stops is the order they are driven, and `sequence` is derived
|
||||
// from it on save — so without this the only way to swap two was to retype both.
|
||||
await userEvent.click(screen.getAllByRole('button', { name: /Move down/i })[0]);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.endpoints.map((e: { name: string }) => e.name)).toEqual(['Berlin', 'Bastei', 'Dresden', 'Prag']);
|
||||
expect(payload.endpoints.map((e: { sequence: number }) => e.sequence)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-061: the ends of the stop list cannot be pushed past themselves', async () => {
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
// A single stop has nothing to swap with, so the buttons stay away entirely.
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
expect(screen.queryByRole('button', { name: /Move up/i })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
const up = screen.getAllByRole('button', { name: /Move up/i });
|
||||
const down = screen.getAllByRole('button', { name: /Move down/i });
|
||||
expect(up[0]).toBeDisabled();
|
||||
expect(down[down.length - 1]).toBeDisabled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-059: a stop removed from a car booking is gone from the saved route', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} days={routeDays} selectedDayId={10} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e.g. Lufthansa/i), 'Mietwagen');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Add stop/i }));
|
||||
expect(screen.getAllByTestId('location-select')).toHaveLength(3);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Remove stop/i }));
|
||||
expect(screen.getAllByTestId('location-select')).toHaveLength(2);
|
||||
|
||||
const fields = screen.getAllByTestId('location-select');
|
||||
fireEvent.change(fields[0], { target: { value: 'Berlin' } });
|
||||
fireEvent.change(fields[1], { target: { value: 'Prag' } });
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave.mock.calls[0][0].endpoints.map((e: { role: string }) => e.role)).toEqual(['from', 'to']);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-057: the open-file link on an attached document does not throw', async () => {
|
||||
const res = buildReservation({ id: 23, type: 'flight', title: 'LH 400' });
|
||||
const attached = buildTripFile({ id: 72, original_name: 'boarding.pdf' });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'react-router'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
@@ -116,6 +116,19 @@ interface StationWaypointForm {
|
||||
seat: string
|
||||
confirmation_number: string
|
||||
}
|
||||
// ── Car stops along the drive ──────────────────────────────────────────────
|
||||
//
|
||||
// A rental is one booking with one pick-up and one return, so — unlike a flight or a
|
||||
// train — the stops in between are not legs of their own: they are places the drive
|
||||
// passes through, each with an optional time the driver plans to be there. They persist
|
||||
// as the same `role: 'stop'` endpoints every other transport type already uses.
|
||||
interface CarStopForm {
|
||||
location: LocationPoint | null
|
||||
time: string
|
||||
}
|
||||
|
||||
const emptyCarStop = (): CarStopForm => ({ location: null, time: '' })
|
||||
|
||||
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
|
||||
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '', confirmation_number: '' }
|
||||
}
|
||||
@@ -200,6 +213,20 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
|
||||
// Train route as an ordered list of stations (origin .. stops .. destination).
|
||||
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
|
||||
// A car keeps its pick-up and return as the frame of the rental and gains the stops
|
||||
// in between (#1797): one booking, one continuous drive, several places along it.
|
||||
const [carStops, setCarStops] = useState<CarStopForm[]>([])
|
||||
|
||||
/** Swaps a stop with its neighbour. Purely local: `sequence` is derived on save. */
|
||||
const moveCarStop = (index: number, delta: number): void => {
|
||||
setCarStops(prev => {
|
||||
const to = index + delta
|
||||
if (to < 0 || to >= prev.length) return prev
|
||||
const next = [...prev]
|
||||
;[next[index], next[to]] = [next[to], next[index]]
|
||||
return next
|
||||
})
|
||||
}
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
@@ -346,6 +373,17 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
} else {
|
||||
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||
// Stops persist for every type; only a car offers an editor for them, so only a
|
||||
// car reads them back into one. The others keep passing theirs through untouched.
|
||||
setCarStops(
|
||||
src.type === 'car'
|
||||
? (src.endpoints ?? [])
|
||||
.filter(e => e.role === 'stop')
|
||||
.slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
.map(e => ({ location: locationFromEndpoint(e), time: e.local_time ?? '' }))
|
||||
: [],
|
||||
)
|
||||
}
|
||||
} else {
|
||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
|
||||
@@ -354,6 +392,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
setToPick({})
|
||||
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
|
||||
setTrainWaypoints([emptyStationWaypoint(selectedDayId ?? ''), emptyStationWaypoint(selectedDayId ?? '')])
|
||||
setCarStops([])
|
||||
}
|
||||
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
|
||||
|
||||
@@ -504,8 +543,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
})
|
||||
} else {
|
||||
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
||||
// Keep the itinerary's transfer stops while the route is unchanged (#1065).
|
||||
const stops = keepTransit
|
||||
// A car writes the stops the driver planned; every other type keeps passing the
|
||||
// itinerary's transfer stops through while the route is unchanged (#1065).
|
||||
const carEndpoints = form.type === 'car'
|
||||
? carStops
|
||||
.filter(s => s.location)
|
||||
.map((s, i) => endpointFromLocation(s.location!, 'stop', i + 1, startDate, s.time || null))
|
||||
: []
|
||||
const stops = keepTransit && form.type !== 'car'
|
||||
? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
|
||||
: []
|
||||
stops.forEach((s, i) => endpoints.push({
|
||||
@@ -513,7 +558,9 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
lat: s.lat, lng: s.lng, timezone: s.timezone ?? null,
|
||||
local_date: s.local_date ?? null, local_time: s.local_time ?? null,
|
||||
}))
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 1, endDate, form.arrival_time || null))
|
||||
carEndpoints.forEach(e => endpoints.push(e))
|
||||
const stopCount = stops.length + carEndpoints.length
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stopCount + 1, endDate, form.arrival_time || null))
|
||||
}
|
||||
|
||||
// Flights and trains derive their span from the first/last waypoint; other
|
||||
@@ -543,7 +590,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
location: null,
|
||||
confirmation_number: form.confirmation_number || null,
|
||||
notes: form.notes || null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
// An empty object, not null: null clears the column outright, and that
|
||||
// took the mirrored booking price with it on every edit of a type that
|
||||
// fills no metadata of its own — restaurant, event, tour, parking, other,
|
||||
// a hotel without check-in times (#2233). An object still clears what the
|
||||
// form dropped, and lets the server carry the price across.
|
||||
metadata,
|
||||
endpoints,
|
||||
needs_review: false,
|
||||
}
|
||||
@@ -950,6 +1002,76 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stops along the drive — cars only (#1797). The rental frame above stays the
|
||||
pick-up and return; these are the places in between, in order. */}
|
||||
{form.type === 'car' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label className={labelClass}>{t('roadtrip.stops.label')}</label>
|
||||
{carStops.map((stop, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||||
{/* The order of these stops IS the route: `sequence` is the array index
|
||||
at save time, so the only way to change which one is driven first
|
||||
was to delete both and re-enter them. Buttons rather than dragging,
|
||||
because the row already carries a location picker and a time picker
|
||||
and there is nothing left to grab. */}
|
||||
{carStops.length > 1 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveCarStop(i, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label={t('dayplan.moveUp')}
|
||||
className="text-content-faint enabled:hover:text-content disabled:opacity-30"
|
||||
style={{ background: 'none', border: 'none', cursor: i === 0 ? 'default' : 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<ChevronUp size={13} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveCarStop(i, 1)}
|
||||
disabled={i === carStops.length - 1}
|
||||
aria-label={t('dayplan.moveDown')}
|
||||
className="text-content-faint enabled:hover:text-content disabled:opacity-30"
|
||||
style={{ background: 'none', border: 'none', cursor: i === carStops.length - 1 ? 'default' : 'pointer', padding: '2px 4px', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<LocationSelect
|
||||
value={stop.location}
|
||||
onChange={l => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, location: l || null } : s)))}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 110, flexShrink: 0 }}>
|
||||
<CustomTimePicker
|
||||
value={stop.time}
|
||||
onChange={v => setCarStops(prev => prev.map((s, j) => (j === i ? { ...s, time: v } : s)))}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCarStops(prev => prev.filter((_, j) => j !== i))}
|
||||
aria-label={t('roadtrip.stops.remove')}
|
||||
className="text-content-faint hover:text-danger"
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 8, display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCarStops(prev => [...prev, emptyCarStop()])}
|
||||
className="text-content-faint hover:text-content-secondary"
|
||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
<Plus size={12} /> {t('reservations.layover.addStop')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Departure row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import RoadtripAlternativesBar from './RoadtripAlternativesBar'
|
||||
import type { AlternativeOverlay } from './alternativeOverlays'
|
||||
import type { LegAlternatives } from './useRouteAlternatives'
|
||||
|
||||
/**
|
||||
* FE-ALTBAR-001..009 — the ways of driving one leg, offered over the map.
|
||||
*
|
||||
* Every value shown comes from the same overlay the map draws. Working them out
|
||||
* here separately is what once made the list label the driven route "Fastest"
|
||||
* while offering another one as quicker beside it, because it assumed the first
|
||||
* entry was the quickest — which stopped being true the moment the road
|
||||
* currently driven was put at the top.
|
||||
*/
|
||||
|
||||
const overlay = (over: Partial<AlternativeOverlay> = {}): AlternativeOverlay => ({
|
||||
index: 0,
|
||||
coordinates: [],
|
||||
color: '#0a84ff',
|
||||
label: '3 h',
|
||||
note: '',
|
||||
duration: 10_800,
|
||||
distance: 290_000,
|
||||
slowerThanQuickest: 0,
|
||||
labelBg: '#0a84ff',
|
||||
at: { lat: 53, lng: 11 },
|
||||
...over,
|
||||
})
|
||||
|
||||
const leg = (over: Partial<LegAlternatives> = {}): LegAlternatives => ({
|
||||
dayId: 4, index: 1, routes: [], loading: false, error: false, ...over,
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ settings: { distance_unit: 'metric' } as never })
|
||||
})
|
||||
|
||||
describe('RoadtripAlternativesBar', () => {
|
||||
it('FE-ALTBAR-001: nothing open means nothing drawn', () => {
|
||||
const { container } = render(
|
||||
<RoadtripAlternativesBar open={null} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-002: while the router is answering it says so rather than showing an empty list', () => {
|
||||
render(
|
||||
<RoadtripAlternativesBar open={leg({ loading: true })} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText(/asking the router/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-003: a router that will not answer says so, and offers nothing', () => {
|
||||
render(
|
||||
<RoadtripAlternativesBar open={leg({ error: true })} overlays={[]} onChoose={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText(/not answering/i)).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('button', { name: /km/ })).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-004: one route back is not a choice, and is said as one', () => {
|
||||
// Offering a single option would ask the traveller to pick the road they are
|
||||
// already on.
|
||||
render(
|
||||
<RoadtripAlternativesBar open={leg()} overlays={[overlay()]} onChoose={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByText(/only/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-005: each offer shows its distance and what it is', () => {
|
||||
render(
|
||||
<RoadtripAlternativesBar
|
||||
open={leg()}
|
||||
overlays={[overlay({ note: 'Fastest' }), overlay({ index: 1, distance: 310_000, note: 'No motorway' })]}
|
||||
onChoose={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText('Fastest')).toBeInTheDocument()
|
||||
expect(screen.getByText('No motorway')).toBeInTheDocument()
|
||||
expect(screen.getByText(/290/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/310/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-006: an offer with nothing to say about itself says how much slower it is', () => {
|
||||
render(
|
||||
<RoadtripAlternativesBar
|
||||
open={leg()}
|
||||
overlays={[overlay({ note: 'Fastest' }), overlay({ index: 1, note: '', slowerThanQuickest: 1800 })]}
|
||||
onChoose={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/30/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-007: choosing one reports its own index, not its position', () => {
|
||||
// The map and the list agree on `index`; a position would drift the moment
|
||||
// the driven road is prepended.
|
||||
const onChoose = vi.fn()
|
||||
render(
|
||||
<RoadtripAlternativesBar
|
||||
open={leg()}
|
||||
overlays={[overlay({ index: 3 }), overlay({ index: 7 })]}
|
||||
onChoose={onChoose}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
fireEvent.click(screen.getAllByRole('button')[2])
|
||||
expect(onChoose).toHaveBeenCalledWith(7)
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-008: pointing at an offer lights that road up, and leaving clears it', () => {
|
||||
const onHighlight = vi.fn()
|
||||
render(
|
||||
<RoadtripAlternativesBar
|
||||
open={leg()}
|
||||
overlays={[overlay({ index: 0 }), overlay({ index: 1 })]}
|
||||
onChoose={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
onHighlight={onHighlight}
|
||||
/>,
|
||||
)
|
||||
const first = screen.getAllByRole('button')[1]
|
||||
fireEvent.mouseEnter(first)
|
||||
expect(onHighlight).toHaveBeenLastCalledWith(0)
|
||||
fireEvent.mouseLeave(first)
|
||||
expect(onHighlight).toHaveBeenLastCalledWith(null)
|
||||
// Keyboard too: the same road lights up on focus.
|
||||
fireEvent.focus(first)
|
||||
expect(onHighlight).toHaveBeenLastCalledWith(0)
|
||||
})
|
||||
|
||||
it('FE-ALTBAR-009: closing is always available, even while it is still asking', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<RoadtripAlternativesBar open={leg({ loading: true })} overlays={[]} onChoose={vi.fn()} onClose={onClose} />,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button', { name: /close/i }))
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import React from 'react'
|
||||
import { Shuffle, X, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import type { LegAlternatives } from './useRouteAlternatives'
|
||||
import type { AlternativeOverlay } from './alternativeOverlays'
|
||||
|
||||
interface RoadtripAlternativesBarProps {
|
||||
open: LegAlternatives | null
|
||||
/** The very rows the map draws, so the two can never disagree about which is fastest. */
|
||||
overlays: AlternativeOverlay[]
|
||||
onChoose: (index: number) => void
|
||||
onClose: () => void
|
||||
/** Reports which option the pointer is on, so the map can light that road up. */
|
||||
onHighlight?: (index: number | null) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The ways of driving one leg, offered over the map.
|
||||
*
|
||||
* A bar rather than a dialog: the answer is on the map behind it — which road each option
|
||||
* takes — so covering the map to ask about it would hide the only thing worth looking at.
|
||||
*
|
||||
* Every value here comes from the same overlay the map draws. Working them out separately
|
||||
* is what made the list label the driven route "Fastest" while offering another one
|
||||
* "58 min quicker" beside it: it assumed the first entry was the quickest, which stopped
|
||||
* being true the moment the road currently driven was put at the top.
|
||||
*/
|
||||
export default function RoadtripAlternativesBar({
|
||||
open, overlays, onChoose, onClose, onHighlight,
|
||||
}: RoadtripAlternativesBarProps): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex max-w-[min(92vw,640px)] flex-col gap-2 rounded-2xl border border-edge-faint bg-surface-elevated px-3 py-2.5 shadow-modal backdrop-blur">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shuffle size={13} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.alt.title')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t('common.close')}
|
||||
className="ms-auto rounded-full p-1 text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={13} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open.loading ? (
|
||||
<p className="text-caption text-content-muted">{t('roadtrip.alt.loading')}</p>
|
||||
) : open.error ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-warning">
|
||||
<AlertTriangle size={12} className="mt-0.5 shrink-0" aria-hidden />
|
||||
{t('roadtrip.alt.failed')}
|
||||
</p>
|
||||
) : overlays.length < 2 ? (
|
||||
// One route back means there is genuinely only one sensible way to drive it.
|
||||
<p className="text-caption text-content-muted">{t('roadtrip.alt.onlyOne')}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{overlays.map(alt => (
|
||||
<button
|
||||
key={alt.index}
|
||||
type="button"
|
||||
onClick={() => onChoose(alt.index)}
|
||||
onMouseEnter={() => onHighlight?.(alt.index)}
|
||||
onMouseLeave={() => onHighlight?.(null)}
|
||||
onFocus={() => onHighlight?.(alt.index)}
|
||||
onBlur={() => onHighlight?.(null)}
|
||||
className="flex items-center gap-2 rounded-xl border border-edge bg-surface-card px-2.5 py-1.5 text-start transition-colors hover:border-content-faint"
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
// theme-lint-disable — the very colour this route is drawn in on the map;
|
||||
// a token here would break the one link between list and picture.
|
||||
style={{ background: alt.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-caption font-medium tabular-nums text-content">
|
||||
{formatDistance(alt.distance / 1000, distanceUnit)}
|
||||
</span>
|
||||
<span className="text-caption tabular-nums text-content-muted">
|
||||
{alt.note
|
||||
? alt.note
|
||||
: t('roadtrip.alt.slower', { time: formatDurationShort(alt.slowerThanQuickest) })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { Fuel, Zap, Utensils } from 'lucide-react'
|
||||
import { fireEvent, render, screen, within } from '../../../tests/helpers/render'
|
||||
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
|
||||
|
||||
/**
|
||||
* FE-CATPICK-001..008 — what the corridor is asked to look for.
|
||||
*
|
||||
* Multi-select, so it stays open on a click: picking "fuel and charging" is one
|
||||
* gesture rather than two round trips through a menu. The summary names the
|
||||
* kinds rather than counting them, because "Fuel, Charging" is the answer and
|
||||
* "2 kinds" is a riddle.
|
||||
*/
|
||||
|
||||
const KEYS = ['fuel', 'charging', 'food'] as const
|
||||
const META = {
|
||||
fuel: { labelKey: 'roadtrip.poi.fuel', Icon: Fuel },
|
||||
charging: { labelKey: 'roadtrip.poi.charging', Icon: Zap },
|
||||
food: { labelKey: 'roadtrip.poi.food', Icon: Utensils },
|
||||
}
|
||||
|
||||
function picker(selected: string[] = [], onToggle = vi.fn()) {
|
||||
render(<RoadtripCategoryPicker keys={KEYS} meta={META} selected={selected} onToggle={onToggle} />)
|
||||
return { onToggle, trigger: () => screen.getByRole('button', { expanded: false }) }
|
||||
}
|
||||
|
||||
/** The control that opens the list, whatever its current state. */
|
||||
const trigger = () => screen.getAllByRole('button')[0]
|
||||
|
||||
describe('RoadtripCategoryPicker', () => {
|
||||
it('FE-CATPICK-001: closed by default, so the column belongs to the results', () => {
|
||||
picker()
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('FE-CATPICK-002: with nothing picked it asks rather than reporting an empty set', () => {
|
||||
picker()
|
||||
expect(screen.getByText('Looking for')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-CATPICK-003: the summary names the kinds, in the order they are offered', () => {
|
||||
// Reversed on the way in: the control lists them the way it offers them, not
|
||||
// the way they happened to be clicked.
|
||||
picker(['food', 'fuel'])
|
||||
expect(screen.getByText('Fuel, Food')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-CATPICK-004: opening lists every kind', () => {
|
||||
picker()
|
||||
fireEvent.click(trigger())
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
|
||||
for (const label of ['Fuel', 'Charging', 'Food']) {
|
||||
expect(screen.getAllByText(label).length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-CATPICK-005: picking one reports it and leaves the list open', () => {
|
||||
// The whole reason it is multi-select: two kinds is one gesture.
|
||||
const { onToggle } = picker()
|
||||
fireEvent.click(trigger())
|
||||
fireEvent.click(screen.getByText('Charging'))
|
||||
|
||||
expect(onToggle).toHaveBeenCalledWith('charging')
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
|
||||
})
|
||||
|
||||
it('FE-CATPICK-006: picking a kind that is already on reports it too, for the toggle', () => {
|
||||
const { onToggle } = picker(['fuel'])
|
||||
fireEvent.click(trigger())
|
||||
fireEvent.click(screen.getAllByText('Fuel')[1] ?? screen.getByText('Fuel'))
|
||||
expect(onToggle).toHaveBeenCalledWith('fuel')
|
||||
})
|
||||
|
||||
it('FE-CATPICK-007: a click outside closes it, a click inside does not', () => {
|
||||
picker()
|
||||
fireEvent.click(trigger())
|
||||
|
||||
fireEvent.mouseDown(trigger())
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('FE-CATPICK-008: Escape closes it', () => {
|
||||
picker()
|
||||
fireEvent.click(trigger())
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Check, ChevronDown, type LucideIcon } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { serviceColor } from './roadtripModel'
|
||||
import { FS } from './typeScale'
|
||||
|
||||
interface RoadtripCategoryPickerProps {
|
||||
/** Every kind the corridor can look for, in the order they are offered. */
|
||||
keys: readonly string[]
|
||||
meta: Record<string, { labelKey: string; Icon: LucideIcon }>
|
||||
selected: readonly string[]
|
||||
onToggle: (key: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* What to look for, as one full-width control.
|
||||
*
|
||||
* Six pills wrapped onto three lines and took the height of the whole card for a set of
|
||||
* answers that rarely changes. Folded into a dropdown it is one line, and the space goes
|
||||
* to the results — which is what the column is actually for.
|
||||
*
|
||||
* Multi-select, so it stays open on a click: picking "fuel and charging" is one gesture
|
||||
* rather than two round trips through a menu. Each row leads with its own coloured icon,
|
||||
* the same one the rail puts on its dashed line and the map puts on the route.
|
||||
*/
|
||||
export default function RoadtripCategoryPicker({ keys, meta, selected, onToggle }: RoadtripCategoryPickerProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onPointer = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
|
||||
document.addEventListener('mousedown', onPointer)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointer)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// The kinds themselves rather than a count: "Fuel, Charging" is the answer, "2 kinds"
|
||||
// is a riddle. Truncated when it runs long, which is what the icons in front are for.
|
||||
const summary = selected.length
|
||||
? keys.filter(k => selected.includes(k)).map(k => t(meta[k].labelKey)).join(', ')
|
||||
: t('roadtrip.poi.looking')
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(o => !o)}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
style={{ fontSize: FS.control }}
|
||||
className="flex h-[32px] w-full items-center gap-2 rounded-xl border border-edge bg-surface-card pe-2 ps-2.5 text-start transition-colors hover:border-content-faint"
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{keys.filter(k => selected.includes(k)).slice(0, 3).map(k => {
|
||||
const Icon = meta[k].Icon
|
||||
return (
|
||||
<span
|
||||
key={k}
|
||||
className="grid h-[18px] w-[18px] place-items-center rounded-md"
|
||||
// theme-lint-disable — the road-signage palette in `roadtripModel`.
|
||||
style={{ background: serviceColor(k), color: '#fff' }} // theme-lint-disable — road-signage palette
|
||||
>
|
||||
<Icon size={10} strokeWidth={2.2} aria-hidden />
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</span>
|
||||
<span className={`min-w-0 flex-1 truncate ${selected.length ? 'font-medium text-content' : 'text-content-faint'}`}>
|
||||
{summary}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`shrink-0 text-content-faint transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-multiselectable
|
||||
className="absolute inset-x-0 top-full z-[var(--z-toast)] mt-1 overflow-hidden rounded-xl border border-edge bg-surface-elevated shadow-dropdown backdrop-blur"
|
||||
>
|
||||
{keys.map(key => {
|
||||
const { labelKey, Icon } = meta[key]
|
||||
const on = selected.includes(key)
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
// Stays open: picking two kinds is one gesture, not two.
|
||||
onClick={() => onToggle(key)}
|
||||
style={{ fontSize: FS.control }}
|
||||
className="flex w-full items-center gap-2.5 px-2.5 py-2 text-start transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
<span
|
||||
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
|
||||
style={on ? { background: serviceColor(key), color: '#fff' } : { background: `${serviceColor(key)}1f`, color: serviceColor(key) }} // theme-lint-disable — road-signage palette
|
||||
>
|
||||
<Icon size={12} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
<span className={`min-w-0 flex-1 truncate ${on ? 'font-semibold text-content' : 'font-medium text-content-secondary'}`}>
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
{on ? <Check size={14} strokeWidth={2.4} className="shrink-0 text-content" aria-hidden /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, within, fireEvent } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripCorridorPanel from './RoadtripCorridorPanel'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
import type { RoadtripCorridor } from './useRoadtripCorridor'
|
||||
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
const stop = (id: number, name: string): RoadtripStop => ({
|
||||
assignmentId: id,
|
||||
placeId: id * 10,
|
||||
name,
|
||||
lat: 53.5,
|
||||
lng: 9.9,
|
||||
time: null,
|
||||
dwellMinutes: null,
|
||||
legMode: null,
|
||||
incomingLegMode: null,
|
||||
stopType: null,
|
||||
})
|
||||
|
||||
const day = (dayId: number, dayNumber: number): RoadtripDay => ({
|
||||
dayId,
|
||||
dayNumber,
|
||||
date: null,
|
||||
title: null,
|
||||
stops: [stop(dayId * 10 + 1, 'Hamburg'), stop(dayId * 10 + 2, 'Berlin')],
|
||||
legs: [],
|
||||
schedule: { entries: [], warnings: [] },
|
||||
legVias: [], driveWarnings: [], dayWarning: null,
|
||||
// Routed. Searching before the day has a geometry builds the corridor from the
|
||||
// straight line between the stops, and the routing answer landing a second
|
||||
// later clears the search mid-flight — so the button waits for this.
|
||||
geometry: [[53.55, 9.99], [52.52, 13.4]] as [number, number][],
|
||||
distance: 0,
|
||||
duration: 0,
|
||||
})
|
||||
|
||||
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
|
||||
days,
|
||||
lines: [],
|
||||
accessLines: [],
|
||||
vias: [],
|
||||
segments: [],
|
||||
totalDistance: 0,
|
||||
totalDuration: 0,
|
||||
totalStops: days.length * 2,
|
||||
quietDays: [],
|
||||
loading: false,
|
||||
})
|
||||
|
||||
function poi(over: Partial<CorridorPoi> & { osm_id: string; name: string }): CorridorPoi {
|
||||
return {
|
||||
lat: 53.5,
|
||||
lng: 9.9,
|
||||
category: 'fuel',
|
||||
poi_type: 'amenity=fuel',
|
||||
address: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
opening_hours: null,
|
||||
cuisine: null,
|
||||
source: 'openstreetmap',
|
||||
offRouteKm: 1.2,
|
||||
alongKm: 40,
|
||||
...over,
|
||||
} as CorridorPoi
|
||||
}
|
||||
|
||||
function corridor(over: Partial<RoadtripCorridor> = {}, search: Partial<RoadtripCorridor['search']> = {}): RoadtripCorridor {
|
||||
const days = over.day ? [over.day] : [day(1, 1)]
|
||||
const searchState: RoadtripCorridor['search'] = {
|
||||
results: [],
|
||||
progress: { done: 0, total: 0 },
|
||||
loading: false,
|
||||
capped: false,
|
||||
failedAreas: 0,
|
||||
truncatedAreas: 0,
|
||||
error: false,
|
||||
spine: [],
|
||||
search: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
...search,
|
||||
}
|
||||
return {
|
||||
dayId: String(days[0].dayId),
|
||||
setDayId: vi.fn(),
|
||||
day: days[0],
|
||||
categories: ['fuel'],
|
||||
toggleCategory: vi.fn(),
|
||||
widthKm: 5,
|
||||
setWidthKm: vi.fn(),
|
||||
nameFilter: '',
|
||||
anchors: [],
|
||||
section: null,
|
||||
setSection: vi.fn(),
|
||||
sectionKm: 50,
|
||||
setSectionKm: vi.fn(),
|
||||
socketFilter: '',
|
||||
setSocketFilter: vi.fn(),
|
||||
minKw: 0,
|
||||
setMinKw: vi.fn(),
|
||||
setNameFilter: vi.fn(),
|
||||
// Same as the hook with an empty filter: everything found is on show.
|
||||
visible: searchState.results,
|
||||
insertIndexFor: vi.fn(() => 1),
|
||||
stopsAlongKm: [0, 100],
|
||||
...over,
|
||||
search: searchState,
|
||||
}
|
||||
}
|
||||
|
||||
describe('RoadtripCorridorPanel', () => {
|
||||
it('FE-ROADTRIP-PANEL-001: searching is a button, never a side effect of opening the panel', () => {
|
||||
const c = corridor()
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(c.search.search).not.toHaveBeenCalled()
|
||||
fireEvent.click(screen.getByRole('button', { name: /search/i }))
|
||||
expect(c.search.search).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-004: a day that has not routed yet cannot be searched along', () => {
|
||||
// Until the day routes, the corridor is the straight line between the stops,
|
||||
// so the boxes march across whatever lies between them instead of along the
|
||||
// roads driven. Worse, the routing answer landing a second later changes the
|
||||
// line, which clears the search mid-flight and drops every result with no
|
||||
// error and no explanation. Routing runs one day at a time, about a second
|
||||
// apart, so on a long trip that window is wide open.
|
||||
const unrouted = { ...day(1, 1), geometry: [] as [number, number][] }
|
||||
const c = corridor({ day: unrouted })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([unrouted])} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-005: nor while the routing is still running', () => {
|
||||
const c = corridor()
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={{ ...routes([day(1, 1)]), loading: true }} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-002: nothing selected means nothing to search for', () => {
|
||||
const c = corridor({ categories: [] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-003: a day with a single stop has no drive to search along', () => {
|
||||
const lonely = { ...day(1, 1), stops: [stop(11, 'Hamburg')] }
|
||||
const c = corridor({ day: lonely })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([lonely])} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /search/i })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-004: toggling a kind reports which one', () => {
|
||||
const c = corridor()
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
// Six kinds used to be six pills wrapped over three lines; they are one dropdown now,
|
||||
// so the options only exist once it is open.
|
||||
fireEvent.click(screen.getByRole('button', { expanded: false }))
|
||||
fireEvent.click(screen.getByRole('option', { name: /campsite/i }))
|
||||
expect(c.toggleCategory).toHaveBeenCalledWith('campsite')
|
||||
// What is already selected reads as selected rather than only looking different.
|
||||
expect(screen.getByRole('option', { name: /fuel/i })).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-005: the corridor width is a choice, and the current one is marked', () => {
|
||||
const c = corridor()
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
const five = screen.getByRole('button', { name: '5 km' })
|
||||
expect(five).toHaveAttribute('aria-pressed', 'true')
|
||||
fireEvent.click(screen.getByRole('button', { name: '10 km' }))
|
||||
expect(c.setWidthKm).toHaveBeenCalledWith(10)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-006: the day picker only appears once there is more than one drive', () => {
|
||||
const one = corridor()
|
||||
const { unmount } = wrap(<RoadtripCorridorPanel corridor={one} routes={routes([day(1, 1)])} />)
|
||||
expect(screen.queryByText('Day 1')).not.toBeInTheDocument()
|
||||
unmount()
|
||||
|
||||
wrap(<RoadtripCorridorPanel corridor={corridor()} routes={routes([day(1, 1), day(2, 2)])} />)
|
||||
expect(screen.getByText('Day 1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-007: groups the hits by kind and counts each group', () => {
|
||||
const c = corridor({ categories: ['fuel', 'campsite'] }, {
|
||||
results: [
|
||||
poi({ osm_id: 'a', name: 'Aral', alongKm: 10 }),
|
||||
poi({ osm_id: 'b', name: 'Shell', alongKm: 20 }),
|
||||
poi({ osm_id: 'c', name: 'Camping Elbe', category: 'campsite', alongKm: 30 }),
|
||||
],
|
||||
})
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
// "Fuel" also names an option in the kind picker; the group header is the one
|
||||
// inside a <header>.
|
||||
const fuelGroup = screen.getAllByText('Fuel').map(el => el.closest('header')).find(Boolean)!
|
||||
expect(within(fuelGroup).getByText('2 on the way')).toBeInTheDocument()
|
||||
expect(screen.getByText('Camping Elbe')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-008: each hit says how far off the route and how far along it is', () => {
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', offRouteKm: 1.2, alongKm: 40 })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
const row = screen.getByText('Aral').closest('li')!
|
||||
expect(within(row).getByText(/1\.2 km/)).toBeInTheDocument()
|
||||
expect(within(row).getByText(/40 km/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-009: a hit right at the start says so instead of "0 km along"', () => {
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', alongKm: 0.1 })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText('Aral').closest('li')!.textContent).not.toContain('0 km along')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-010: a chain gets its category icon, never its own logo', () => {
|
||||
// A corridor is mostly chains, and their marks turned a list of petrol stations into
|
||||
// an advertisement — while a brand with no logo on file fell back to a different
|
||||
// picture, so no two rows looked alike. The category's icon is also what the rail and
|
||||
// the map draw for the same stop.
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral', brand_wikidata: 'Q565734' } as Partial<CorridorPoi> & { osm_id: string; name: string })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
const row = screen.getByText('Aral').closest('li')!
|
||||
expect(row.querySelector('img')).toBeNull()
|
||||
expect(row.querySelector('svg.lucide-fuel')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-012: adding a hit puts it on the day that was searched', () => {
|
||||
const onAddPoi = vi.fn()
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'node:9', name: 'Aral' })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} onAddPoi={onAddPoi} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /add/i }))
|
||||
// Day and position both: a fuel stop belongs where it is driven past, not at the end.
|
||||
expect(onAddPoi).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'node:9' }), 1, 1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-023: the position offered is the one worked out for that very hit', () => {
|
||||
const onAddPoi = vi.fn()
|
||||
const hit = poi({ osm_id: 'node:9', name: 'Aral', alongKm: 120 })
|
||||
const insertIndexFor = vi.fn(() => 3)
|
||||
const c = corridor({ insertIndexFor }, { results: [hit] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} onAddPoi={onAddPoi} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /add/i }))
|
||||
expect(insertIndexFor).toHaveBeenCalledWith(hit)
|
||||
expect(onAddPoi).toHaveBeenCalledWith(expect.objectContaining({ osm_id: 'node:9' }), 1, 3)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-013: while searching it reports progress rather than sitting still', () => {
|
||||
const c = corridor({}, { loading: true, progress: { done: 3, total: 12 } })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '25')
|
||||
expect(screen.getByRole('button', { name: /3/ })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-014: a partial answer says which stretches nobody looked at', () => {
|
||||
const c = corridor({}, { failedAreas: 2, results: [poi({ osm_id: 'a', name: 'Aral' })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
// The difference between "no fuel here" and "nobody looked here".
|
||||
expect(screen.getByText('2 stretches could not be searched — the place search did not answer.')).toBeInTheDocument()
|
||||
expect(screen.getByText('Aral')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-015: a total outage reads as a failure, not as an empty road', () => {
|
||||
const c = corridor({}, { error: true, failedAreas: 4 })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText('The place search is not answering right now.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-016: a drive longer than the budget admits the tail went unsearched', () => {
|
||||
const c = corridor({}, { capped: true })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText('The route is long — only the first stretch was searched.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-017: a stretch the server cut short is admitted, not passed off as empty', () => {
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })], truncatedAreas: 2 })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText(/2 stretches had more than fits in one answer/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-018: the filter appears only once there is something to narrow', () => {
|
||||
const empty = corridor()
|
||||
const { unmount } = wrap(<RoadtripCorridorPanel corridor={empty} routes={routes([day(1, 1)])} />)
|
||||
expect(screen.queryByLabelText('Filter by name')).not.toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
const found = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={found} routes={routes([day(1, 1)])} />)
|
||||
expect(screen.getByLabelText('Filter by name')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-019: typing in the filter reports the term rather than searching again', () => {
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Filter by name'), { target: { value: 'shell' } })
|
||||
expect(c.setNameFilter).toHaveBeenCalledWith('shell')
|
||||
// The filter must never trigger another round of Overpass requests.
|
||||
expect(c.search.search).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-020: the list shows what is visible, and counts it against the whole', () => {
|
||||
const hits = [poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]
|
||||
const c = corridor({ nameFilter: 'shell', visible: [hits[1]] }, { results: hits })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText('Shell')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Aral')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('1 of 2 on the way')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-021: filtering everything away says so instead of looking unsearched', () => {
|
||||
const c = corridor(
|
||||
{ nameFilter: 'esso', visible: [] },
|
||||
{ results: [poi({ osm_id: 'a', name: 'Aral' })] },
|
||||
)
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.getByText(/Nothing on the way matches/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('Pick what you need and search.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-PANEL-022: without permission to add, no hit offers an Add button', () => {
|
||||
const c = corridor({}, { results: [poi({ osm_id: 'a', name: 'Aral' })] })
|
||||
wrap(<RoadtripCorridorPanel corridor={c} routes={routes([day(1, 1)])} />)
|
||||
|
||||
expect(screen.queryByText('Add')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,555 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import {
|
||||
Search, Plus, RotateCw, AlertTriangle, X, BedDouble, MapPin,
|
||||
} from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import RoadtripCategoryPicker from './RoadtripCategoryPicker'
|
||||
import { serviceColor } from './roadtripModel'
|
||||
import { CORRIDOR_CATEGORY_BY_KEY } from './stopKinds'
|
||||
import { FS } from './typeScale'
|
||||
import { CORRIDOR_CATEGORY_KEYS, CORRIDOR_SECTION_KM, CORRIDOR_WIDTHS_KM, type RoadtripCorridor } from './useRoadtripCorridor'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
interface RoadtripCorridorPanelProps {
|
||||
corridor: RoadtripCorridor
|
||||
routes: RoadtripRoutes
|
||||
/** Opens the place form prefilled from a POI, on a day and at a position in it. */
|
||||
onAddPoi?: (
|
||||
poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string },
|
||||
dayId?: number | null,
|
||||
position?: number | null,
|
||||
) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* How each socket family is written on the plug.
|
||||
*
|
||||
* Not translated and not derived from the tag: these are proper nouns with an accepted
|
||||
* spelling, and "Type 2" as a lowercase tag key is not what anybody looks for on a
|
||||
* charger. Anything not listed falls through to the raw key rather than being hidden,
|
||||
* because an unknown socket is still worth seeing.
|
||||
*/
|
||||
/** The steps worth offering: a household socket, a fast AC post, and the two DC tiers. */
|
||||
const KW_STEPS = [11, 22, 50, 150]
|
||||
|
||||
const SOCKET_LABEL: Record<string, string> = {
|
||||
type2: 'Type 2',
|
||||
type2_combo: 'CCS',
|
||||
type2_cable: 'Type 2 cable',
|
||||
ccs: 'CCS',
|
||||
chademo: 'CHAdeMO',
|
||||
type1: 'Type 1',
|
||||
type1_combo: 'CCS1',
|
||||
schuko: 'Schuko',
|
||||
tesla_supercharger: 'Supercharger',
|
||||
tesla_destination: 'Tesla Destination',
|
||||
}
|
||||
|
||||
/** Label and icon per category, from the one table every road-trip surface reads. */
|
||||
const CATEGORY_META = CORRIDOR_CATEGORY_BY_KEY
|
||||
|
||||
/** The small capitalised word over a group of controls, matching the rail's own captions. */
|
||||
const EYEBROW = 'font-geist font-semibold uppercase tracking-[0.15em] text-content-faint'
|
||||
|
||||
/** A card in this column: the same corner, hairline and surface the rail's cards use. */
|
||||
const CARD = 'rounded-2xl border border-edge-faint bg-surface-card'
|
||||
|
||||
/**
|
||||
* The tile in front of a result, in its category's own colour.
|
||||
*
|
||||
* Never the brand's logo. A corridor is mostly chains, and putting their marks here made
|
||||
* a list of petrol stations read as an advertisement — while the ones with no logo on
|
||||
* file fell back to a different picture entirely, so no two rows looked alike. The
|
||||
* category's icon in its own tint is the same thing the rail draws on its dashed line and
|
||||
* the map draws on the route, which is what makes a row, a pin and a stop recognisably
|
||||
* one place.
|
||||
*/
|
||||
function ResultBadge({ category }: { category: string }): React.ReactElement {
|
||||
const Icon = CATEGORY_META[category]?.Icon ?? MapPin
|
||||
const color = serviceColor(category)
|
||||
return (
|
||||
<span
|
||||
className="grid h-[30px] w-[30px] shrink-0 place-items-center rounded-[10px]"
|
||||
// theme-lint-disable — the road-signage palette in `roadtripModel`, the same colour
|
||||
// this kind of stop carries in the rail and on the map.
|
||||
style={{ background: `${color}1f`, color }}
|
||||
>
|
||||
<Icon size={14} strokeWidth={1.9} aria-hidden />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ResultRow({ poi, onAdd }: { poi: CorridorPoi; onAdd?: () => void }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
return (
|
||||
<li className="group flex items-center gap-3 rounded-xl py-1.5 pe-1.5 ps-1 transition-colors hover:bg-surface-hover">
|
||||
<ResultBadge category={poi.category} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold tracking-[-0.012em] text-content" style={{ fontSize: FS.name }}>
|
||||
{poi.name}
|
||||
</div>
|
||||
{/* Two facts, two elements. Both are translated phrases rather than short fixed
|
||||
values, so they stay text and the gap does the separating a middot used to —
|
||||
which also lets them wrap onto their own lines instead of breaking around a
|
||||
dot left stranded at the end of a line. */}
|
||||
<div
|
||||
className="flex flex-wrap items-baseline gap-x-2 tabular-nums text-content-faint"
|
||||
style={{ fontSize: FS.meta }}
|
||||
>
|
||||
<span>{t('roadtrip.poi.offRoute', { distance: formatDistance(poi.offRouteKm, distanceUnit) })}</span>
|
||||
<span>
|
||||
{poi.alongKm < 0.5
|
||||
? t('roadtrip.poi.atStart')
|
||||
: t('roadtrip.poi.alongRoute', { distance: formatDistance(poi.alongKm, distanceUnit) })}
|
||||
</span>
|
||||
{/* What the charger offers, where OSM says. Socket names are proper nouns and
|
||||
stay as they are; the numbers around them are what decides whether a car can
|
||||
use it at all. A station that says nothing shows nothing rather than a row
|
||||
of dashes, because "not stated" is not "no". */}
|
||||
{poi.charging?.sockets.length ? (
|
||||
<span className="flex flex-wrap items-baseline gap-x-1.5">
|
||||
{poi.charging.sockets.slice(0, 3).map(s => (
|
||||
<span key={s.type} className="text-content-muted">
|
||||
{SOCKET_LABEL[s.type] ?? s.type}
|
||||
{s.kw ? ` ${s.kw} kW` : ''}
|
||||
{s.count && s.count > 1 ? ` ×${s.count}` : ''}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
{poi.charging?.fee === false ? (
|
||||
<span className="text-success">{t('roadtrip.poi.free')}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{/* Always there, quiet until the row is under the pointer: a button that only
|
||||
exists on hover is one a keyboard user has to find by faith. */}
|
||||
{onAdd ? (() => {
|
||||
// One button, two meanings. Somewhere to sleep ends the day rather than
|
||||
// interrupting the drive, so the icon and the label say that before the dialog
|
||||
// opens instead of after.
|
||||
const night = poi.category === 'hotel'
|
||||
const label = night ? t('roadtrip.stay.nightAction') : t('roadtrip.poi.add')
|
||||
const Icon = night ? BedDouble : Plus
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
aria-label={label}
|
||||
className="grid h-[26px] w-[26px] shrink-0 place-items-center rounded-lg text-content-faint transition-colors hover:bg-accent hover:text-accent-text focus-visible:bg-accent focus-visible:text-accent-text focus-visible:outline-none"
|
||||
>
|
||||
<Icon size={15} strokeWidth={2.2} aria-hidden />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})() : null}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** One category's hits, so a mixed search reads as several short lists instead of one long one. */
|
||||
function ResultGroup({ category, pois, dayId, insertIndexFor, onAddPoi }: {
|
||||
category: string
|
||||
pois: CorridorPoi[]
|
||||
dayId: number | null
|
||||
insertIndexFor: RoadtripCorridor['insertIndexFor']
|
||||
onAddPoi: RoadtripCorridorPanelProps['onAddPoi']
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const meta = CATEGORY_META[category]
|
||||
const Icon = meta?.Icon ?? MapPin
|
||||
const color = serviceColor(category)
|
||||
return (
|
||||
<section className="pb-1">
|
||||
<header className="flex items-center gap-2.5 px-2 pb-1.5 pt-3">
|
||||
<span
|
||||
className="grid h-[22px] w-[22px] shrink-0 place-items-center rounded-[7px]"
|
||||
// theme-lint-disable — see ResultBadge.
|
||||
style={{ background: color, color: '#fff' }} // theme-lint-disable — road-signage palette
|
||||
>
|
||||
<Icon size={12} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
<span className="font-semibold tracking-[-0.01em] text-content" style={{ fontSize: FS.name }}>
|
||||
{meta ? t(meta.labelKey) : category}
|
||||
</span>
|
||||
<span className={`ms-auto ${EYEBROW} tabular-nums`} style={{ fontSize: FS.micro }}>
|
||||
{t('roadtrip.poi.found', { count: pois.length })}
|
||||
</span>
|
||||
</header>
|
||||
<ul>
|
||||
{pois.map(poi => (
|
||||
<ResultRow
|
||||
key={poi.osm_id}
|
||||
poi={poi}
|
||||
// Added where it will be driven past, not at the end of the day.
|
||||
onAdd={onAddPoi ? () => onAddPoi(poi, dayId, insertIndexFor(poi)) : undefined}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* "What is on the way" — the right column while road trip mode is on.
|
||||
*
|
||||
* Searching is a button, not a side effect of panning: one run is several Overpass
|
||||
* requests and the mirrors are shared infrastructure. The scope is one day's drive, which
|
||||
* keeps a run to a handful of boxes and makes "add this" unambiguous — the stop lands on
|
||||
* the day whose route it was found along. Hits are drawn on the map at the same time, so
|
||||
* the list answers "which of these" and the map answers "which side of the road".
|
||||
*
|
||||
* Three cards down one column, the same shapes the rail opposite uses: what to look for,
|
||||
* how the search is getting on, and what it found. The day it searches sits in the
|
||||
* header rather than among the filters — it is the question's subject, not one of its
|
||||
* conditions.
|
||||
*/
|
||||
export default function RoadtripCorridorPanel({ corridor, routes, onAddPoi }: RoadtripCorridorPanelProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const { search } = corridor
|
||||
|
||||
const dayOptions = routes.days.map(d => ({
|
||||
value: String(d.dayId),
|
||||
label: t('roadtrip.day', { number: d.dayNumber }),
|
||||
}))
|
||||
|
||||
const grouped = CORRIDOR_CATEGORY_KEYS
|
||||
.map(key => ({ key, pois: corridor.visible.filter(p => p.category === key) }))
|
||||
.filter(g => g.pois.length > 0)
|
||||
|
||||
// Something was found, the filter just hides it — a different state from "not searched
|
||||
// yet" and from "the drive really has none of these".
|
||||
/**
|
||||
* The socket families this search actually turned up, in the order the table lists
|
||||
* them. Offering all ten would put CHAdeMO in the dropdown for a corridor that has
|
||||
* three Type 2 posts on it, which is a filter that can only produce an empty list.
|
||||
*/
|
||||
const socketsFound = useMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
for (const p of search.results) for (const s of p.charging?.sockets ?? []) seen.add(s.type)
|
||||
// Compared explicitly: the default sort is by UTF-16 code unit, which is not the
|
||||
// order anybody reads a list of plug names in.
|
||||
return [...seen].sort((a, b) => a.localeCompare(b))
|
||||
}, [search.results])
|
||||
const hasCharging = socketsFound.length > 0
|
||||
|
||||
const filteredToNothing = search.results.length > 0 && corridor.visible.length === 0
|
||||
|
||||
// The day has to have routed. Until it does, the corridor is built from the
|
||||
// straight line between the stops, so the boxes march across whatever lies
|
||||
// between them rather than along the roads actually driven — and when the
|
||||
// routing answer lands a second later the line changes, which clears the
|
||||
// search mid-flight and drops every result with no error and no explanation.
|
||||
// The routing runs one day at a time, about a second apart, so on a long trip
|
||||
// that window is wide open for the day somebody picks.
|
||||
const dayRouted = (corridor.day?.geometry.length ?? 0) > 1
|
||||
const canSearch = !search.loading
|
||||
&& !routes.loading
|
||||
&& dayRouted
|
||||
&& corridor.categories.length > 0
|
||||
&& (corridor.day?.stops.length ?? 0) > 1
|
||||
const progressPct = search.progress.total
|
||||
? Math.round((search.progress.done / search.progress.total) * 100)
|
||||
: 0
|
||||
|
||||
const warnings: [string, string][] = []
|
||||
if (search.capped) warnings.push(['capped', t('roadtrip.poi.capped')])
|
||||
if (search.error) warnings.push(['failed', t('roadtrip.poi.failed')])
|
||||
// Some boxes answered and some did not. Saying so is the difference between "there is
|
||||
// no fuel on this stretch" and "nobody looked at this stretch".
|
||||
else if (search.failedAreas > 0) warnings.push(['partial', t('roadtrip.poi.partial', { count: search.failedAreas })])
|
||||
// The stretch was searched and the answer came back short. Without this line a filter
|
||||
// finding nothing looks like proof there is nothing.
|
||||
if (search.truncatedAreas > 0) warnings.push(['truncated', t('roadtrip.poi.truncated', { count: search.truncatedAreas })])
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 px-3.5 pb-3.5 pt-3">
|
||||
{/* Header — the panel's name, and the day the question is about. */}
|
||||
<div className="flex flex-shrink-0 items-center gap-2.5 px-1">
|
||||
<h2 className="font-semibold tracking-[-0.022em] text-content" style={{ fontSize: FS.panelTitle }}>
|
||||
{t('roadtrip.poi.title')}
|
||||
</h2>
|
||||
{dayOptions.length > 1 ? (
|
||||
<div className="ms-auto min-w-0">
|
||||
<CustomSelect
|
||||
value={corridor.dayId}
|
||||
onChange={value => corridor.setDayId(String(value))}
|
||||
options={dayOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* What to look for. */}
|
||||
<div className={`flex flex-shrink-0 flex-col gap-3.5 ${CARD} px-4 pb-3.5 pt-3.5`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={EYEBROW} style={{ fontSize: FS.label }}>{t('roadtrip.poi.looking')}</span>
|
||||
<RoadtripCategoryPicker
|
||||
keys={CORRIDOR_CATEGORY_KEYS}
|
||||
meta={CATEGORY_META}
|
||||
selected={corridor.categories}
|
||||
onToggle={corridor.toggleCategory}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className="h-px bg-edge-faint" aria-hidden />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className={EYEBROW} style={{ fontSize: FS.label }}>{t('roadtrip.poi.within')}</span>
|
||||
<div className="flex gap-1 rounded-xl bg-surface-tertiary p-1">
|
||||
{CORRIDOR_WIDTHS_KM.map(km => (
|
||||
<button
|
||||
key={km}
|
||||
type="button"
|
||||
aria-pressed={corridor.widthKm === km}
|
||||
onClick={() => corridor.setWidthKm(km)}
|
||||
style={{ fontSize: FS.control }}
|
||||
className={`flex h-[26px] flex-1 items-center justify-center rounded-lg tabular-nums transition-colors ${
|
||||
corridor.widthKm === km
|
||||
? 'bg-surface-card font-semibold text-content shadow-card'
|
||||
: 'font-medium text-content-muted hover:bg-surface-hover hover:text-content'
|
||||
}`}
|
||||
>
|
||||
{formatDistance(km, distanceUnit)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* One word, so it can be read at the size a primary action deserves. The panel
|
||||
it sits in is headed "along the route" and every control above it narrows the
|
||||
same search; repeating that on the button only made it small. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={search.search}
|
||||
disabled={!canSearch}
|
||||
className="flex h-[38px] w-full items-center justify-center gap-2 rounded-xl bg-accent text-body font-semibold text-accent-text transition-opacity disabled:opacity-50"
|
||||
>
|
||||
{search.loading
|
||||
? <RotateCw size={15} className="animate-spin" aria-hidden />
|
||||
: <Search size={15} strokeWidth={2} aria-hidden />}
|
||||
{search.loading
|
||||
? t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })
|
||||
: t('roadtrip.poi.search')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* How the run is getting on — a row of its own while it lasts, with the share
|
||||
done as a figure rather than only as a bar: a corridor search is many requests
|
||||
and "58%" answers "is this worth waiting for" that a bar only hints at. */}
|
||||
{search.loading ? (
|
||||
<div
|
||||
className="flex h-[32px] flex-shrink-0 items-center gap-2 rounded-xl border border-edge bg-surface-card pe-1.5 ps-3 text-content-muted"
|
||||
role="progressbar"
|
||||
aria-valuenow={progressPct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<RotateCw size={13} className="animate-spin" aria-hidden />
|
||||
<span className="min-w-0 truncate font-medium" style={{ fontSize: FS.control }}>
|
||||
{t('roadtrip.poi.searching', { done: search.progress.done, total: search.progress.total })}
|
||||
</span>
|
||||
<span
|
||||
className="ms-auto inline-flex h-[22px] shrink-0 items-center rounded-full bg-inverse px-2 font-geist font-semibold tabular-nums tracking-[0.08em] text-inverse-text"
|
||||
style={{ fontSize: FS.micro }}
|
||||
>
|
||||
{`${progressPct}%`}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{warnings.length > 0 ? (
|
||||
<div className="flex flex-shrink-0 flex-col gap-1.5">
|
||||
{warnings.map(([key, text]) => (
|
||||
<p
|
||||
key={key}
|
||||
className={`flex items-start gap-1.5 ${key === 'failed' ? 'text-danger' : 'text-warning'}`}
|
||||
style={{ fontSize: FS.meta }}
|
||||
>
|
||||
<AlertTriangle size={12} className="mt-px shrink-0" aria-hidden />
|
||||
{text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* What was found. */}
|
||||
<div className={`flex min-h-0 flex-1 flex-col overflow-hidden ${CARD}`}>
|
||||
{/* Narrowing what was found. Deliberately not called "search": the button above
|
||||
asks the place search for new results, this only hides rows already in. It
|
||||
appears once there is something to narrow. */}
|
||||
{search.results.length > 0 ? (
|
||||
<div className="flex-shrink-0 border-b border-edge-faint p-3">
|
||||
<div className="relative">
|
||||
<Search size={14} strokeWidth={1.9} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-content-faint" aria-hidden />
|
||||
<input
|
||||
type="text"
|
||||
value={corridor.nameFilter}
|
||||
onChange={e => corridor.setNameFilter(e.target.value)}
|
||||
placeholder={t('roadtrip.poi.filter')}
|
||||
aria-label={t('roadtrip.poi.filter')}
|
||||
style={{ fontSize: FS.time }}
|
||||
className="h-[32px] w-full rounded-[10px] bg-surface-tertiary pe-8 ps-9 text-content placeholder:text-content-faint focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
{corridor.nameFilter ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => corridor.setNameFilter('')}
|
||||
aria-label={t('common.clear')}
|
||||
className="absolute end-1.5 top-1/2 -translate-y-1/2 rounded-full p-1 text-content-faint transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
<X size={13} aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Around one point of the drive, which is how a break is actually planned: you
|
||||
decide roughly where to stop and then look at what is there, rather than
|
||||
reading seventy hits spread over seven hundred kilometres. The midpoint of
|
||||
a leg is offered alongside the stops, because a break on a five-hour drive
|
||||
belongs in the middle of it and not at either end.
|
||||
|
||||
The minWidth: 0 is what makes the name shorten instead of pushing the
|
||||
kilometre control off the panel: a nowrap label reports its whole width as
|
||||
its min-content contribution, so a flex item at its default min-width of
|
||||
auto refuses to go below it however long the place name is. */}
|
||||
{corridor.anchors.length > 1 && corridor.day ? (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<CustomSelect
|
||||
value={corridor.section ? `${corridor.section.kind}:${corridor.section.index}` : ''}
|
||||
onChange={value => {
|
||||
const raw = String(value)
|
||||
if (!raw) { corridor.setSection(null); return }
|
||||
const [kind, index] = raw.split(':')
|
||||
corridor.setSection({
|
||||
dayId: corridor.day!.dayId,
|
||||
kind: kind as 'stop' | 'leg',
|
||||
index: Number(index),
|
||||
})
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: t('roadtrip.poi.wholeDay') },
|
||||
...corridor.anchors.map(a => ({
|
||||
value: `${a.kind}:${a.index}`,
|
||||
label: a.kind === 'stop'
|
||||
? (corridor.day!.stops[a.index]?.name ?? t('roadtrip.poi.wholeDay'))
|
||||
: t('roadtrip.poi.midLeg', {
|
||||
from: corridor.day!.stops[a.index]?.name ?? '',
|
||||
to: corridor.day!.stops[a.index + 1]?.name ?? '',
|
||||
}),
|
||||
})),
|
||||
]}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
size="sm"
|
||||
menuFit="content"
|
||||
/>
|
||||
{corridor.section ? (
|
||||
<CustomSelect
|
||||
value={String(corridor.sectionKm)}
|
||||
onChange={value => corridor.setSectionKm(Number(value))}
|
||||
options={CORRIDOR_SECTION_KM.map(km => ({
|
||||
value: String(km),
|
||||
label: `± ${formatDistance(km, distanceUnit)}`,
|
||||
}))}
|
||||
style={{ width: 110, flexShrink: 0 }}
|
||||
size="sm"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Only where there is a charger to narrow. Two answers OSM actually carries
|
||||
often enough to filter on: which plug, and how fast. A station that states
|
||||
neither stays in the list, because roughly two thirds of them state no
|
||||
power at all and reading that silence as "too slow" would empty the map. */}
|
||||
{hasCharging ? (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<CustomSelect
|
||||
value={corridor.socketFilter}
|
||||
onChange={value => corridor.setSocketFilter(String(value))}
|
||||
options={[
|
||||
{ value: '', label: t('roadtrip.poi.anySocket') },
|
||||
...socketsFound.map(s => ({ value: s, label: SOCKET_LABEL[s] ?? s })),
|
||||
]}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
size="sm"
|
||||
/>
|
||||
<CustomSelect
|
||||
value={String(corridor.minKw)}
|
||||
onChange={value => corridor.setMinKw(Number(value))}
|
||||
options={[
|
||||
{ value: '0', label: t('roadtrip.poi.anyPower') },
|
||||
...KW_STEPS.map(kw => ({ value: String(kw), label: `${kw}+ kW` })),
|
||||
]}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-2.5 pb-3">
|
||||
{grouped.length === 0 ? (
|
||||
// The same empty state the rest of TREK uses, with the mascot acting out the
|
||||
// scene. `search` while it is running, `idle` before anyone has asked, and a
|
||||
// confused look when a filter left nothing standing — the picture says which
|
||||
// of the three it is before the sentence is read.
|
||||
<EmptyState
|
||||
scene={search.loading ? 'search' : 'idle'}
|
||||
mood={filteredToNothing ? 'confused' : undefined}
|
||||
size={88}
|
||||
compact
|
||||
surface="var(--bg-secondary)"
|
||||
title={
|
||||
search.loading
|
||||
? t('roadtrip.poi.searchingHint')
|
||||
: filteredToNothing
|
||||
// Four controls narrow this list and only one of them is the
|
||||
// name box. Blaming it regardless produced `Nothing on the
|
||||
// way matches ""` — empty quotes naming a filter the reader
|
||||
// never set — when a section, a plug type or a minimum power
|
||||
// was what emptied it.
|
||||
? corridor.nameFilter.trim()
|
||||
? t('roadtrip.poi.noMatch', { name: corridor.nameFilter.trim() })
|
||||
: t('roadtrip.poi.noneMatchFilters')
|
||||
: t('roadtrip.poi.empty')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Shown whenever anything narrowed the list, not only the name
|
||||
box: a section or a plug filter hides hits just as much, and
|
||||
the reader is owed the same "x of y" either way. */}
|
||||
{corridor.visible.length !== search.results.length ? (
|
||||
<p className="px-2 pt-2.5 text-content-faint" style={{ fontSize: FS.meta }}>
|
||||
{t('roadtrip.poi.foundFiltered', { count: corridor.visible.length, total: search.results.length })}
|
||||
</p>
|
||||
) : null}
|
||||
{grouped.map(g => (
|
||||
<ResultGroup
|
||||
key={g.key}
|
||||
category={g.key}
|
||||
pois={g.pois}
|
||||
dayId={corridor.day?.dayId ?? null}
|
||||
insertIndexFor={corridor.insertIndexFor}
|
||||
onAddPoi={onAddPoi}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import RoadtripLimitsCard from './RoadtripLimitsCard'
|
||||
|
||||
/**
|
||||
* FE-ROADTRIP-LIMITS-001..008 — the three numbers that decide every warning.
|
||||
*
|
||||
* The one that matters is when a number is written. These fields had no local
|
||||
* state, so every keystroke was a settings PUT: typing "180" sent three, and
|
||||
* they raced. The value that decides whether a day is over budget could end up a
|
||||
* tenth of what was typed and only say so after the next reload.
|
||||
*/
|
||||
|
||||
function open(onSave?: (key: string, value: number) => void) {
|
||||
render(<RoadtripLimitsCard onSave={onSave} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
}
|
||||
|
||||
/** The three inputs, in the order the dialog lists them. */
|
||||
const inputs = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
roadtrip_leg_minutes: 0,
|
||||
roadtrip_day_minutes: 0,
|
||||
roadtrip_range_km: 0,
|
||||
distance_unit: 'metric',
|
||||
} as never,
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripLimitsCard', () => {
|
||||
it('FE-ROADTRIP-LIMITS-001: typing a number writes nothing until the field is left', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '1' } })
|
||||
fireEvent.change(leg, { target: { value: '18' } })
|
||||
fireEvent.change(leg, { target: { value: '180' } })
|
||||
|
||||
// Three keystrokes used to be three unordered PUTs carrying 1, 18 and 180.
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(leg.value).toBe('180')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-002: leaving the field saves once, with what was typed', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '180' } })
|
||||
fireEvent.blur(leg)
|
||||
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_leg_minutes', 180)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-003: Enter commits without having to click away', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const [, day] = inputs()
|
||||
fireEvent.change(day, { target: { value: '540' } })
|
||||
fireEvent.keyDown(day, { key: 'Enter' })
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_day_minutes', 540)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-004: leaving a field nobody touched saves nothing', () => {
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
fireEvent.blur(inputs()[0])
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-005: retyping the value it already had is not a write', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_leg_minutes: 180, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'metric' } as never,
|
||||
})
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '180' } })
|
||||
fireEvent.blur(leg)
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-006: clearing a field turns the limit off', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_leg_minutes: 180, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'metric' } as never,
|
||||
})
|
||||
const onSave = vi.fn()
|
||||
open(onSave)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '' } })
|
||||
fireEvent.blur(leg)
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith('roadtrip_leg_minutes', 0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-007: the range field carries the reader own unit', () => {
|
||||
useSettingsStore.setState({
|
||||
settings: { roadtrip_leg_minutes: 0, roadtrip_day_minutes: 0, roadtrip_range_km: 0, distance_unit: 'imperial' } as never,
|
||||
})
|
||||
open(vi.fn())
|
||||
|
||||
expect(screen.getByText('mi')).toBeInTheDocument()
|
||||
expect(screen.queryByText('km')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-LIMITS-008: without a way to save, the dialog is read-only', () => {
|
||||
// `onSave` absent is how the caller says the reader may look but not change.
|
||||
open(undefined)
|
||||
|
||||
const [leg] = inputs()
|
||||
fireEvent.change(leg, { target: { value: '180' } })
|
||||
// Nothing to assert but the absence of a crash: the commit path has no
|
||||
// handler to call, and must not assume one.
|
||||
expect(() => fireEvent.blur(leg)).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Clock, Fuel, CalendarClock, SlidersHorizontal, ChevronRight } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { convertDistance, formatDistance } from '../../utils/units'
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import { FS } from './typeScale'
|
||||
|
||||
/**
|
||||
* The three numbers that decide when the rail speaks up about the driving.
|
||||
*
|
||||
* Behind a button rather than laid out in the column, because this is set once and then
|
||||
* read never: a permanent card costs three rows of a narrow sidebar for something most
|
||||
* travellers touch on the first day of planning and leave alone after. The button carries
|
||||
* the current answer, so what is set stays visible without the form being.
|
||||
*
|
||||
* All three are personal rather than instance configuration: how long somebody is willing
|
||||
* to sit behind a wheel, and how far their car goes on a tank, belong to the traveller.
|
||||
* They are plain per-user settings, which is why this needs no migration.
|
||||
*
|
||||
* Nothing here suggests a stop, adds one, or knows anything about a vehicle. OSM has no
|
||||
* idea what car this is, and neither has TREK, so the range is a number the traveller
|
||||
* types. Furkot does exactly the same, for the same reason.
|
||||
*/
|
||||
|
||||
/** Empty means no limit, and so does zero — both are stored as 0 and read as off. */
|
||||
function parseLimit(raw: string): number {
|
||||
const n = Number(raw.replace(',', '.'))
|
||||
return Number.isFinite(n) && n > 0 ? n : 0
|
||||
}
|
||||
|
||||
function LimitRow({ icon: Icon, label, suffix, value, placeholder, onChange }: {
|
||||
icon: typeof Clock
|
||||
label: string
|
||||
suffix: string
|
||||
value: number | undefined
|
||||
placeholder: string
|
||||
onChange: (next: number) => void
|
||||
}): React.ReactElement {
|
||||
// Typed here, saved on blur or Enter. Writing on every keystroke sent one
|
||||
// settings PUT per character — typing "180" produced three, carrying 1, 18 and
|
||||
// 180, unordered and concurrent over HTTP/2. Whichever the server committed
|
||||
// last won, so the limit that decides every over-budget warning could quietly
|
||||
// end up a tenth of what was typed, and only show it after the next reload.
|
||||
const [draft, setDraft] = useState<string | null>(null)
|
||||
const shown = draft ?? (value ? String(value) : '')
|
||||
const commit = () => {
|
||||
if (draft === null) return
|
||||
const next = parseLimit(draft)
|
||||
setDraft(null)
|
||||
if (next !== (value ?? 0)) onChange(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-3">
|
||||
<Icon size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="min-w-0 flex-1 text-body text-content-secondary">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
value={shown}
|
||||
placeholder={placeholder}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
// Committed here rather than by blurring and letting onBlur do it: a
|
||||
// second commit is a no-op anyway, and going through blur made Enter
|
||||
// depend on focus handling instead of on the key.
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(); (e.target as HTMLInputElement).blur() } }}
|
||||
className="w-20 rounded-lg border border-edge bg-surface px-2 py-1 text-end text-body tabular-nums text-content focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<span className="w-7 text-caption text-content-faint">{suffix}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoadtripLimitsCard({ onSave }: {
|
||||
/** Persists one setting. Absent leaves the dialog read-only. */
|
||||
onSave?: (key: string, value: number) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettingsStore(s => s.settings)
|
||||
const [open, setOpen] = useState(false)
|
||||
const distanceUnit: DistanceUnit = settings.distance_unit === 'imperial' ? 'imperial' : 'metric'
|
||||
|
||||
const legMinutes = settings.roadtrip_leg_minutes
|
||||
const dayMinutes = settings.roadtrip_day_minutes
|
||||
const rangeKm = settings.roadtrip_range_km
|
||||
|
||||
// Kilometres in storage, the traveller's own unit on screen. Without the round trip an
|
||||
// imperial user types 400 meaning miles, 400 km gets stored, and the warnings arrive a
|
||||
// third too early for ever after.
|
||||
const rangeShown = rangeKm ? Math.round(convertDistance(rangeKm, distanceUnit)) : undefined
|
||||
const setRange = (shown: number) => {
|
||||
const km = distanceUnit === 'imperial' ? shown / 0.621371 : shown
|
||||
onSave?.('roadtrip_range_km', Math.round(km))
|
||||
}
|
||||
|
||||
// What is set, on the button itself, so the form does not have to be open to read it.
|
||||
// Each one keeps its own icon, because three numbers in a row say nothing about which
|
||||
// is which: a clock, a calendar and a pump do. Distances go through formatDistance, so
|
||||
// an imperial traveller reads miles here and types miles in the dialog, while what is
|
||||
// stored stays kilometres either way.
|
||||
const badges = [
|
||||
legMinutes ? { key: 'leg', Icon: Clock, text: formatDurationShort(legMinutes * 60) } : null,
|
||||
dayMinutes ? { key: 'day', Icon: CalendarClock, text: formatDurationShort(dayMinutes * 60) } : null,
|
||||
rangeKm ? { key: 'range', Icon: Fuel, text: formatDistance(rangeKm, distanceUnit) } : null,
|
||||
].filter(Boolean) as { key: string; Icon: typeof Clock; text: string }[]
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Full width and card-sized, the same weight as the search controls above it: this
|
||||
is one of three things this column does, not a footnote under the other two.
|
||||
The current answer rides on the button, so what is set stays visible without the
|
||||
form being open. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex w-full items-center gap-3 rounded-xl border border-edge-faint bg-surface-card px-3.5 py-3 text-start transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{/* No icon tile on the trigger: the three badges below already carry a clock, a
|
||||
calendar and a pump, and a fourth mark in front of the title only ate the width
|
||||
they need to sit on one line. */}
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="truncate text-body font-semibold text-content">
|
||||
{t('roadtrip.limit.title')}
|
||||
</span>
|
||||
{badges.length ? (
|
||||
<span className="flex flex-wrap items-center gap-1">
|
||||
{badges.map(({ key, Icon, text }) => (
|
||||
<span
|
||||
key={key}
|
||||
className="inline-flex h-[16px] items-stretch overflow-hidden rounded border border-edge"
|
||||
>
|
||||
<span className="flex items-center bg-surface-tertiary px-1 text-content-faint">
|
||||
<Icon size={9} aria-hidden />
|
||||
</span>
|
||||
<span
|
||||
className="flex items-center border-s border-edge bg-surface-card px-1.5 font-semibold tabular-nums text-content-secondary"
|
||||
style={{ fontSize: FS.label }}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span className="truncate text-content-faint" style={{ fontSize: FS.label }}>
|
||||
{t('roadtrip.limit.none')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronRight size={16} className="shrink-0 text-content-faint" aria-hidden />
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<Modal isOpen onClose={() => setOpen(false)} size="sm" title={
|
||||
<span className="flex items-center gap-2">
|
||||
<SlidersHorizontal size={15} className="text-content-faint" aria-hidden />
|
||||
{t('roadtrip.limit.title')}
|
||||
</span>
|
||||
}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<LimitRow
|
||||
icon={Clock}
|
||||
label={t('roadtrip.limit.legLabel')}
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
value={legMinutes}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
onChange={v => onSave?.('roadtrip_leg_minutes', v)}
|
||||
/>
|
||||
<LimitRow
|
||||
icon={CalendarClock}
|
||||
label={t('roadtrip.limit.dayLabel')}
|
||||
suffix={t('roadtrip.limit.minutes')}
|
||||
value={dayMinutes}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
onChange={v => onSave?.('roadtrip_day_minutes', v)}
|
||||
/>
|
||||
<LimitRow
|
||||
icon={Fuel}
|
||||
label={t('roadtrip.limit.rangeLabel')}
|
||||
suffix={distanceUnit === 'imperial' ? 'mi' : 'km'}
|
||||
value={rangeShown}
|
||||
placeholder={t('roadtrip.limit.off')}
|
||||
onChange={setRange}
|
||||
/>
|
||||
{/* Said out loud rather than left to be discovered: the day figure this is
|
||||
measured against is driving time only. It does not include how long anyone
|
||||
stands still, and it does not include the leg to the hotel, because the
|
||||
rail does not model one. A limit called "travel time per day" over that
|
||||
number would be a lie that looks tidy. */}
|
||||
<p className="text-caption text-content-faint">{t('roadtrip.limit.hint')}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { CalendarDays, Route } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
|
||||
interface RoadtripModeSwitchProps {
|
||||
active: boolean
|
||||
onChange: (roadtrip: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the plan view's left rail between the day plan and the road trip reading of
|
||||
* the same trip. Only rendered while the road trip addon is on, so a trip that isn't
|
||||
* driven anywhere never grows a control it has no use for.
|
||||
*/
|
||||
export default function RoadtripModeSwitch({ active, onChange }: RoadtripModeSwitchProps): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const options: [boolean, string, typeof Route][] = [
|
||||
[false, t('roadtrip.mode.days'), CalendarDays],
|
||||
[true, t('roadtrip.mode.roadtrip'), Route],
|
||||
]
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t('roadtrip.mode.label')}
|
||||
className="mx-3.5 mb-0 mt-3 flex gap-1 rounded-xl border border-edge-faint bg-surface-tertiary p-1"
|
||||
>
|
||||
{options.map(([value, label, Icon]) => {
|
||||
const selected = active === value
|
||||
return (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => onChange(value)}
|
||||
// Sized off the day plan's own type, like the road trip rail below it: the
|
||||
// switcher sits above both, so it cannot be the largest thing in the column.
|
||||
style={{ fontSize: 'calc(11.5px * var(--fs-scale-body, 1))' }}
|
||||
className={`flex h-[30px] flex-1 items-center justify-center gap-1.5 rounded-lg px-2 transition-colors ${
|
||||
selected
|
||||
? 'bg-surface-card font-semibold text-content shadow-card'
|
||||
: 'font-medium text-content-muted hover:bg-surface-hover hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Icon size={13} strokeWidth={1.8} aria-hidden />
|
||||
<span className="truncate">{label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, within, fireEvent } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripSidebar from './RoadtripSidebar'
|
||||
import RoadtripModeSwitch from './RoadtripModeSwitch'
|
||||
import type { RoadtripDay, RoadtripRoutes, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { RouteSegment } from '../../types'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
function stop(over: Partial<RoadtripStop> & { assignmentId: number; name: string }): RoadtripStop {
|
||||
return {
|
||||
placeId: over.assignmentId * 10,
|
||||
lat: 53.5,
|
||||
lng: 9.9,
|
||||
time: null,
|
||||
dwellMinutes: null,
|
||||
legMode: null,
|
||||
incomingLegMode: null,
|
||||
stopType: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const leg = (over: Partial<RouteSegment> = {}): RouteSegment =>
|
||||
({ distance: 100000, duration: 3600, distanceText: '100 km', durationText: '1 h', mode: 'driving', ...over }) as RouteSegment
|
||||
|
||||
function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
const stops = over.stops ?? [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Berlin' }),
|
||||
]
|
||||
return {
|
||||
dayId: 1,
|
||||
dayNumber: 1,
|
||||
date: null,
|
||||
title: null,
|
||||
stops,
|
||||
legs: [leg()],
|
||||
legVias: [], driveWarnings: [], dayWarning: null,
|
||||
schedule: { entries: stops.map(() => ({ arrival: null, departure: null, anchored: false, dayOffset: 0 })), warnings: [] },
|
||||
geometry: [],
|
||||
distance: 100000,
|
||||
duration: 3600,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function routes(over: Partial<RoadtripRoutes> = {}): RoadtripRoutes {
|
||||
return {
|
||||
days: [day()],
|
||||
lines: [],
|
||||
segments: [],
|
||||
accessLines: [],
|
||||
vias: [],
|
||||
totalDistance: 100000,
|
||||
totalDuration: 3600,
|
||||
totalStops: 2,
|
||||
quietDays: [],
|
||||
loading: false,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('RoadtripSidebar', () => {
|
||||
/**
|
||||
* The value the summary head pairs with this label. The same words also name the chips
|
||||
* further down for screen readers, so the lookup has to hold on to the `dt` — otherwise
|
||||
* it matches "Distance" three times over.
|
||||
*/
|
||||
const total = (label: string): string => {
|
||||
// The summary is three columns; the caption and its number are siblings in one cell.
|
||||
// The same words also name the badges further down, so the lookup keeps to the cell
|
||||
// whose first child is the caption itself.
|
||||
const caption = screen.getAllByText(label).find(el => el.parentElement?.firstElementChild === el)!
|
||||
return caption.parentElement!.lastElementChild!.textContent!.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-001: leads with the totals for the whole drive', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ totalDistance: 250000, totalDuration: 9000, totalStops: 5 })} />)
|
||||
|
||||
expect(total('Distance')).toBe('250 km')
|
||||
expect(total('Driving time')).toBe('2 h 30 min')
|
||||
expect(total('Stops')).toBe('5')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-002: chains the stops with the drive between them', () => {
|
||||
// Trip and day totals deliberately differ from the single leg, so the leg's own two
|
||||
// values are the only place '100 km' and '1 h' can come from.
|
||||
wrap(<RoadtripSidebar routes={routes({
|
||||
days: [day({ distance: 250000, duration: 9000 })],
|
||||
totalDistance: 250000,
|
||||
totalDuration: 9000,
|
||||
})} />)
|
||||
|
||||
expect(screen.getByText('Hamburg')).toBeInTheDocument()
|
||||
expect(screen.getByText('Berlin')).toBeInTheDocument()
|
||||
// One sentence, not two glued values: the middot is gone and so is the pair of chips.
|
||||
expect(screen.getByText('100 km in 1 h')).toBeInTheDocument()
|
||||
expect(screen.queryByText('100 km · 1 h')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-003: says a leg is still coming rather than showing a blank', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({
|
||||
days: [day({ legs: [undefined], distance: 0, duration: 0 })],
|
||||
totalDistance: 0,
|
||||
totalDuration: 0,
|
||||
})} />)
|
||||
|
||||
// An absent value says so rather than reading zero.
|
||||
expect(screen.queryByText(/100 km in/)).not.toBeInTheDocument()
|
||||
expect(screen.getByText('No route')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-004: sets a pinned arrival apart from a computed one', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Ferry' }), stop({ assignmentId: 2, name: 'Berlin' })]
|
||||
const schedule = {
|
||||
entries: [
|
||||
{ arrival: '09:00', departure: '09:30', anchored: true, dayOffset: 0 },
|
||||
{ arrival: '10:30', departure: '10:30', anchored: false, dayOffset: 0 },
|
||||
],
|
||||
warnings: [],
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
// Both are plain text at the row's edge — no pill, no icon. What separates the time
|
||||
// somebody chose from the one the drive worked out is weight and ink. The reason is
|
||||
// named in a tooltip, which only enters the DOM on hover, so the visible difference
|
||||
// is what this pins.
|
||||
const pinned = screen.getByText('09:00')
|
||||
const computed = screen.getByText('10:30')
|
||||
expect(pinned.className).toContain('font-semibold')
|
||||
expect(computed.className).not.toContain('font-semibold')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-005: flags a stop the drive cannot reach in time', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Ferry' })]
|
||||
const schedule = {
|
||||
entries: stops.map(() => ({ arrival: '09:00', departure: '09:00', anchored: true, dayOffset: 0 })),
|
||||
warnings: [{ index: 1, code: 'late' as const, minutes: 45 }],
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-006: shows how long a stop is planned to take', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Museum', dwellMinutes: 90 }), stop({ assignmentId: 2, name: 'Berlin' })]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
|
||||
|
||||
expect(screen.getByText('1 h 30 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-007: selecting a stop reports the place and the assignment', () => {
|
||||
const onSelectStop = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes()} onSelectStop={onSelectStop} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Berlin'))
|
||||
expect(onSelectStop).toHaveBeenCalledWith(20, 2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-008: marks the selected stop for assistive tech', () => {
|
||||
wrap(<RoadtripSidebar routes={routes()} selectedAssignmentId={2} />)
|
||||
|
||||
const selected = screen.getByText('Berlin').closest('button')!
|
||||
expect(selected).toHaveAttribute('aria-current', 'true')
|
||||
expect(screen.getByText('Hamburg').closest('button')).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-009: a trip with nothing to drive explains itself', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [], totalDistance: 0, totalDuration: 0, totalStops: 0 })} />)
|
||||
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Hamburg')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-010: each day carries its own distance and time', () => {
|
||||
const second = day({ dayId: 2, dayNumber: 2, distance: 50000, duration: 1800, stops: [
|
||||
stop({ assignmentId: 3, name: 'Dresden' }),
|
||||
stop({ assignmentId: 4, name: 'Prague' }),
|
||||
] })
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day(), second], totalStops: 4 })} />)
|
||||
|
||||
const headings = screen.getAllByRole('heading', { level: 3 })
|
||||
expect(headings).toHaveLength(2)
|
||||
expect(within(headings[1].parentElement!).getByText(/50 km/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-011: draws the midnight crossing where it happens', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' })]
|
||||
const schedule = {
|
||||
entries: [
|
||||
{ arrival: '22:00', departure: '22:00', anchored: false, dayOffset: 0 },
|
||||
{ arrival: '01:30', departure: '01:30', anchored: false, dayOffset: 1 },
|
||||
],
|
||||
warnings: [{ index: 1, code: 'overnight' as const }],
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
// Said twice on purpose: once as the break in the chain, once on the arrival after
|
||||
// it, where "01:30" would otherwise read as tonight.
|
||||
expect(screen.getAllByText('Next day')).toHaveLength(2)
|
||||
// The day marker rides on the arrival that crossed midnight, where "01:30" would
|
||||
// otherwise read as tonight.
|
||||
expect(screen.getByText('01:30').textContent).toContain('+1')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-012: a late arrival past midnight keeps both findings', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Ferry' })]
|
||||
const schedule = {
|
||||
entries: [
|
||||
{ arrival: '22:00', departure: '22:00', anchored: false, dayOffset: 0 },
|
||||
{ arrival: '01:30', departure: '01:30', anchored: true, dayOffset: 1 },
|
||||
],
|
||||
// Both belong to the same stop. Reading only the first one dropped the late flag.
|
||||
warnings: [
|
||||
{ index: 1, code: 'overnight' as const },
|
||||
{ index: 1, code: 'late' as const, minutes: 45 },
|
||||
],
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
expect(screen.getAllByText('Next day')).toHaveLength(2)
|
||||
expect(screen.getByLabelText(/45/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-013: says the totals are still a partial sum while legs arrive', () => {
|
||||
const { unmount } = wrap(<RoadtripSidebar routes={routes({ loading: true })} />)
|
||||
expect(screen.getByText('Still working out the rest of the drive')).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
wrap(<RoadtripSidebar routes={routes({ loading: false })} />)
|
||||
expect(screen.queryByText('Still working out the rest of the drive')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-014: shows the name a day was given', () => {
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ title: 'Along the coast' })] })} />)
|
||||
|
||||
expect(screen.getByText('Along the coast')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-015: dates the day machine-readably, whatever the locale prints', () => {
|
||||
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ date: '2026-08-31' })] })} />)
|
||||
|
||||
const stamp = container.querySelector('time')!
|
||||
expect(stamp).toHaveAttribute('datetime', '2026-08-31')
|
||||
expect(stamp.textContent).not.toBe('')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-016: a leg a plugin routed carries its own mark and its note', () => {
|
||||
const charged = leg({ mode: 'plugin:charge', noteText: '25 min charge' })
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [charged] })] })} />)
|
||||
|
||||
// Free text, so it gets a line rather than being squeezed into a pill.
|
||||
expect(screen.getByText('25 min charge')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-019: a fuel stop breaks the drive instead of taking a number', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Aral Autohof', stopType: 'fuel', dwellMinutes: 15 }),
|
||||
stop({ assignmentId: 3, name: 'Berlin' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} />)
|
||||
|
||||
// The kind is what you look for on a drive; the running number is on the map pin.
|
||||
expect(screen.getByLabelText('Fuel')).toBeInTheDocument()
|
||||
expect(screen.getByText('Aral Autohof')).toBeInTheDocument()
|
||||
// How long the pause takes is what the row is for.
|
||||
expect(screen.getByText('15 min')).toBeInTheDocument()
|
||||
// The two places the trip is actually for keep counting one, two — the tank stop
|
||||
// between them is part of the drive, not a third destination. Scoped to the chain:
|
||||
// the summary above counts stops too, in the same digits.
|
||||
const chain = screen.getByRole('list')
|
||||
expect(within(chain).getByText('1')).toBeInTheDocument()
|
||||
expect(within(chain).getByText('2')).toBeInTheDocument()
|
||||
expect(within(chain).queryByText('3')).not.toBeInTheDocument()
|
||||
// Two drive bands, because the stop splits the leg it falls on. Scoped to the chain
|
||||
// again: the day's header badge phrases its own total the same way.
|
||||
expect(within(chain).getAllByText('100 km in 1 h')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-032: every stop offers a stay, whether it has one or not', () => {
|
||||
// The value has never been editable anywhere in TREK, so a stop without one needs a
|
||||
// way in before it can get one — the plus sits in the slot the number will occupy.
|
||||
const onEditStay = vi.fn()
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Berlin' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onEditStay={onEditStay} />)
|
||||
|
||||
expect(screen.getByText('1 h 30 min')).toBeInTheDocument()
|
||||
const chain = screen.getByRole('list')
|
||||
const add = within(chain).getByText('+')
|
||||
|
||||
fireEvent.click(add)
|
||||
expect(onEditStay).toHaveBeenCalledWith({ placeId: 20, name: 'Berlin', minutes: null, arrival: null })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-033: without the right to edit, a stop with no stay shows nothing', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg', dwellMinutes: 90 }),
|
||||
stop({ assignmentId: 2, name: 'Berlin' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
|
||||
|
||||
// A label, not an invitation that leads nowhere.
|
||||
expect(screen.getByText('1 h 30 min')).toBeInTheDocument()
|
||||
expect(screen.queryByText('+')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-030: a day counts the places it visits, not the pauses on the way', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Ionity Horst', stopType: 'charging' }),
|
||||
stop({ assignmentId: 3, name: 'Rasthof Fläming', stopType: 'rest_area' }),
|
||||
stop({ assignmentId: 4, name: 'Berlin' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg(), leg()] })] })} />)
|
||||
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-031: every kind the corridor finds is a pause, not a destination', () => {
|
||||
// Anything picked off "along the route" was come across on the way, so all six kinds
|
||||
// sit inside the leg with their own icon and none of them counts as a stop.
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Camping Seeblick', stopType: 'campsite' }),
|
||||
stop({ assignmentId: 3, name: 'Bäckerei Junge', stopType: 'restaurant' }),
|
||||
stop({ assignmentId: 4, name: 'Holstentor', stopType: 'sights' }),
|
||||
stop({ assignmentId: 5, name: 'Lübeck' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg(), leg(), leg()] })] })} />)
|
||||
|
||||
expect(screen.getByLabelText('Campsite')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Food')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Sights')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 stops')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-020: a stop kind nobody knows falls back to the running number', () => {
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Etwas Neues', stopType: 'helipad' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
|
||||
|
||||
expect(within(screen.getByRole('list')).getByText('2')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-021: without the right to edit days the chain cannot be dragged', () => {
|
||||
const { container } = wrap(<RoadtripSidebar routes={routes()} />)
|
||||
|
||||
expect(container.querySelector('li[draggable="true"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-022: dropping a stop on another reports where it should go', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Lueneburg' }),
|
||||
stop({ assignmentId: 3, name: 'Berlin' }),
|
||||
]
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} onReorderStop={onReorderStop} />,
|
||||
)
|
||||
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
expect(rows).toHaveLength(3)
|
||||
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
|
||||
fireEvent.dragStart(rows[2], { dataTransfer })
|
||||
fireEvent.dragOver(rows[0])
|
||||
fireEvent.drop(rows[0])
|
||||
|
||||
// The last stop dropped on the first: day, the assignment moved, and its new index.
|
||||
expect(onReorderStop).toHaveBeenCalledWith(1, 3, 0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-023: dropping a stop back on itself changes nothing', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
const { container } = wrap(<RoadtripSidebar routes={routes()} onReorderStop={onReorderStop} />)
|
||||
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
|
||||
fireEvent.dragStart(rows[0], { dataTransfer })
|
||||
fireEvent.dragOver(rows[0])
|
||||
fireEvent.drop(rows[0])
|
||||
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-024: the chain can be reordered from the keyboard too', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
const stops = [
|
||||
stop({ assignmentId: 1, name: 'Hamburg' }),
|
||||
stop({ assignmentId: 2, name: 'Lueneburg' }),
|
||||
stop({ assignmentId: 3, name: 'Berlin' }),
|
||||
]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, legs: [leg(), leg()] })] })} onReorderStop={onReorderStop} />)
|
||||
|
||||
// Dragging is a gesture; without this the chain is unreachable without a mouse.
|
||||
fireEvent.keyDown(screen.getByText('Lueneburg').closest('button')!, { key: 'ArrowDown', altKey: true })
|
||||
expect(onReorderStop).toHaveBeenCalledWith(1, 2, 2)
|
||||
|
||||
fireEvent.keyDown(screen.getByText('Lueneburg').closest('button')!, { key: 'ArrowUp', altKey: true })
|
||||
expect(onReorderStop).toHaveBeenCalledWith(1, 2, 0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-025: the ends of a day cannot be pushed past themselves', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes()} onReorderStop={onReorderStop} />)
|
||||
|
||||
fireEvent.keyDown(screen.getByText('Hamburg').closest('button')!, { key: 'ArrowUp', altKey: true })
|
||||
fireEvent.keyDown(screen.getByText('Berlin').closest('button')!, { key: 'ArrowDown', altKey: true })
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-026: an arrow without Alt still belongs to the list, not to us', () => {
|
||||
const onReorderStop = vi.fn()
|
||||
wrap(<RoadtripSidebar routes={routes()} onReorderStop={onReorderStop} />)
|
||||
|
||||
fireEvent.keyDown(screen.getByText('Hamburg').closest('button')!, { key: 'ArrowDown' })
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-027: a day without a drive appears only while something is dragged', () => {
|
||||
const quiet = { dayId: 9, dayNumber: 2, date: null, title: null, stops: [] }
|
||||
const onMoveStopToDay = vi.fn()
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ quietDays: [quiet] })}
|
||||
onReorderStop={vi.fn()}
|
||||
onMoveStopToDay={onMoveStopToDay}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Nothing in flight: an empty day is not worth a row, the rail is about the drive.
|
||||
expect(screen.queryByText(/drop one here/i)).not.toBeInTheDocument()
|
||||
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
fireEvent.dragStart(rows[0], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
|
||||
expect(screen.getByText(/drop one here/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-028: dropping on another day reports both days', () => {
|
||||
const quiet = { dayId: 9, dayNumber: 2, date: null, title: null, stops: [] }
|
||||
const onMoveStopToDay = vi.fn()
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ quietDays: [quiet] })}
|
||||
onReorderStop={vi.fn()}
|
||||
onMoveStopToDay={onMoveStopToDay}
|
||||
/>,
|
||||
)
|
||||
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
fireEvent.dragStart(rows[1], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
|
||||
const target = screen.getByText(/drop one here/i).closest('section')!
|
||||
fireEvent.dragOver(target)
|
||||
fireEvent.drop(target)
|
||||
|
||||
// From day 1, the second stop (assignment 2), onto day 9, at its end.
|
||||
expect(onMoveStopToDay).toHaveBeenCalledWith(1, 2, 9, 0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-029: a stop dropped on a row of another day lands at that row', () => {
|
||||
const second = day({
|
||||
dayId: 2,
|
||||
dayNumber: 2,
|
||||
stops: [stop({ assignmentId: 3, name: 'Dresden' }), stop({ assignmentId: 4, name: 'Prague' })],
|
||||
})
|
||||
const onMoveStopToDay = vi.fn()
|
||||
const onReorderStop = vi.fn()
|
||||
const { container } = wrap(
|
||||
<RoadtripSidebar
|
||||
routes={routes({ days: [day(), second], totalStops: 4 })}
|
||||
onReorderStop={onReorderStop}
|
||||
onMoveStopToDay={onMoveStopToDay}
|
||||
/>,
|
||||
)
|
||||
|
||||
const rows = container.querySelectorAll('li[draggable="true"]')
|
||||
fireEvent.dragStart(rows[0], { dataTransfer: { effectAllowed: '', setData: vi.fn() } })
|
||||
fireEvent.dragOver(rows[3])
|
||||
fireEvent.drop(rows[3])
|
||||
|
||||
expect(onMoveStopToDay).toHaveBeenCalledWith(1, 1, 2, 1)
|
||||
// Crossing days is a different call; the same-day reorder must not also fire.
|
||||
expect(onReorderStop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-018: a travel mode with no mark of its own falls back to the car', () => {
|
||||
const ferry = leg({ mode: 'ferry' })
|
||||
const { container } = wrap(<RoadtripSidebar routes={routes({ days: [day({ legs: [ferry] })] })} />)
|
||||
|
||||
expect(container.querySelector('svg.lucide-car-front')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-017: a late finding without a figure still reports itself', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Ferry' })]
|
||||
const schedule = {
|
||||
entries: stops.map(() => ({ arrival: '09:00', departure: '09:00', anchored: false, dayOffset: 0 })),
|
||||
warnings: [{ index: 1, code: 'late' as const }],
|
||||
}
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops, schedule })] })} />)
|
||||
|
||||
expect(screen.getByLabelText(/0 min/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-018: the track badge is a viewer-free control, and it says when a day is shaped', () => {
|
||||
const onFollowTrack = vi.fn()
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' })]
|
||||
const { rerender } = wrap(
|
||||
<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onFollowTrack={onFollowTrack} viaCounts={{ 1: 4 }} />,
|
||||
)
|
||||
|
||||
const badge = screen.getByRole('button', { name: 'Track' })
|
||||
fireEvent.click(badge)
|
||||
expect(onFollowTrack).toHaveBeenCalledWith(1)
|
||||
// The rail draws no vias, so the tint is the only place a drive shaped by hand
|
||||
// differs from one the router picked on its own.
|
||||
expect(badge.className).toContain('bg-accent-subtle')
|
||||
|
||||
rerender(
|
||||
<TranslationProvider>
|
||||
<RoadtripSidebar routes={routes({ days: [day({ stops })] })} onFollowTrack={onFollowTrack} />
|
||||
</TranslationProvider>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Track' }).className).not.toContain('bg-accent-subtle')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-SIDEBAR-019: a viewer is offered no track badge at all', () => {
|
||||
const stops = [stop({ assignmentId: 1, name: 'Hamburg' }), stop({ assignmentId: 2, name: 'Berlin' })]
|
||||
wrap(<RoadtripSidebar routes={routes({ days: [day({ stops })] })} />)
|
||||
expect(screen.queryByRole('button', { name: 'Track' })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('RoadtripModeSwitch', () => {
|
||||
it('FE-ROADTRIP-MODESWITCH-001: offers both readings and marks the active one', () => {
|
||||
wrap(<RoadtripModeSwitch active onChange={vi.fn()} />)
|
||||
|
||||
const tabs = screen.getAllByRole('tab')
|
||||
expect(tabs).toHaveLength(2)
|
||||
expect(tabs[0]).toHaveAttribute('aria-selected', 'false')
|
||||
expect(tabs[1]).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODESWITCH-002: switching back to the day plan reports false', () => {
|
||||
const onChange = vi.fn()
|
||||
wrap(<RoadtripModeSwitch active onChange={onChange} />)
|
||||
|
||||
fireEvent.click(screen.getAllByRole('tab')[0])
|
||||
expect(onChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { fireEvent, render, screen, waitFor } from '../../../tests/helpers/render'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import RoadtripStayModal, { type StayDraft } from './RoadtripStayModal'
|
||||
|
||||
/**
|
||||
* FE-STAYMODAL-001..011 — how long the traveller stays at one stop.
|
||||
*
|
||||
* The value is `places.duration_minutes`, which the schedule has read for years
|
||||
* and nothing could write. Two things are worth pinning: that clearing sends a
|
||||
* zero rather than a null — the update statement folds a null into "leave it
|
||||
* alone", so a null could give a stop a stay but never take one away — and that
|
||||
* the dialog answers the question the number is chosen for, which is when the
|
||||
* drive leaves again.
|
||||
*/
|
||||
|
||||
const draft = (over: Partial<StayDraft> = {}): StayDraft => ({
|
||||
placeId: 9,
|
||||
name: 'Rasthof Dammer Berge',
|
||||
minutes: null,
|
||||
arrival: '12:00',
|
||||
...over,
|
||||
})
|
||||
|
||||
function open(over: Partial<StayDraft> = {}, onSave = vi.fn(), onClose = vi.fn()) {
|
||||
render(<RoadtripStayModal stop={draft(over)} onClose={onClose} onSave={onSave} />)
|
||||
return { onSave, onClose }
|
||||
}
|
||||
|
||||
const slider = () => screen.getByRole('slider') as HTMLInputElement
|
||||
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ settings: { time_format: '24h' } as never })
|
||||
})
|
||||
|
||||
describe('RoadtripStayModal', () => {
|
||||
it('FE-STAYMODAL-001: no stop means no dialog', () => {
|
||||
const { container } = render(<RoadtripStayModal stop={null} onClose={vi.fn()} onSave={vi.fn()} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-002: it opens on the stop own length, not on the last one looked at', () => {
|
||||
open({ minutes: 45 })
|
||||
expect(slider().value).toBe('45')
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-003: a stop with no stay opens at nothing rather than at a guess', () => {
|
||||
open({ minutes: null })
|
||||
expect(slider().value).toBe('0')
|
||||
expect(screen.getAllByText(/no stay|none/i).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-004: it answers the question the number is chosen for', () => {
|
||||
// The arrival is fixed by the drive; the departure is the only thing this
|
||||
// dialog moves, and seeing it move is the point of a slider over a box.
|
||||
open({ minutes: 30, arrival: '12:00' })
|
||||
|
||||
expect(screen.getByText('12:00')).toBeInTheDocument()
|
||||
expect(screen.getByText('12:30')).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(slider(), { target: { value: '90' } })
|
||||
expect(screen.getByText('13:30')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-005: a stop the drive has not timed shows no clock at all', () => {
|
||||
// Better than inventing one: a departure without an arrival is a number with
|
||||
// nothing behind it.
|
||||
open({ minutes: 30, arrival: null })
|
||||
expect(screen.queryByText(/\d\d:\d\d/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-006: the clock follows the reader own format', () => {
|
||||
useSettingsStore.setState({ settings: { time_format: '12h' } as never })
|
||||
open({ minutes: 60, arrival: '13:00' })
|
||||
expect(screen.getByText(/1:00\s*PM/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/2:00\s*PM/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-007: the two buttons nudge, and stop at the ends', () => {
|
||||
open({ minutes: 0 })
|
||||
|
||||
// Nothing below zero: the minus is off rather than silently doing nothing.
|
||||
expect(screen.getByRole('button', { name: '-5' })).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '+5' }))
|
||||
expect(slider().value).toBe('5')
|
||||
fireEvent.click(screen.getByRole('button', { name: '-5' }))
|
||||
expect(slider().value).toBe('0')
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-008: the presets are one tap, which is the point of them', () => {
|
||||
open({ minutes: 0 })
|
||||
fireEvent.click(screen.getByRole('button', { name: '1 h' }))
|
||||
expect(slider().value).toBe('60')
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-009: saving reports the stop and the chosen length, then closes', async () => {
|
||||
const { onSave, onClose } = open({ minutes: 0 })
|
||||
|
||||
fireEvent.change(slider(), { target: { value: '45' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /^save$/i }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(9, 45))
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-010: clearing sends a zero, never a null', async () => {
|
||||
// The update statement folds a null into "leave it alone", so a null could
|
||||
// give a stop a stay and never take one away.
|
||||
const { onSave } = open({ minutes: 90 })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /no stay|none/i }))
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(9, 0))
|
||||
expect(onSave.mock.calls[0][1]).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-011: a save already running is not started twice', async () => {
|
||||
// Two clicks on Save wrote the same value twice and closed on the first
|
||||
// answer, which on a slow connection is easy to do.
|
||||
let release: () => void = () => {}
|
||||
const onSave = vi.fn(() => new Promise<void>(r => { release = r }))
|
||||
open({ minutes: 30 }, onSave as never)
|
||||
|
||||
const save = screen.getByRole('button', { name: /^save$/i })
|
||||
fireEvent.click(save)
|
||||
fireEvent.click(save)
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
|
||||
release()
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('FE-STAYMODAL-012: cancelling changes nothing', () => {
|
||||
const { onSave, onClose } = open({ minutes: 30 })
|
||||
fireEvent.change(slider(), { target: { value: '120' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: /^cancel$/i }))
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,219 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Hourglass, Minus, Plus, ArrowRight } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDurationShort, formatClock, parseClock } from './roadtripModel'
|
||||
import { formatClockTime } from '../../utils/formatters'
|
||||
|
||||
/**
|
||||
* The lengths a stop usually takes, so the common answer is one tap.
|
||||
*
|
||||
* Kept short on purpose: a longer list reads as a form, and anything not on it is what
|
||||
* the slider is for.
|
||||
*/
|
||||
const PRESETS = [15, 30, 45, 60, 90, 120]
|
||||
|
||||
/** As far as the slider goes, in minutes. Beyond four hours it is a day, not a stop. */
|
||||
const MAX = 240
|
||||
/** The step the slider and the two buttons move in. */
|
||||
const STEP = 5
|
||||
|
||||
/** The stop being given a length. */
|
||||
export interface StayDraft {
|
||||
placeId: number
|
||||
name: string
|
||||
minutes: number | null
|
||||
/** When the drive gets here, so the dialog can show what the stay pushes back. */
|
||||
arrival?: string | null
|
||||
}
|
||||
|
||||
interface RoadtripStayModalProps {
|
||||
stop: StayDraft | null
|
||||
onClose: () => void
|
||||
/** Zero clears it: the rail reads "no stay" and "nothing planned" as the same thing. */
|
||||
onSave: (placeId: number, minutes: number) => Promise<void> | void
|
||||
}
|
||||
|
||||
/** The slider's own track and thumb, drawn from tokens so both themes get it right. */
|
||||
const SLIDER = [
|
||||
'h-1.5 w-full cursor-pointer appearance-none rounded-full bg-surface-tertiary',
|
||||
'[&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5',
|
||||
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full',
|
||||
'[&::-webkit-slider-thumb]:bg-accent [&::-webkit-slider-thumb]:shadow-card',
|
||||
'[&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-[var(--bg-card)]',
|
||||
'[&::-moz-range-thumb]:h-5 [&::-moz-range-thumb]:w-5 [&::-moz-range-thumb]:border-0',
|
||||
'[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent',
|
||||
].join(' ')
|
||||
|
||||
/**
|
||||
* How long the traveller stays at one stop.
|
||||
*
|
||||
* A road-trip idea rather than a property of the place: it is what pushes every later
|
||||
* arrival back, so it belongs beside the drive times rather than in the place form, which
|
||||
* is why this opens from the rail and nowhere else.
|
||||
*
|
||||
* Built around the number instead of around a field. The chosen length is the largest
|
||||
* thing on screen and updates as the slider moves, and under it the dialog answers the
|
||||
* question the number is actually being chosen for: given when the drive arrives, when
|
||||
* does it leave again. Typing minutes into a box could never show that.
|
||||
*
|
||||
* The value is `places.duration_minutes`, which the schedule has read for years and
|
||||
* nothing has ever been able to write. Cleared as a zero rather than a null, because the
|
||||
* update statement folds a null into "leave it alone" — a null could give a stop a stay
|
||||
* but never take one away.
|
||||
*/
|
||||
export default function RoadtripStayModal({ stop, onClose, onSave }: RoadtripStayModalProps): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const [minutes, setMinutes] = useState(0)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Reopened on a different stop, so it starts from that stop's own value rather than
|
||||
// from whatever the last one was left on.
|
||||
useEffect(() => { setMinutes(stop?.minutes ?? 0) }, [stop])
|
||||
|
||||
// What the stay does to the day, worked out live: the arrival is fixed by the drive,
|
||||
// the departure is the only thing this dialog moves.
|
||||
const times = useMemo(() => {
|
||||
const arrival = parseClock(stop?.arrival)
|
||||
if (arrival === null) return null
|
||||
return {
|
||||
arrive: formatClockTime(formatClock(arrival), is12h),
|
||||
leave: formatClockTime(formatClock(arrival + minutes), is12h),
|
||||
}
|
||||
}, [stop?.arrival, minutes, is12h])
|
||||
|
||||
if (!stop) return null
|
||||
|
||||
const commit = async (value: number) => {
|
||||
if (saving) return
|
||||
setSaving(true)
|
||||
try {
|
||||
await onSave(stop.placeId, value)
|
||||
onClose()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const nudge = (delta: number) => setMinutes(m => Math.min(MAX, Math.max(0, m + delta)))
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} size="sm" title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Hourglass size={15} className="text-content-faint" aria-hidden />
|
||||
{t('roadtrip.stop.stay')}
|
||||
</span>
|
||||
}>
|
||||
<div className="flex flex-col gap-5">
|
||||
<p className="break-words text-center text-caption text-content-muted">{stop.name}</p>
|
||||
|
||||
{/* The number, the way a timer shows one. Minus and plus flank it so a value can
|
||||
be nudged without hunting for the slider's thumb. */}
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nudge(-STEP)}
|
||||
disabled={minutes === 0}
|
||||
aria-label={`-${STEP}`}
|
||||
className="grid h-9 w-9 place-items-center rounded-full border border-edge text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-30"
|
||||
>
|
||||
<Minus size={15} aria-hidden />
|
||||
</button>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className="min-w-[8rem] text-center text-title font-semibold tabular-nums tracking-tight text-content"
|
||||
>
|
||||
{minutes > 0 ? formatDurationShort(minutes * 60) : t('roadtrip.stay.none')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nudge(STEP)}
|
||||
disabled={minutes >= MAX}
|
||||
aria-label={`+${STEP}`}
|
||||
className="grid h-9 w-9 place-items-center rounded-full border border-edge text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-30"
|
||||
>
|
||||
<Plus size={15} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={MAX}
|
||||
step={STEP}
|
||||
value={minutes}
|
||||
onChange={e => setMinutes(Number(e.target.value))}
|
||||
aria-label={t('roadtrip.stay.custom')}
|
||||
className={SLIDER}
|
||||
/>
|
||||
|
||||
{/* What the stay costs the rest of the day. The arrival cannot move — the drive
|
||||
decides it — so the arrow shows the one end this dialog does move. */}
|
||||
{times ? (
|
||||
<div className="flex items-center justify-center gap-3 rounded-xl border border-edge-faint bg-surface-secondary px-3 py-2.5">
|
||||
<span className="flex flex-col items-center gap-0.5">
|
||||
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
|
||||
{t('roadtrip.stay.arrive')}
|
||||
</span>
|
||||
<span dir="ltr" className="text-body font-semibold tabular-nums text-content-secondary">{times.arrive}</span>
|
||||
</span>
|
||||
<ArrowRight size={14} className="mt-3 shrink-0 text-content-faint" aria-hidden />
|
||||
<span className="flex flex-col items-center gap-0.5">
|
||||
<span className="font-geist text-caption font-semibold uppercase tracking-[0.12em] text-content-faint">
|
||||
{t('roadtrip.stay.leave')}
|
||||
</span>
|
||||
<span dir="ltr" className="text-body font-semibold tabular-nums text-content">{times.leave}</span>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{PRESETS.map(value => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setMinutes(value)}
|
||||
className={`rounded-full border px-3 py-1 text-caption font-medium tabular-nums transition-colors ${
|
||||
minutes === value
|
||||
? 'border-accent bg-accent text-accent-text'
|
||||
: 'border-edge bg-surface-card text-content-secondary hover:border-content-faint'
|
||||
}`}
|
||||
>
|
||||
{formatDurationShort(value * 60)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center gap-2">
|
||||
{/* Removing is the same write as setting one, so it sits with the other actions
|
||||
rather than behind a confirmation. */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving || !stop.minutes}
|
||||
onClick={() => void commit(0)}
|
||||
className="rounded-lg px-3 py-2 text-caption font-medium text-content-muted transition-colors hover:bg-surface-hover hover:text-content disabled:opacity-40"
|
||||
>
|
||||
{t('roadtrip.stay.none')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="ms-auto rounded-lg border border-edge px-3 py-2 text-caption font-medium text-content-secondary transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={saving}
|
||||
onClick={() => void commit(minutes)}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-caption font-semibold text-accent-text transition-opacity disabled:opacity-40"
|
||||
>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripStopPopup, { type RoadtripStopDraft } from './RoadtripStopPopup'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
function poi(over: Partial<CorridorPoi> = {}): CorridorPoi {
|
||||
return {
|
||||
osm_id: 'node:9',
|
||||
name: 'Aral Autohof',
|
||||
lat: 53.1,
|
||||
lng: 10.2,
|
||||
category: 'fuel',
|
||||
poi_type: 'amenity=fuel',
|
||||
address: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
opening_hours: null,
|
||||
cuisine: null,
|
||||
source: 'openstreetmap',
|
||||
offRouteKm: 1.2,
|
||||
alongKm: 120,
|
||||
...over,
|
||||
} as CorridorPoi
|
||||
}
|
||||
|
||||
const draft = (over: Partial<RoadtripStopDraft> = {}): RoadtripStopDraft => ({
|
||||
poi: poi(),
|
||||
dayId: 4,
|
||||
position: 2,
|
||||
dayNumber: 1,
|
||||
...over,
|
||||
})
|
||||
|
||||
const noop = {
|
||||
onClose: vi.fn(),
|
||||
onSave: vi.fn(),
|
||||
onMoreDetails: vi.fn(),
|
||||
}
|
||||
|
||||
describe('RoadtripStopPopup', () => {
|
||||
it('FE-ROADTRIP-STOPPOPUP-001: without a draft there is nothing to show', () => {
|
||||
const { container } = wrap(<RoadtripStopPopup draft={null} {...noop} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-002: says where the stop will land, counting from one', () => {
|
||||
wrap(<RoadtripStopPopup draft={draft({ position: 2, dayNumber: 3 })} {...noop} />)
|
||||
|
||||
expect(screen.getByText('Aral Autohof')).toBeInTheDocument()
|
||||
// Position 2 in the array is the third stop for a reader.
|
||||
expect(screen.getByText('Day 3, as stop 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-003: the kind the search found is preselected', () => {
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /Fuel/ })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('button', { name: /Charging/ })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-004: saving reports the kind and how long it takes', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
// Fuel suggests ten minutes; nobody plans an hour for it.
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 10 }))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-005: picking another kind brings its own duration along', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
// Charging is half an hour, not the ten minutes a tank takes.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Charging/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'charging', dwellMinutes: 30 }))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-006: a duration chosen by hand survives the save', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /45 min/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: 'fuel', dwellMinutes: 45 }))
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-007: unpicking the kind leaves an ordinary place', async () => {
|
||||
const onSave = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Fuel/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ stopType: null, dwellMinutes: 10 }))
|
||||
})
|
||||
|
||||
/**
|
||||
* Every category the corridor can search for, with the name its button carries.
|
||||
*
|
||||
* The keys are the contract with `CATEGORY_OSM_FILTERS` on the server, which labels
|
||||
* each hit with the category its own OSM tag matched. A key the popup does not know
|
||||
* preselects nothing, and the stop is saved as an ordinary place — which is exactly
|
||||
* how food and sights used to end up as numbered destinations in the middle of a drive.
|
||||
*/
|
||||
const CORRIDOR_KINDS: [string, string][] = [
|
||||
['fuel', 'Fuel'],
|
||||
['charging', 'Charging'],
|
||||
['rest_area', 'Rest area'],
|
||||
['campsite', 'Campsite'],
|
||||
['restaurant', 'Food'],
|
||||
['sights', 'Sights'],
|
||||
]
|
||||
|
||||
it.each(CORRIDOR_KINDS)('FE-ROADTRIP-STOPPOPUP-008: a %s hit preselects its own kind', (category, label) => {
|
||||
const { unmount } = wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category, name: 'Found on the way' }) })} {...noop} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: new RegExp(label) })).toHaveAttribute('aria-pressed', 'true')
|
||||
// Every other kind is off, so the preselect cannot be reading the wrong row. Scoped
|
||||
// to the kind buttons: the dwell presets use `aria-pressed` for their own state.
|
||||
for (const [, other] of CORRIDOR_KINDS) {
|
||||
if (other === label) continue
|
||||
expect(screen.getByRole('button', { name: new RegExp(other) })).toHaveAttribute('aria-pressed', 'false')
|
||||
}
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-012: a kind nobody knows preselects nothing', () => {
|
||||
wrap(<RoadtripStopPopup draft={draft({ poi: poi({ category: 'museum', name: 'Albertinum' }) })} {...noop} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: /Fuel/ })).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(screen.getByRole('button', { name: /Campsite/ })).toHaveAttribute('aria-pressed', 'false')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-009: warns when the same place is already on the trip', () => {
|
||||
const { unmount } = wrap(<RoadtripStopPopup draft={draft()} {...noop} duplicateName="Aral Autohof" />)
|
||||
expect(screen.getByText(/already on this trip/)).toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} />)
|
||||
expect(screen.queryByText(/already on this trip/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-010: the way out to the full form is offered and reported', () => {
|
||||
const onMoreDetails = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onMoreDetails={onMoreDetails} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'More details' }))
|
||||
expect(onMoreDetails).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-011: a save in flight cannot be fired twice', async () => {
|
||||
let release: () => void = () => {}
|
||||
const onSave = vi.fn(() => new Promise<void>(resolve => { release = resolve }))
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSave={onSave} />)
|
||||
|
||||
const add = screen.getByRole('button', { name: 'Add' })
|
||||
fireEvent.click(add)
|
||||
await waitFor(() => expect(add).toBeDisabled())
|
||||
fireEvent.click(add)
|
||||
expect(onSave).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Settle it inside act, or React re-enables the button after the test has finished.
|
||||
await act(async () => { release() })
|
||||
expect(add).not.toBeDisabled()
|
||||
})
|
||||
// ── Overnight mode ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Somewhere to sleep is the one hit that ends the day rather than interrupting the
|
||||
// drive, so the dialog grows a second mode instead of offering a hotel a dwell of
|
||||
// thirty minutes.
|
||||
|
||||
const overnight = {
|
||||
days: [
|
||||
{ id: 4, number: 1, date: '2026-08-31' },
|
||||
{ id: 5, number: 2, date: '2026-09-01' },
|
||||
{ id: 6, number: 3, date: '2026-09-02' },
|
||||
],
|
||||
defaultEndDayId: 5,
|
||||
}
|
||||
const nightDraft = (category: string) => draft({
|
||||
poi: poi({ category, name: 'Hotel Adlon' }),
|
||||
overnight,
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-020: a hotel opens on the overnight mode', () => {
|
||||
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} onSaveNight={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Overnight' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByText('Check out on')).toBeInTheDocument()
|
||||
// The two questions a pause asks are gone: a hotel has no kind and no dwell.
|
||||
expect(screen.queryByText('Kind of stop')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-021: a campsite opens on the pause mode but offers the night', () => {
|
||||
// It is a service stop today and stays one, but people do sleep at campsites, so the
|
||||
// choice is offered rather than decided.
|
||||
wrap(<RoadtripStopPopup draft={nightDraft('campsite')} {...noop} onSaveNight={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Pause' })).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByText('Kind of stop')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Overnight' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-022: a petrol station is never offered a night', () => {
|
||||
// Nothing to choose between, so no switch at all.
|
||||
wrap(<RoadtripStopPopup draft={draft()} {...noop} onSaveNight={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Overnight' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-023: booking a night reports the check-out day, defaulting to the next', () => {
|
||||
const onSaveNight = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} onSaveNight={onSaveNight} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
|
||||
expect(onSaveNight).toHaveBeenCalledWith({ endDayId: 5, checkIn: '', checkOut: '' })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-024: switching back to a pause saves a pause', () => {
|
||||
const onSave = vi.fn()
|
||||
const onSaveNight = vi.fn()
|
||||
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} onSave={onSave} onSaveNight={onSaveNight} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Pause' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
||||
|
||||
expect(onSaveNight).not.toHaveBeenCalled()
|
||||
expect(onSave).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-026: a scheme-less OSM website still leaves the app', () => {
|
||||
// OSM `website` tags are routinely written without a scheme. As a raw href
|
||||
// `www.hotel.de` is a RELATIVE url, so the click navigated the planner to
|
||||
// /trips/<id>/www.hotel.de and the user lost the page they were on.
|
||||
wrap(
|
||||
<RoadtripStopPopup
|
||||
draft={draft({ poi: poi({ category: 'hotel', website: 'www.hotel-adlon.de' }), overnight })}
|
||||
{...noop}
|
||||
onSaveNight={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Website' })).toHaveAttribute('href', 'https://www.hotel-adlon.de')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-027: a website that is not a link is not rendered as one', () => {
|
||||
// Anything that is not http(s) loses the link rather than being handed to
|
||||
// the browser. The CSP stops a javascript: navigation on a stock deployment,
|
||||
// but this is defended everywhere else in the app and belongs here too.
|
||||
for (const bad of ['javascript:alert(1)', 'data:text/html,<script>', 'tel:+49301234']) {
|
||||
const { unmount } = wrap(
|
||||
<RoadtripStopPopup
|
||||
draft={draft({ poi: poi({ category: 'hotel', website: bad }), overnight })}
|
||||
{...noop}
|
||||
onSaveNight={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
expect(screen.queryByRole('link', { name: 'Website' }), bad).not.toBeInTheDocument()
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-STOPPOPUP-025: without a way to book a night the switch stays away', () => {
|
||||
// An onSaveNight-less caller gets the dialog it always had, rather than a mode that
|
||||
// leads nowhere.
|
||||
wrap(<RoadtripStopPopup draft={nightDraft('hotel')} {...noop} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Overnight' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Kind of stop')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,326 @@
|
||||
import React, { useState } from 'react'
|
||||
import { ParkingSquare, Hourglass, AlertTriangle, BedDouble } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { safeExternalHref } from '../../utils/safeUrl'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import { formatDurationShort, SERVICE_COLORS } from './roadtripModel'
|
||||
import { STOP_KINDS, STOP_KIND_BY_KEY } from './stopKinds'
|
||||
import type { CorridorPoi } from './useCorridorPois'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* The four kinds of stop, with the icon and colour they already carry in the corridor
|
||||
* search, and how long each one usually takes.
|
||||
*
|
||||
* Deliberately not TREK's place categories: those are the traveller's own editable list,
|
||||
* shared across the whole instance. Refuelling is not a taste, it is a fact about the
|
||||
* place, so it lives in `places.stop_type` and keeps the palette the search gave it.
|
||||
*
|
||||
* The colours are the road-trip palette from `poiCategories.ts` and are the same in both
|
||||
* themes on purpose — a brand-neutral fuel blue reads as fuel either way.
|
||||
*/
|
||||
// The kinds, their icons, their colours and how long each one usually takes all come
|
||||
// from the one table in stopKinds.ts.
|
||||
|
||||
/** How long to stand still, offered as the few answers anyone actually gives. */
|
||||
const DWELL_CHOICES = [5, 10, 20, 30, 45, 60]
|
||||
|
||||
export interface RoadtripStopDraft {
|
||||
poi: CorridorPoi
|
||||
dayId: number
|
||||
/** Where in the day's chain it goes, worked out from how far along the drive it sits. */
|
||||
position: number
|
||||
dayNumber: number
|
||||
/**
|
||||
* Set only for a hit somebody could sleep at, and it is what turns the popup into two
|
||||
* modes rather than one. Absent, the dialog asks the two questions it always asked.
|
||||
*/
|
||||
overnight?: {
|
||||
/** The trip's days from this one on, in travel order — the check-out options. */
|
||||
days: { id: number; number: number; date: string | null }[]
|
||||
/** The day after this one, or this one when it is the last. */
|
||||
defaultEndDayId: number
|
||||
}
|
||||
}
|
||||
|
||||
interface RoadtripStopPopupProps {
|
||||
draft: RoadtripStopDraft | null
|
||||
/** Names of stops already on this trip that came from the same OSM object. */
|
||||
duplicateName?: string | null
|
||||
onClose: () => void
|
||||
onSave: (input: { stopType: RoadtripStopType | null; dwellMinutes: number }) => Promise<void> | void
|
||||
/**
|
||||
* Books the hit as a night instead of a pause: the place, its day, and a row in
|
||||
* day_accommodations, in one go. Only ever called when the draft carries `overnight`.
|
||||
*/
|
||||
onSaveNight?: (input: { endDayId: number; checkIn: string; checkOut: string }) => Promise<void> | void
|
||||
/** Hands over what has been picked here, so the full form opens on the same answer. */
|
||||
onMoreDetails: (stop: { stopType: RoadtripStopType | null; dwellMinutes: number }) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Adding something found along the drive, without the full place form.
|
||||
*
|
||||
* The form is right for a place you are planning a day around; it is wrong for a petrol
|
||||
* station. Everything it asks for — category, price, photo, notes, files — is empty for a
|
||||
* refuelling stop, and in road trip mode it also drops the place into the unplanned pool,
|
||||
* which neither column shows. This asks the two questions that matter (what kind of stop,
|
||||
* how long) and says where it will land, with a way out to the full form for the rest.
|
||||
*/
|
||||
export default function RoadtripStopPopup({
|
||||
draft, duplicateName, onClose, onSave, onSaveNight, onMoreDetails,
|
||||
}: RoadtripStopPopupProps): React.ReactElement | null {
|
||||
const { t, language } = useTranslation()
|
||||
// Through the same guard every other external link in the app goes through.
|
||||
// This value is an unvalidated OpenStreetMap tag, and OSM `website` tags are
|
||||
// routinely written without a scheme — `www.hotel.de` as a raw href is a
|
||||
// RELATIVE url, so the click navigated the planner to
|
||||
// /trips/<id>/www.hotel.de instead of leaving the app. The helper adds the
|
||||
// scheme for a bare host and drops anything that is not http(s).
|
||||
const websiteHref = safeExternalHref(draft?.poi.website)
|
||||
const suggested = STOP_KINDS.find(k => k.key === draft?.poi.category)
|
||||
const [stopType, setStopType] = useState<RoadtripStopType | null>(suggested?.key ?? null)
|
||||
const [dwell, setDwell] = useState<number>(suggested?.defaultMinutes ?? 30)
|
||||
// A hotel is a night by default and a campsite a pause, which is what each already
|
||||
// means everywhere else — but both offer the other, because a campsite is somewhere
|
||||
// people sleep and a hotel is somewhere people stop for lunch.
|
||||
const [night, setNight] = useState<boolean>(draft?.poi.category === 'hotel')
|
||||
const [endDayId, setEndDayId] = useState<number | null>(draft?.overnight?.defaultEndDayId ?? null)
|
||||
const [checkIn, setCheckIn] = useState('')
|
||||
const [checkOut, setCheckOut] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
if (!draft) return null
|
||||
const overnight = draft.overnight
|
||||
const asNight = !!overnight && !!onSaveNight && night
|
||||
|
||||
const kind = STOP_KINDS.find(k => k.key === stopType)
|
||||
|
||||
const submit = async (): Promise<void> => {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (asNight) {
|
||||
await onSaveNight?.({
|
||||
endDayId: endDayId ?? overnight!.defaultEndDayId,
|
||||
checkIn,
|
||||
checkOut,
|
||||
})
|
||||
} else {
|
||||
await onSave({ stopType, dwellMinutes: dwell })
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen onClose={onClose} title={t('roadtrip.stop.addTitle')} size="sm">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-xl"
|
||||
// theme-lint-disable — the road-trip palette, the same one the map pin and the
|
||||
// result row use; a token here would make the three disagree.
|
||||
style={{ background: `${kind?.color ?? '#64748B'}1f`, color: kind?.color ?? '#64748B' }}
|
||||
>
|
||||
{kind ? <kind.Icon size={17} aria-hidden /> : <ParkingSquare size={17} aria-hidden />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="break-words text-body font-semibold text-content">{draft.poi.name}</p>
|
||||
<p className="mt-0.5 text-caption text-content-muted">
|
||||
{t('roadtrip.stop.landsOn', { day: draft.dayNumber, position: draft.position + 1 })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{duplicateName ? (
|
||||
// The full place form warns about duplicates; losing that warning would make
|
||||
// this the easiest way to add the same petrol station twice.
|
||||
<p className="flex items-start gap-1.5 rounded-lg bg-warning-soft px-2.5 py-2 text-caption text-warning">
|
||||
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden />
|
||||
{t('roadtrip.stop.duplicate', { name: duplicateName })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Only where sleeping is on the table. A petrol station gets no choice, because
|
||||
there is nothing to choose between. */}
|
||||
{overnight && onSaveNight ? (
|
||||
<div className="flex rounded-lg border border-edge p-0.5">
|
||||
{[
|
||||
{ on: false, label: t('roadtrip.stay.mode.pause'), Icon: Hourglass },
|
||||
{ on: true, label: t('roadtrip.stay.mode.night'), Icon: BedDouble },
|
||||
].map(({ on, label, Icon }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
aria-pressed={night === on}
|
||||
onClick={() => setNight(on)}
|
||||
className={`flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-caption transition-colors ${
|
||||
night === on
|
||||
? 'bg-accent font-semibold text-accent-text'
|
||||
: 'text-content-secondary hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<Icon size={13} aria-hidden />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{asNight ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.stay.checkOutDay')}
|
||||
</span>
|
||||
<div className="mt-1.5">
|
||||
<CustomSelect
|
||||
value={String(endDayId ?? overnight!.defaultEndDayId)}
|
||||
onChange={value => setEndDayId(Number(value))}
|
||||
options={overnight!.days.map(d => ({
|
||||
value: String(d.id),
|
||||
// Through the shared helper, like every other day date in
|
||||
// this feature. Interpolating the column gave this one
|
||||
// dropdown a bare ISO date while the rail beside it showed
|
||||
// the same day in the reader's own order.
|
||||
label: d.date
|
||||
? `${t('roadtrip.stay.dayNumber', { number: d.number })} · ${formatDate(d.date, language)}`
|
||||
: t('roadtrip.stay.dayNumber', { number: d.number }),
|
||||
}))}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Both optional: leaving them empty is normal, and the server stores null.
|
||||
A hotel found on a map has no idea when its reception opens. */}
|
||||
<div className="flex gap-3">
|
||||
<label className="min-w-0 flex-1">
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('day.checkIn')}
|
||||
</span>
|
||||
<input
|
||||
type="time"
|
||||
value={checkIn}
|
||||
onChange={e => setCheckIn(e.target.value)}
|
||||
className="mt-1.5 w-full rounded-lg border border-edge bg-surface px-2 py-1 text-body text-content focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
<label className="min-w-0 flex-1">
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('day.checkOut')}
|
||||
</span>
|
||||
<input
|
||||
type="time"
|
||||
value={checkOut}
|
||||
onChange={e => setCheckOut(e.target.value)}
|
||||
className="mt-1.5 w-full rounded-lg border border-edge bg-surface px-2 py-1 text-body text-content focus:border-accent focus:outline-none"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{/* What OSM knows about the house, which is the whole of what we offer: no
|
||||
price, no availability, no link into a booking portal. TREK links to maps
|
||||
and to the place's own site, never to a vendor. */}
|
||||
{websiteHref || draft.poi.phone ? (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-caption">
|
||||
{websiteHref ? (
|
||||
<a
|
||||
href={websiteHref}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{t('places.formWebsite')}
|
||||
</a>
|
||||
) : null}
|
||||
{draft.poi.phone ? (
|
||||
<a href={`tel:${draft.poi.phone}`} className="text-accent hover:underline">
|
||||
{draft.poi.phone}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.stop.kind')}
|
||||
</span>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{STOP_KINDS.map(({ key, labelKey, Icon }) => {
|
||||
const on = stopType === key
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => {
|
||||
setStopType(on ? null : key)
|
||||
// Picking a kind is also picking how long it takes, until the user
|
||||
// says otherwise — a charge is not a fuel stop.
|
||||
if (!on) setDwell(STOP_KIND_BY_KEY[key].defaultMinutes)
|
||||
}}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-caption transition-colors ${
|
||||
on
|
||||
? 'border-transparent bg-accent font-semibold text-accent-text'
|
||||
: 'border-edge text-content-secondary hover:border-content-faint hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Icon size={12} aria-hidden />
|
||||
{t(labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-caption font-medium uppercase tracking-wide text-content-faint">
|
||||
{t('roadtrip.stop.stay')}
|
||||
</span>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{DWELL_CHOICES.map(minutes => (
|
||||
<button
|
||||
key={minutes}
|
||||
type="button"
|
||||
aria-pressed={dwell === minutes}
|
||||
onClick={() => setDwell(minutes)}
|
||||
className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-caption tabular-nums transition-colors ${
|
||||
dwell === minutes
|
||||
? 'border-transparent bg-accent font-semibold text-accent-text'
|
||||
: 'border-edge text-content-secondary hover:border-content-faint hover:text-content'
|
||||
}`}
|
||||
>
|
||||
<Hourglass size={11} aria-hidden />
|
||||
{formatDurationShort(minutes * 60)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-edge-faint pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMoreDetails({ stopType, dwellMinutes: dwell })}
|
||||
className="rounded-lg px-2.5 py-1.5 text-caption text-content-muted transition-colors hover:bg-surface-hover hover:text-content"
|
||||
>
|
||||
{t('roadtrip.stop.moreDetails')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={saving}
|
||||
className="ms-auto rounded-lg bg-accent px-3.5 py-1.5 text-body font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{t('roadtrip.poi.add')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { TranslationProvider } from '../../i18n'
|
||||
import RoadtripTrackModal from './RoadtripTrackModal'
|
||||
import type { FollowTrack, TrackChoice } from './useFollowTrack'
|
||||
|
||||
const wrap = (ui: React.ReactElement) => render(<TranslationProvider>{ui}</TranslationProvider>)
|
||||
|
||||
const track = (over: Partial<TrackChoice> & { id: number; name: string }): TrackChoice => ({
|
||||
color: '#0ea5e9',
|
||||
points: [{ lat: 52, lng: 13 }, { lat: 52, lng: 14 }],
|
||||
lengthKm: 68,
|
||||
gapKm: 0.4,
|
||||
...over,
|
||||
})
|
||||
|
||||
function follow(over: Partial<FollowTrack> = {}): FollowTrack {
|
||||
return {
|
||||
dayId: 7,
|
||||
open: vi.fn(),
|
||||
close: vi.fn(),
|
||||
tracks: [track({ id: 3, name: 'Atlantic Road' })],
|
||||
busy: false,
|
||||
round: 0,
|
||||
error: null,
|
||||
outcome: null,
|
||||
apply: vi.fn().mockResolvedValue(undefined),
|
||||
clear: vi.fn().mockResolvedValue(undefined),
|
||||
viaCount: 0,
|
||||
available: true,
|
||||
current: null,
|
||||
namesByDay: {},
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
describe('RoadtripTrackModal', () => {
|
||||
it('FE-TRACKMODAL-001: closed is nothing at all, not a hidden dialog', () => {
|
||||
const { container } = wrap(<RoadtripTrackModal follow={follow({ dayId: null })} dayNumber={1} />)
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-002: a trip with no tracks says where tracks come from', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ tracks: [] })} dayNumber={2} />)
|
||||
expect(screen.getByText('No tracks in this trip')).toBeTruthy()
|
||||
expect(screen.getByText(/GPX or KML/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-003: a track close to the day says so rather than quoting a distance', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow()} dayNumber={1} />)
|
||||
expect(screen.getByText(/along this day/)).toBeTruthy()
|
||||
expect(screen.getByText(/68 km long/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-004: a track that runs elsewhere is offered with the distance to here', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ tracks: [track({ id: 9, name: 'Elsewhere', gapKm: 240 })] })} dayNumber={1} />)
|
||||
expect(screen.getByText(/240 km off this day/)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-005: nothing is applied until a track is picked', () => {
|
||||
const state = follow()
|
||||
wrap(<RoadtripTrackModal follow={state} dayNumber={1} />)
|
||||
|
||||
const action = screen.getByRole('button', { name: 'Follow this track' })
|
||||
expect((action as HTMLButtonElement).disabled).toBe(true)
|
||||
|
||||
fireEvent.click(screen.getByText('Atlantic Road'))
|
||||
expect((screen.getByRole('button', { name: 'Follow this track' }) as HTMLButtonElement).disabled).toBe(false)
|
||||
// What it will do, before it does it: a track becomes a dozen waypoints on the day.
|
||||
expect(screen.getByText(/via points/)).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Follow this track' }))
|
||||
expect(state.apply).toHaveBeenCalledWith(3)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-006: a picked track can be unpicked', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow()} dayNumber={1} />)
|
||||
fireEvent.click(screen.getByText('Atlantic Road'))
|
||||
fireEvent.click(screen.getByText('Atlantic Road'))
|
||||
expect((screen.getByRole('button', { name: 'Follow this track' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-007: a run says which round it is on rather than going blank', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ busy: true, round: 4 })} dayNumber={1} />)
|
||||
expect(screen.getByRole('status').textContent).toMatch(/round 4/)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-008: a finished run reports how close the drive got', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ outcome: { vias: 6, strayKm: 0.8, capped: false } })} dayNumber={1} />)
|
||||
expect(screen.getByRole('status').textContent).toMatch(/6 via points placed/)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-009: a run that ran out of vias admits the gap it left', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ outcome: { vias: 12, strayKm: 2.4, capped: true } })} dayNumber={1} />)
|
||||
// The honest form: it did what it could, and the number says how much that was worth.
|
||||
expect(screen.getByRole('status').textContent).toMatch(/no closer than/)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-010: a drive that already followed the track is not dressed up as work', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ outcome: { vias: 0, strayKm: 0, capped: false } })} dayNumber={1} />)
|
||||
expect(screen.getByRole('status').textContent).toMatch(/already followed/)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-011: a failure is an alert, not a silent nothing', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ error: 'route' })} dayNumber={1} />)
|
||||
expect(screen.getByRole('alert').textContent).toMatch(/routing service/)
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-012: dropping the day\'s vias is offered only once it has some', () => {
|
||||
const state = follow({ viaCount: 5 })
|
||||
const { rerender } = wrap(<RoadtripTrackModal follow={state} dayNumber={1} />)
|
||||
const drop = screen.getByRole('button', { name: /Drop 5 via points/ })
|
||||
fireEvent.click(drop)
|
||||
expect(state.clear).toHaveBeenCalled()
|
||||
|
||||
rerender(<TranslationProvider><RoadtripTrackModal follow={follow({ viaCount: 0 })} dayNumber={1} /></TranslationProvider>)
|
||||
expect(screen.queryByRole('button', { name: /Drop/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-TRACKMODAL-013: while it runs, nothing else in the dialog can be touched', () => {
|
||||
wrap(<RoadtripTrackModal follow={follow({ busy: true, viaCount: 2 })} dayNumber={1} />)
|
||||
expect((screen.getByRole('button', { name: /Atlantic Road/ }) as HTMLButtonElement).disabled).toBe(true)
|
||||
expect((screen.getByRole('button', { name: /Drop 2 via points/ }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { AlertTriangle, Check, RotateCw, Spline, Trash2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { FS } from './typeScale'
|
||||
import type { FollowTrack, TrackChoice } from './useFollowTrack'
|
||||
|
||||
/**
|
||||
* Picking the track a day drives along.
|
||||
*
|
||||
* The list is the whole dialog. Everything a traveller needs to tell two imported tracks
|
||||
* apart is on the row — the colour the map draws it in, how long it runs, and how far it
|
||||
* is from this particular day — and the last of those is why the list is ordered the way
|
||||
* it is: a trip carrying every track of a three-week tour still opens on the one beside
|
||||
* today's drive.
|
||||
*
|
||||
* Applying is slow and says so. It is a routing round trip per refinement, half a minute
|
||||
* on a long scenic route, and a dialog that went blank for that long would read as broken.
|
||||
*/
|
||||
|
||||
/** A track close enough that it is plainly about this day rather than another one. */
|
||||
const ON_THIS_DAY_KM = 2
|
||||
|
||||
function TrackRow({ track, disabled, onPick }: {
|
||||
track: TrackChoice
|
||||
disabled: boolean
|
||||
onPick: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const onDay = track.gapKm <= ON_THIS_DAY_KM
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onPick}
|
||||
className="flex w-full items-center gap-3 rounded-xl px-2 py-2 text-start transition-colors hover:bg-surface-hover disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
{/* The line's own colour, as a short stroke rather than a dot: it is a line on the
|
||||
map, and a dot beside a name reads as a place. */}
|
||||
<span
|
||||
className="h-1 w-6 shrink-0 rounded-full"
|
||||
// theme-lint-disable — the colour the track carries, the same one the map draws.
|
||||
style={{ background: track.color || 'var(--text-faint)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-semibold text-content" style={{ fontSize: FS.name }}>
|
||||
{track.name || t('roadtrip.track.untitled')}
|
||||
</span>
|
||||
<span className="block truncate tabular-nums text-content-faint" style={{ fontSize: FS.meta }}>
|
||||
{t('roadtrip.track.length', { distance: formatDistance(track.lengthKm, distanceUnit) })}
|
||||
{' · '}
|
||||
{onDay
|
||||
? t('roadtrip.track.onDay')
|
||||
: t('roadtrip.track.gap', { distance: formatDistance(track.gapKm, distanceUnit) })}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export default function RoadtripTrackModal({ follow, dayNumber }: {
|
||||
follow: FollowTrack
|
||||
/** The day's number, so the dialog says which drive it is about to reshape. */
|
||||
dayNumber: number
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const [picked, setPicked] = useState<number | null>(null)
|
||||
|
||||
const { dayId, busy, round, error, outcome, tracks, viaCount, current } = follow
|
||||
|
||||
// A track picked on one day means nothing on the next.
|
||||
useEffect(() => { setPicked(null) }, [dayId])
|
||||
|
||||
if (dayId === null) return null
|
||||
|
||||
const chosen = tracks.find(track => track.id === picked) ?? null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={follow.close}
|
||||
size="sm"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Spline size={15} className="text-content-faint" aria-hidden />
|
||||
{t('roadtrip.track.title', { number: dayNumber })}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* What the day follows today, before anything is picked. Read back from the day
|
||||
rather than remembered from the run that applied it, which is the whole reason
|
||||
it is stored: it has to survive a reload. */}
|
||||
{current ? (
|
||||
<p className="rounded-xl bg-surface-secondary px-3 py-2 text-caption text-content-secondary">
|
||||
{t('roadtrip.track.current', { name: current.name })}
|
||||
</p>
|
||||
) : null}
|
||||
{tracks.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 py-2">
|
||||
<EmptyState scene="transport" size={96} compact title={t('roadtrip.track.empty')} />
|
||||
<p className="text-center text-caption text-content-faint">{t('roadtrip.track.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="-mx-2 max-h-[280px] overflow-y-auto">
|
||||
{tracks.map(track => (
|
||||
<TrackRow
|
||||
key={track.id}
|
||||
track={track}
|
||||
disabled={busy}
|
||||
onPick={() => setPicked(track.id === picked ? null : track.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* What the pick will do, before it is done. The count is the honest part: a track
|
||||
becomes a dozen waypoints on the day, and a traveller who later wonders where
|
||||
they came from should have been told here. */}
|
||||
{chosen ? (
|
||||
<p className="rounded-xl bg-surface-secondary px-3 py-2 text-caption text-content-secondary">
|
||||
{t('roadtrip.track.explain')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{busy ? (
|
||||
<p className="flex items-center gap-2 text-caption text-content-secondary" role="status">
|
||||
<RotateCw size={13} className="animate-spin shrink-0" aria-hidden />
|
||||
{t('roadtrip.track.working', { round })}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-danger" role="alert">
|
||||
<AlertTriangle size={13} className="mt-px shrink-0" aria-hidden />
|
||||
{t(error === 'route' ? 'roadtrip.track.errorRoute' : 'roadtrip.track.errorSave')}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{outcome && !busy && !error ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-content-secondary" role="status">
|
||||
<Check size={13} className="mt-px shrink-0 text-success" aria-hidden />
|
||||
{outcome.vias === 0
|
||||
? t('roadtrip.track.already')
|
||||
: outcome.capped
|
||||
? t('roadtrip.track.capped', {
|
||||
count: outcome.vias,
|
||||
distance: formatDistance(outcome.strayKm, distanceUnit),
|
||||
})
|
||||
: t('roadtrip.track.done', {
|
||||
count: outcome.vias,
|
||||
distance: formatDistance(outcome.strayKm, distanceUnit),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Offered whenever the day carries vias, whether this dialog put them there or
|
||||
a dragged handle did: "put it back the way the router had it" is the same
|
||||
wish either way. */}
|
||||
{viaCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => { void follow.clear() }}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2.5 py-2 text-caption text-content-secondary transition-colors hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={13} aria-hidden />
|
||||
{t('roadtrip.track.clear', { count: viaCount })}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!chosen || busy}
|
||||
onClick={() => { if (chosen) void follow.apply(chosen.id) }}
|
||||
className="ms-auto rounded-lg bg-accent px-3 py-2 text-caption font-semibold text-accent-text transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{t('roadtrip.track.action')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { fireEvent, render, screen } from '../../../tests/helpers/render'
|
||||
import StopKindPicker from './StopKindPicker'
|
||||
import { STOP_KINDS } from './stopKinds'
|
||||
|
||||
/**
|
||||
* FE-STOPKIND-001..009 — turning a place on the drive into a pause, and back.
|
||||
*
|
||||
* Opened from the stop's own number, because the number is what changes: a
|
||||
* service stop has none. The popover is a portal hung under that element, so
|
||||
* what is worth pinning is the part a portal makes easy to get wrong — that it
|
||||
* renders nothing without an anchor, that clicking outside and Escape both close
|
||||
* it, and that picking the kind a stop already has takes it back off rather than
|
||||
* setting it twice.
|
||||
*/
|
||||
|
||||
let anchor: HTMLElement
|
||||
|
||||
beforeEach(() => {
|
||||
anchor = document.createElement('button')
|
||||
document.body.appendChild(anchor)
|
||||
})
|
||||
afterEach(() => { anchor.remove() })
|
||||
|
||||
function open(over: { current?: string | null; onPick?: (k: string | null) => void; onClose?: () => void } = {}) {
|
||||
const onPick = over.onPick ?? vi.fn()
|
||||
const onClose = over.onClose ?? vi.fn()
|
||||
render(
|
||||
<StopKindPicker
|
||||
anchor={anchor}
|
||||
current={over.current ?? null}
|
||||
onPick={onPick as never}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
return { onPick, onClose }
|
||||
}
|
||||
|
||||
describe('StopKindPicker', () => {
|
||||
it('FE-STOPKIND-001: without an anchor there is nothing to hang under', () => {
|
||||
const onPick = vi.fn()
|
||||
render(<StopKindPicker anchor={null} current={null} onPick={onPick} onClose={vi.fn()} />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-002: every kind the rail draws is offered, as a disc rather than a word', () => {
|
||||
open()
|
||||
const dialog = screen.getByRole('dialog')
|
||||
// One button per kind, plus none extra: six rows of text for six icons would
|
||||
// be a menu where a palette does.
|
||||
const buttons = dialog.querySelectorAll('button[aria-pressed]')
|
||||
expect(buttons).toHaveLength(STOP_KINDS.length)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-003: picking a kind reports it', () => {
|
||||
const { onPick } = open()
|
||||
const first = STOP_KINDS[0]
|
||||
fireEvent.click(screen.getAllByRole('button', { pressed: false })[0])
|
||||
expect(onPick).toHaveBeenCalledWith(first.key)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-004: picking the kind it already is takes it back off', () => {
|
||||
// The control is its own toggle. Without this, changing your mind about a
|
||||
// fuel stop means finding the separate undo below.
|
||||
const kind = STOP_KINDS[0].key
|
||||
const { onPick } = open({ current: kind })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { pressed: true }))
|
||||
|
||||
expect(onPick).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-005: the current kind is the pressed one, and it is the only one', () => {
|
||||
open({ current: STOP_KINDS[1].key })
|
||||
expect(screen.getAllByRole('button', { pressed: true })).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-006: the way back to a destination appears only once there is one', () => {
|
||||
const { onPick } = open({ current: STOP_KINDS[0].key })
|
||||
const back = screen.getByRole('button', { name: /destination/i })
|
||||
fireEvent.click(back)
|
||||
expect(onPick).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-007: on an ordinary place it would say "leave everything as it is", so it is absent', () => {
|
||||
open({ current: null })
|
||||
expect(screen.queryByRole('button', { name: /destination/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-008: a click outside closes it, a click inside does not', () => {
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.mouseDown(screen.getByRole('dialog'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
// The anchor is not "outside" either: clicking the number again is what
|
||||
// toggles the picker, and closing here would fight that.
|
||||
fireEvent.mouseDown(anchor)
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.mouseDown(document.body)
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-009: Escape closes it, and so does scrolling the rail underneath', () => {
|
||||
const { onClose } = open()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Following the anchor would mean measuring on every frame for a menu that
|
||||
// is open for two seconds; closing is the cheaper honest answer.
|
||||
fireEvent.scroll(window)
|
||||
expect(onClose).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MapPin } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { STOP_KINDS } from './stopKinds'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* Turning a place on the drive into a pause, and back.
|
||||
*
|
||||
* Opened from the stop's own number, because the number is exactly what changes: a
|
||||
* service stop has none. That makes the control its own preview — click the 3, pick the
|
||||
* pump, and the 3 becomes an orange disc while everything below renumbers itself.
|
||||
*
|
||||
* The kinds are the discs the rail already draws rather than a list of words. At this
|
||||
* size the colour and the shape are the label: a traveller who has seen one petrol stop
|
||||
* on the map knows the orange pump before reading anything, and six rows of text for six
|
||||
* icons would be a menu where a palette does.
|
||||
*
|
||||
* Only for stops on a drive. A place with no coordinates never reaches the rail, and a
|
||||
* hotel is not offered here at all, because sleeping somewhere is not a pause in the
|
||||
* driving — that path goes through the accommodation it books.
|
||||
*/
|
||||
|
||||
const DISC = 'grid place-items-center rounded-full transition-transform'
|
||||
|
||||
export default function StopKindPicker({ anchor, current, onPick, onClose }: {
|
||||
/** The element the popover hangs under, usually the stop's number. */
|
||||
anchor: HTMLElement | null
|
||||
current: string | null
|
||||
onPick: (kind: RoadtripStopType | null) => void
|
||||
onClose: () => void
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null)
|
||||
|
||||
// Measured after paint, before the browser shows it: reading the size first and then
|
||||
// placing it is what stops the panel appearing at 0,0 for one frame.
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !ref.current) return
|
||||
const a = anchor.getBoundingClientRect()
|
||||
const p = ref.current.getBoundingClientRect()
|
||||
const gap = 8
|
||||
let top = a.bottom + gap
|
||||
// Flips above when there is no room below, which is most of the rail on a short
|
||||
// window: a panel clipped by the viewport is one that cannot be used at all.
|
||||
if (top + p.height > window.innerHeight - 8) top = Math.max(8, a.top - p.height - gap)
|
||||
// Centred on the column, not on the number. The number sits hard against the left
|
||||
// edge of the rail, so a panel centred on it hangs half off the sidebar and points at
|
||||
// the map. Vertically it still follows the row it belongs to.
|
||||
const column = anchor.closest('section')?.getBoundingClientRect() ?? a
|
||||
const left = Math.min(
|
||||
Math.max(8, column.left + column.width / 2 - p.width / 2),
|
||||
window.innerWidth - p.width - 8,
|
||||
)
|
||||
setPos({ top, left })
|
||||
}, [anchor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (ref.current?.contains(e.target as Node)) return
|
||||
if (anchor.contains(e.target as Node)) return
|
||||
onClose()
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey)
|
||||
// Any scroll moves the anchor out from under the panel, and following it would mean
|
||||
// measuring on every frame for a menu that is open for two seconds.
|
||||
window.addEventListener('scroll', onClose, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
window.removeEventListener('scroll', onClose, true)
|
||||
}
|
||||
}, [anchor, onClose])
|
||||
|
||||
if (!anchor) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="dialog"
|
||||
aria-label={t('roadtrip.stop.kind')}
|
||||
className="fixed z-[70] rounded-2xl border border-edge bg-surface-card p-2 shadow-xl"
|
||||
style={{ top: pos?.top ?? -9999, left: pos?.left ?? -9999, visibility: pos ? 'visible' : 'hidden' }}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
{STOP_KINDS.map(({ key, labelKey, Icon, color }) => {
|
||||
const on = current === key
|
||||
return (
|
||||
<Tooltip key={key} label={t(labelKey)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
aria-label={t(labelKey)}
|
||||
onClick={() => onPick(on ? null : key)}
|
||||
className="group grid h-11 w-11 place-items-center rounded-xl transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<span
|
||||
className={`${DISC} h-7 w-7 group-hover:scale-110 ${on ? 'ring-2 ring-offset-2 ring-offset-surface-card' : ''}`}
|
||||
// theme-lint-disable — the road-signage palette from `roadtripModel`, the
|
||||
// same one the rail disc and the map pin use, so the choice looks like
|
||||
// what it will become.
|
||||
style={{ background: color, color: '#fff', ...(on ? { boxShadow: `0 0 0 2px ${color}` } : {}) }} // theme-lint-disable — road-signage palette
|
||||
>
|
||||
<Icon size={14} strokeWidth={2.2} aria-hidden />
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Only once there is something to undo. On an ordinary place it would be a button
|
||||
that says "leave everything as it is". */}
|
||||
{current ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(null)}
|
||||
className="mt-1 flex w-full items-center gap-2 rounded-xl px-2.5 py-2 text-caption text-content-secondary transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<MapPin size={14} className="shrink-0 text-content-faint" aria-hidden />
|
||||
{t('roadtrip.stop.backToDestination')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ACCESS_SPUR_MIN_M, ACCESS_LABEL_MIN_M, spurFor, spurWorthLabelling } from './accessSpur'
|
||||
import type { SnappedWaypoint } from '../../types'
|
||||
|
||||
const snap = (meters: number): SnappedWaypoint => ({
|
||||
asked: [53.5, 10.0],
|
||||
at: [53.51, 10.01],
|
||||
meters,
|
||||
})
|
||||
|
||||
describe('access spur', () => {
|
||||
it('FE-ACCESSSPUR-001: a gap worth showing becomes the line from the place to the road', () => {
|
||||
const s = snap(400)
|
||||
expect(spurFor(s)).toEqual([s.asked, s.at])
|
||||
})
|
||||
|
||||
it('FE-ACCESSSPUR-002: map precision is not a gap', () => {
|
||||
// A place pinned off an aerial photo lands a few metres from the centreline it
|
||||
// belongs to all the time. Drawing those would put a dash on nearly every stop.
|
||||
expect(spurFor(snap(0))).toBeNull()
|
||||
expect(spurFor(snap(ACCESS_SPUR_MIN_M - 1))).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ACCESSSPUR-003: the threshold itself counts as a gap', () => {
|
||||
expect(spurFor(snap(ACCESS_SPUR_MIN_M))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ACCESSSPUR-004: nothing to draw when the router said nothing', () => {
|
||||
expect(spurFor(undefined)).toBeNull()
|
||||
expect(spurFor(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ACCESSSPUR-005: a NaN distance is not a gap either', () => {
|
||||
// Recomputed distances can come out NaN from a malformed coordinate, and NaN >= n is
|
||||
// false — asserted rather than assumed, because the guard reads as a plain comparison.
|
||||
expect(spurFor(snap(Number.NaN))).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ACCESSSPUR-006: only a walk long enough to change the plan gets a number', () => {
|
||||
expect(spurWorthLabelling(ACCESS_LABEL_MIN_M)).toBe(true)
|
||||
expect(spurWorthLabelling(ACCESS_LABEL_MIN_M - 1)).toBe(false)
|
||||
expect(spurWorthLabelling(null)).toBe(false)
|
||||
expect(spurWorthLabelling(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ACCESSSPUR-007: the line is drawn but left unlabelled in between', () => {
|
||||
// The band where the map says "there is a gap" and the rail stays quiet.
|
||||
const between = (ACCESS_SPUR_MIN_M + ACCESS_LABEL_MIN_M) / 2
|
||||
expect(spurFor(snap(between))).not.toBeNull()
|
||||
expect(spurWorthLabelling(between)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { SnappedWaypoint } from '../../types'
|
||||
|
||||
/**
|
||||
* The gap between a place and the road the drive actually reaches it from.
|
||||
*
|
||||
* A router snaps every waypoint to the nearest road before it starts, and OSRM does it
|
||||
* with no distance limit. A place set back from the road is therefore driven to from
|
||||
* somewhere else, and the line drawn for that drive begins at the somewhere else without
|
||||
* saying so — which is what a viewpoint above a valley, a farmhouse down a track or a
|
||||
* marina at the end of a pier all look like today: a route that stops short for no
|
||||
* visible reason, or worse, one that appears to cut across open ground.
|
||||
*
|
||||
* Drawn as a dashed spur rather than hidden or silently absorbed, which is the same
|
||||
* answer Google gives: the drive ends at the road, the rest is not driving, and the
|
||||
* picture should say which is which.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Below this, the gap is map precision rather than missing road.
|
||||
*
|
||||
* Thirty metres is roughly the width of a dual carriageway plus its verge, and a place
|
||||
* pinned from an aerial photo lands that far from the centreline it belongs to all the
|
||||
* time. Drawing those would put a dash on almost every stop and teach people to ignore
|
||||
* the mark entirely.
|
||||
*/
|
||||
export const ACCESS_SPUR_MIN_M = 30
|
||||
|
||||
/**
|
||||
* Above this, the spur is worth a number as well as a line.
|
||||
*
|
||||
* A quarter of a kilometre is where "the car park is over there" turns into something
|
||||
* that changes the plan: a walk with luggage, a locked gate, a track a hire car should
|
||||
* not be on. Below it the dash alone says enough.
|
||||
*/
|
||||
export const ACCESS_LABEL_MIN_M = 250
|
||||
|
||||
/** The two-point line from a place to the road it was routed from, or null if there is none worth drawing. */
|
||||
export function spurFor(snapped: SnappedWaypoint | undefined | null): [[number, number], [number, number]] | null {
|
||||
if (!snapped) return null
|
||||
if (!(snapped.meters >= ACCESS_SPUR_MIN_M)) return null
|
||||
return [snapped.asked, snapped.at]
|
||||
}
|
||||
|
||||
/** Whether this gap is far enough that the rail should print the distance beside it. */
|
||||
export function spurWorthLabelling(meters: number | null | undefined): boolean {
|
||||
return typeof meters === 'number' && meters >= ACCESS_LABEL_MIN_M
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* How the offered routes are drawn, after Apple Maps.
|
||||
*
|
||||
* All of them blue, not one blue and the rest grey: they are all the same road network
|
||||
* and all equally drivable, so the difference between them is emphasis, not category. The
|
||||
* chosen one is the full-strength blue the route already uses; the others are a pale blue
|
||||
* that stays visible on a grey basemap — Positron is almost entirely greys, and a grey
|
||||
* line on it disappears, which is exactly what happened when these were grey.
|
||||
*
|
||||
* Fixed values rather than theme tokens: they have to match the blue the route itself is
|
||||
* drawn in, which is fixed too, and a token that shifted with the colour scheme would
|
||||
* break the pairing.
|
||||
*/
|
||||
|
||||
/** The route as chosen — same blue as the drawn trip route. */
|
||||
export const ALT_PRIMARY = '#0a84ff'
|
||||
/** The other offers: unmistakably blue, unmistakably secondary. */
|
||||
export const ALT_SECONDARY = '#7eb8f0'
|
||||
/** Under both, so either reads on a pale map, a dark one or a satellite tile. */
|
||||
export const ALT_CASING = '#ffffff'
|
||||
|
||||
/**
|
||||
* The label colours, again after Apple: the chosen route's time is a filled blue pill,
|
||||
* the alternatives' are near-black. Both carry white text.
|
||||
*/
|
||||
export const ALT_LABEL_PRIMARY_BG = '#0a84ff'
|
||||
export const ALT_LABEL_SECONDARY_BG = '#2b2f36'
|
||||
export const ALT_LABEL_TEXT = '#ffffff'
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildAlternativeOverlays } from './alternativeOverlays'
|
||||
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
|
||||
import type { RouteAlternative } from '../Map/RouteCalculator'
|
||||
|
||||
/**
|
||||
* FE-ALTOVL-001..010 — turning the router's answers into something drawable.
|
||||
*
|
||||
* Two decisions live here and neither is cosmetic: which of the offered roads is
|
||||
* drawn as the one you are on, and where each label hangs. A label anchored on a
|
||||
* stretch two routes share appears pinned to the wrong road, which is the whole
|
||||
* reason this is not simply the midpoint.
|
||||
*/
|
||||
|
||||
const LABELS = { fastest: 'Fastest', current: 'Current', noMotorway: 'No motorway', noToll: 'No tolls' }
|
||||
|
||||
/** A straight line of `n` points from (0,0) heading east, offset north by `lat`. */
|
||||
function line(n: number, lat: number): [number, number][] {
|
||||
return Array.from({ length: n }, (_, i) => [lat, i / 10] as [number, number])
|
||||
}
|
||||
|
||||
function alt(over: Partial<RouteAlternative & { current?: boolean }> = {}): RouteAlternative & { current?: boolean } {
|
||||
return {
|
||||
coordinates: line(20, 0),
|
||||
duration: 3600,
|
||||
distance: 100_000,
|
||||
...over,
|
||||
} as RouteAlternative & { current?: boolean }
|
||||
}
|
||||
|
||||
describe('buildAlternativeOverlays', () => {
|
||||
it('FE-ALTOVL-001: nothing to choose between is not a choice', () => {
|
||||
// One answer drawn as an "alternative" would just double the route already
|
||||
// on the map.
|
||||
expect(buildAlternativeOverlays(undefined, LABELS)).toEqual([])
|
||||
expect(buildAlternativeOverlays([], LABELS)).toEqual([])
|
||||
expect(buildAlternativeOverlays([alt()], LABELS)).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-002: the quickest is the blue one when no road is being driven yet', () => {
|
||||
const out = buildAlternativeOverlays(
|
||||
[alt({ duration: 5400, coordinates: line(20, 0) }), alt({ duration: 3600, coordinates: line(20, 1) })],
|
||||
LABELS,
|
||||
)
|
||||
expect(out[1].color).toBe(ALT_PRIMARY)
|
||||
expect(out[1].labelBg).toBe(ALT_LABEL_PRIMARY_BG)
|
||||
expect(out[0].color).toBe(ALT_SECONDARY)
|
||||
expect(out[0].labelBg).toBe(ALT_LABEL_SECONDARY_BG)
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-003: the road already being driven takes the blue, even when it is slower', () => {
|
||||
// Blue means "the road you are on". Handing it to the quickest offer instead
|
||||
// would recolour the drive the traveller deliberately bent.
|
||||
const out = buildAlternativeOverlays(
|
||||
[alt({ duration: 7200, current: true, coordinates: line(20, 0) }), alt({ duration: 3600, coordinates: line(20, 1) })],
|
||||
LABELS,
|
||||
)
|
||||
expect(out[0].color).toBe(ALT_PRIMARY)
|
||||
expect(out[0].note).toBe('Current')
|
||||
expect(out[1].color).toBe(ALT_SECONDARY)
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-004: what a road IS beats what it is not', () => {
|
||||
// A way offered because the motorway was left out says so, rather than being
|
||||
// labelled by how much slower it is — being off the motorway is the reason
|
||||
// somebody would take it.
|
||||
const out = buildAlternativeOverlays(
|
||||
[
|
||||
alt({ duration: 3600, coordinates: line(20, 0) }),
|
||||
alt({ duration: 5400, avoids: 'motorway', coordinates: line(20, 1) }),
|
||||
alt({ duration: 5400, avoids: 'toll', coordinates: line(20, 2) }),
|
||||
],
|
||||
LABELS,
|
||||
)
|
||||
expect(out.map(o => o.note)).toEqual(['Fastest', 'No motorway', 'No tolls'])
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-005: a plain slower offer carries no note at all', () => {
|
||||
const out = buildAlternativeOverlays(
|
||||
[alt({ duration: 3600, coordinates: line(20, 0) }), alt({ duration: 5400, coordinates: line(20, 1) })],
|
||||
LABELS,
|
||||
)
|
||||
expect(out[1].note).toBe('')
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-006: how much slower is measured against the quickest, and never negative', () => {
|
||||
const out = buildAlternativeOverlays(
|
||||
[alt({ duration: 5400, coordinates: line(20, 0) }), alt({ duration: 3600, coordinates: line(20, 1) })],
|
||||
LABELS,
|
||||
)
|
||||
expect(out[0].slowerThanQuickest).toBe(1800)
|
||||
expect(out[1].slowerThanQuickest).toBe(0)
|
||||
expect(out.every(o => o.slowerThanQuickest >= 0)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-007: a label hangs where its own road is unlike every other', () => {
|
||||
// Two roads that share both ends and part in the middle. The midpoint of the
|
||||
// shared stretch would put both labels on the same line; the point furthest
|
||||
// from the other route puts each on the stretch only it uses.
|
||||
const shared = (lat: number): [number, number][] => [
|
||||
[0, 0], [0, 1], [lat, 2], [lat, 3], [0, 4], [0, 5],
|
||||
]
|
||||
const out = buildAlternativeOverlays(
|
||||
[alt({ coordinates: shared(0.5) }), alt({ coordinates: shared(-0.5) })],
|
||||
LABELS,
|
||||
)
|
||||
// Each label sits on its own detour, not on the common ends.
|
||||
expect(Math.abs(out[0].at.lat)).toBeCloseTo(0.5, 5)
|
||||
expect(Math.abs(out[1].at.lat)).toBeCloseTo(0.5, 5)
|
||||
expect(out[0].at.lat).not.toBe(out[1].at.lat)
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-008: a route with no coordinates gets a label anchor rather than crashing', () => {
|
||||
const out = buildAlternativeOverlays(
|
||||
[alt({ coordinates: [] }), alt({ coordinates: line(20, 1) })],
|
||||
LABELS,
|
||||
)
|
||||
expect(out).toHaveLength(2)
|
||||
expect(out[0].at).toEqual({ lat: 0, lng: 0 })
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-009: the drive time is carried through as text and as seconds', () => {
|
||||
const out = buildAlternativeOverlays(
|
||||
[alt({ duration: 3600, distance: 42_000 }), alt({ duration: 5400, coordinates: line(20, 1) })],
|
||||
LABELS,
|
||||
)
|
||||
expect(out[0].duration).toBe(3600)
|
||||
expect(out[0].distance).toBe(42_000)
|
||||
// The label is the readable form of the same number.
|
||||
expect(out[0].label).toBeTruthy()
|
||||
expect(out[0].index).toBe(0)
|
||||
expect(out[1].index).toBe(1)
|
||||
})
|
||||
|
||||
it('FE-ALTOVL-010: only one road is ever the blue one', () => {
|
||||
const out = buildAlternativeOverlays(
|
||||
[
|
||||
alt({ duration: 3600, coordinates: line(20, 0) }),
|
||||
alt({ duration: 3600, coordinates: line(20, 1) }),
|
||||
alt({ duration: 3600, coordinates: line(20, 2) }),
|
||||
],
|
||||
LABELS,
|
||||
)
|
||||
expect(out.filter(o => o.color === ALT_PRIMARY)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { formatDurationShort } from './roadtripModel'
|
||||
import { ALT_PRIMARY, ALT_SECONDARY, ALT_LABEL_PRIMARY_BG, ALT_LABEL_SECONDARY_BG } from './alternativeColors'
|
||||
import type { RouteAlternative } from '../Map/RouteCalculator'
|
||||
|
||||
/** One offered route, ready to draw: the line, its colour, and where its label sits. */
|
||||
export interface AlternativeOverlay {
|
||||
index: number
|
||||
coordinates: [number, number][]
|
||||
color: string
|
||||
/** The drive time, the way Apple Maps puts it on the road itself. */
|
||||
label: string
|
||||
/** The second line under it — "Fastest", "Current" — or empty for the plain ones. */
|
||||
note: string
|
||||
/** How long this way takes and how far it is, for the list beside the map. */
|
||||
duration: number
|
||||
distance: number
|
||||
/** Seconds more than the quickest offer; negative never happens by construction. */
|
||||
slowerThanQuickest: number
|
||||
labelBg: string
|
||||
/** Where to hang the label — a point on this route and on no other. */
|
||||
at: { lat: number; lng: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Squared distance between two points, in degrees, with longitude scaled by latitude.
|
||||
*
|
||||
* Only ever compared against other values from this same function, so degrees are fine
|
||||
* and a haversine would cost more for an answer nobody reads.
|
||||
*/
|
||||
function roughDist(a: [number, number], b: [number, number]): number {
|
||||
const dLat = a[0] - b[0]
|
||||
const dLng = (a[1] - b[1]) * Math.cos((a[0] * Math.PI) / 180)
|
||||
return dLat * dLat + dLng * dLng
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of `line` that lies furthest from every one of `others`.
|
||||
*
|
||||
* This is where a route is unmistakably itself. Anchoring a label at the geometric
|
||||
* midpoint instead put it wherever that happened to fall — and on a pair of routes that
|
||||
* split early and rejoin, the midpoint sits on the shared stretch, so the label appeared
|
||||
* to be pinned to the wrong road.
|
||||
*
|
||||
* Sampled rather than exhaustive: alternatives differ over kilometres, and comparing two
|
||||
* thousand-point lines in full costs milliseconds for a pixel of accuracy.
|
||||
*/
|
||||
function mostDistinctPoint(
|
||||
line: [number, number][],
|
||||
others: [number, number][][],
|
||||
): { lat: number; lng: number } | null {
|
||||
if (!line.length) return null
|
||||
if (!others.length) {
|
||||
const mid = line[Math.floor(line.length / 2)]
|
||||
return mid ? { lat: mid[0], lng: mid[1] } : null
|
||||
}
|
||||
const sample = (l: [number, number][]) => Math.max(1, Math.floor(l.length / 150))
|
||||
let best: [number, number] | null = null
|
||||
let bestScore = -1
|
||||
for (let i = 0; i < line.length; i += sample(line)) {
|
||||
// How far this point is from the NEAREST other route: a point close to any of them
|
||||
// is not distinctive, however far it sits from the rest.
|
||||
let nearestOfAll = Infinity
|
||||
for (const other of others) {
|
||||
let nearest = Infinity
|
||||
const step = sample(other)
|
||||
for (let j = 0; j < other.length; j += step) {
|
||||
const d = roughDist(line[i], other[j])
|
||||
if (d < nearest) nearest = d
|
||||
}
|
||||
if (nearest < nearestOfAll) nearestOfAll = nearest
|
||||
}
|
||||
if (nearestOfAll > bestScore) { bestScore = nearestOfAll; best = line[i] }
|
||||
}
|
||||
return best ? { lat: best[0], lng: best[1] } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The routes as the map should draw them, built once so every renderer agrees.
|
||||
*
|
||||
* Each label hangs where its own route is furthest from all the others, so a label always
|
||||
* sits on a stretch only that route uses. Two routes that share their first and last
|
||||
* thirds still get their labels on the middle third, where they actually differ.
|
||||
*/
|
||||
export function buildAlternativeOverlays(
|
||||
routes: (RouteAlternative & { current?: boolean; direct?: boolean })[] | undefined,
|
||||
labels: { fastest: string; current: string; noMotorway: string; noToll: string },
|
||||
): AlternativeOverlay[] {
|
||||
if (!routes?.length) return []
|
||||
// A single answer is not a choice; drawing it would just double the route already there.
|
||||
if (routes.length < 2) return []
|
||||
|
||||
// The quickest of what came back, which is what Apple calls out — not necessarily the
|
||||
// first entry, since the road currently driven is put at the top when there is one.
|
||||
const quickest = routes.reduce((best, r, i) => (r.duration < routes[best].duration ? i : best), 0)
|
||||
const anyCurrent = routes.some(r => r.current)
|
||||
|
||||
return routes.map((route, index) => {
|
||||
const others = routes.filter((_, i) => i !== index).map(r => r.coordinates)
|
||||
const at = mostDistinctPoint(route.coordinates, others)
|
||||
// Blue is the road you are on: the one currently driven, or the router's own pick
|
||||
// when nothing has been bent. Everything else is the pale blue of an offer.
|
||||
const primary = route.current || (!anyCurrent && index === quickest)
|
||||
return {
|
||||
index,
|
||||
coordinates: route.coordinates,
|
||||
color: primary ? ALT_PRIMARY : ALT_SECONDARY,
|
||||
label: formatDurationShort(route.duration),
|
||||
// What this way IS beats what it is not: a road offered because the motorway was
|
||||
// left out of it says so, rather than being labelled by how much slower it is —
|
||||
// that is the reason somebody would take it.
|
||||
note: route.current
|
||||
? labels.current
|
||||
: route.avoids === 'motorway'
|
||||
? labels.noMotorway
|
||||
: route.avoids === 'toll'
|
||||
? labels.noToll
|
||||
: index === quickest ? labels.fastest : '',
|
||||
duration: route.duration,
|
||||
distance: route.distance,
|
||||
slowerThanQuickest: Math.max(0, Math.round(route.duration - routes[quickest].duration)),
|
||||
labelBg: primary ? ALT_LABEL_PRIMARY_BG : ALT_LABEL_SECONDARY_BG,
|
||||
at: at ?? { lat: 0, lng: 0 },
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
corridorTiles,
|
||||
distanceToSegmentKm,
|
||||
haversineKm,
|
||||
projectOntoRoute,
|
||||
simplifyLine,
|
||||
type LatLng,
|
||||
} from './corridor'
|
||||
|
||||
const BERLIN: LatLng = { lat: 52.52, lng: 13.405 }
|
||||
const DRESDEN: LatLng = { lat: 51.05, lng: 13.74 }
|
||||
const PRAGUE: LatLng = { lat: 50.088, lng: 14.42 }
|
||||
|
||||
describe('haversineKm', () => {
|
||||
it('measures a known distance', () => {
|
||||
// Berlin → Dresden is about 165 km as the crow flies.
|
||||
expect(haversineKm(BERLIN, DRESDEN)).toBeGreaterThan(160)
|
||||
expect(haversineKm(BERLIN, DRESDEN)).toBeLessThan(170)
|
||||
})
|
||||
|
||||
it('is zero for the same point', () => {
|
||||
expect(haversineKm(BERLIN, BERLIN)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('distanceToSegmentKm', () => {
|
||||
const a: LatLng = { lat: 52, lng: 13 }
|
||||
const b: LatLng = { lat: 52, lng: 14 }
|
||||
|
||||
it('measures perpendicular distance to the line', () => {
|
||||
const p: LatLng = { lat: 52.09, lng: 13.5 }
|
||||
// 0.09° of latitude is about 10 km, whatever the longitude does.
|
||||
expect(distanceToSegmentKm(p, a, b)).toBeGreaterThan(9)
|
||||
expect(distanceToSegmentKm(p, a, b)).toBeLessThan(11)
|
||||
})
|
||||
|
||||
it('measures to the end, not to the infinite line, for a point beyond it', () => {
|
||||
const beyond: LatLng = { lat: 52, lng: 16 }
|
||||
const d = distanceToSegmentKm(beyond, a, b)
|
||||
// Two degrees of longitude past the end at 52° north — roughly 137 km.
|
||||
expect(d).toBeGreaterThan(120)
|
||||
expect(d).toBeLessThan(150)
|
||||
})
|
||||
|
||||
it('falls back to point distance for a zero-length segment', () => {
|
||||
expect(distanceToSegmentKm(DRESDEN, a, a)).toBeCloseTo(haversineKm(DRESDEN, a), 5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectOntoRoute', () => {
|
||||
const line = [BERLIN, DRESDEN, PRAGUE]
|
||||
|
||||
it('reports how far off the route a point is', () => {
|
||||
const onRoute = projectOntoRoute({ lat: 51.8, lng: 13.5 }, line)
|
||||
expect(onRoute!.offRouteKm).toBeLessThan(15)
|
||||
const wayOff = projectOntoRoute({ lat: 53.55, lng: 10.0 }, line)
|
||||
expect(wayOff!.offRouteKm).toBeGreaterThan(150)
|
||||
})
|
||||
|
||||
it('orders points by how far into the drive they come', () => {
|
||||
const early = projectOntoRoute({ lat: 52.3, lng: 13.45 }, line)!
|
||||
const late = projectOntoRoute({ lat: 50.4, lng: 14.2 }, line)!
|
||||
expect(early.alongKm).toBeLessThan(late.alongKm)
|
||||
})
|
||||
|
||||
it('tells two points on the same long straight apart', () => {
|
||||
// The petrol stations along one motorway segment must not all report the same
|
||||
// position in the drive, which is what taking the segment midpoint produced.
|
||||
const straight = [{ lat: 52, lng: 13 }, { lat: 52, lng: 15 }]
|
||||
const early = projectOntoRoute({ lat: 52.01, lng: 13.3 }, straight)!
|
||||
const late = projectOntoRoute({ lat: 52.01, lng: 14.7 }, straight)!
|
||||
expect(late.alongKm - early.alongKm).toBeGreaterThan(80)
|
||||
})
|
||||
|
||||
it('returns nothing for a line that is not one', () => {
|
||||
expect(projectOntoRoute(BERLIN, [])).toBeNull()
|
||||
expect(projectOntoRoute(BERLIN, [BERLIN])).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('corridorTiles', () => {
|
||||
it('covers a short route with a single box', () => {
|
||||
const tiles = corridorTiles([BERLIN, { lat: 52.4, lng: 13.5 }], 5)
|
||||
expect(tiles).toHaveLength(1)
|
||||
expect(tiles[0].south).toBeLessThan(52.4)
|
||||
expect(tiles[0].north).toBeGreaterThan(52.52)
|
||||
})
|
||||
|
||||
it('splits a long route into boxes the server will not clamp', () => {
|
||||
const tiles = corridorTiles([BERLIN, DRESDEN, PRAGUE], 10)
|
||||
expect(tiles.length).toBeGreaterThan(1)
|
||||
for (const t of tiles) {
|
||||
expect(t.north - t.south).toBeLessThanOrEqual(0.45)
|
||||
expect(t.east - t.west).toBeLessThanOrEqual(0.45)
|
||||
}
|
||||
})
|
||||
|
||||
it('pads longitude more the further north it goes', () => {
|
||||
const south = corridorTiles([{ lat: 0, lng: 0 }], 10)[0]
|
||||
const north = corridorTiles([{ lat: 60, lng: 0 }], 10)[0]
|
||||
expect(north.east - north.west).toBeGreaterThan(south.east - south.west)
|
||||
})
|
||||
|
||||
it('covers the middle of a long straight leg, not only its two ends', () => {
|
||||
// A motorway with no bend in it reduces to two points, 250 km apart. Tiling walks
|
||||
// point to point, so without splitting the step everything between them goes
|
||||
// unsearched while the search still reports itself complete.
|
||||
const straight = [{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }]
|
||||
const tiles = corridorTiles(straight, 10)
|
||||
|
||||
expect(tiles.length).toBeGreaterThan(4)
|
||||
// A box somewhere over the middle of the drive, which is what was missing.
|
||||
const midLat = 53.03
|
||||
const midLng = 11.7
|
||||
expect(tiles.some(t => t.south <= midLat && t.north >= midLat && t.west <= midLng && t.east >= midLng)).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves no gap between consecutive boxes along a route', () => {
|
||||
const tiles = corridorTiles([{ lat: 53.55, lng: 9.99 }, { lat: 52.52, lng: 13.4 }], 10)
|
||||
for (let i = 1; i < tiles.length; i++) {
|
||||
const a = tiles[i - 1]
|
||||
const b = tiles[i]
|
||||
// Overlapping or touching in both axes: a hole here is a stretch nobody looks at.
|
||||
expect(Math.min(a.north, b.north) - Math.max(a.south, b.south)).toBeGreaterThanOrEqual(0)
|
||||
expect(Math.min(a.east, b.east) - Math.max(a.west, b.west)).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('has nothing to cover for an empty route', () => {
|
||||
expect(corridorTiles([], 10)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('simplifyLine', () => {
|
||||
it('keeps the ends and drops what sits on the line between them', () => {
|
||||
const straight = [
|
||||
{ lat: 52, lng: 13 },
|
||||
{ lat: 52, lng: 13.5 },
|
||||
{ lat: 52, lng: 14 },
|
||||
]
|
||||
expect(simplifyLine(straight, 1)).toEqual([straight[0], straight[2]])
|
||||
})
|
||||
|
||||
it('keeps a point that actually bends the route', () => {
|
||||
const bent = [
|
||||
{ lat: 52, lng: 13 },
|
||||
{ lat: 52.5, lng: 13.5 },
|
||||
{ lat: 52, lng: 14 },
|
||||
]
|
||||
expect(simplifyLine(bent, 1)).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('leaves a two-point line alone', () => {
|
||||
expect(simplifyLine([BERLIN, DRESDEN], 5)).toEqual([BERLIN, DRESDEN])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Corridor geometry — "what lies along this drive", without asking Overpass to think
|
||||
* about a polyline.
|
||||
*
|
||||
* The naive form (`around:` with a hundred coordinates) is what every POI provider warns
|
||||
* about and times out on. OsmAnd solves it the other way round and this follows it: ask
|
||||
* for plain bounding boxes that cover the route, then measure each candidate's
|
||||
* perpendicular distance to the line and throw away what is too far. The boxes are what
|
||||
* the existing /api/maps/pois route already accepts, so no new server surface is needed.
|
||||
*
|
||||
* Everything here is pure: no React, no network, no store.
|
||||
*/
|
||||
|
||||
export interface LatLng {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
export interface Bbox {
|
||||
south: number
|
||||
west: number
|
||||
north: number
|
||||
east: number
|
||||
}
|
||||
|
||||
const EARTH_RADIUS_KM = 6371
|
||||
const KM_PER_DEG_LAT = 111.32
|
||||
|
||||
const toRad = (deg: number): number => (deg * Math.PI) / 180
|
||||
|
||||
/** Great-circle distance in kilometres. */
|
||||
export function haversineKm(a: LatLng, b: LatLng): number {
|
||||
const dLat = toRad(b.lat - a.lat)
|
||||
const dLng = toRad(b.lng - a.lng)
|
||||
const lat1 = toRad(a.lat)
|
||||
const lat2 = toRad(b.lat)
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 + Math.sin(dLng / 2) ** 2 * Math.cos(lat1) * Math.cos(lat2)
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Perpendicular distance from a point to a line segment, in kilometres.
|
||||
*
|
||||
* Works in a local equirectangular projection around the segment: longitudes shrink by
|
||||
* cos(lat), which is the whole reason a uniform degree padding is wrong at 60° north.
|
||||
* Over the few kilometres a corridor spans, the error against a proper geodesic is far
|
||||
* below the width the user picked.
|
||||
*/
|
||||
export function distanceToSegmentKm(p: LatLng, a: LatLng, b: LatLng): number {
|
||||
return projectOnSegment(p, a, b).distanceKm
|
||||
}
|
||||
|
||||
/**
|
||||
* Perpendicular distance to a segment plus where along it the foot of that perpendicular
|
||||
* falls (0 at `a`, 1 at `b`). The second number is what places a candidate correctly in
|
||||
* the drive — taking the segment's midpoint instead makes every petrol station on a long
|
||||
* straight report the same position.
|
||||
*/
|
||||
/** A segment shorter than a millimetre, squared — below this there is nothing to project onto. */
|
||||
const DEGENERATE_SEGMENT_KM2 = 1e-12
|
||||
|
||||
export function projectOnSegment(p: LatLng, a: LatLng, b: LatLng): { distanceKm: number; t: number } {
|
||||
const latScale = KM_PER_DEG_LAT
|
||||
const lngScale = KM_PER_DEG_LAT * Math.cos(toRad((a.lat + b.lat) / 2))
|
||||
const ax = a.lng * lngScale
|
||||
const ay = a.lat * latScale
|
||||
const bx = b.lng * lngScale
|
||||
const by = b.lat * latScale
|
||||
const px = p.lng * lngScale
|
||||
const py = p.lat * latScale
|
||||
const dx = bx - ax
|
||||
const dy = by - ay
|
||||
const lenSq = dx * dx + dy * dy
|
||||
// A segment whose two ends are the same point to within a millimetre has no
|
||||
// direction to project onto, so measure straight to it. Written as a range because
|
||||
// the ends came through a chain of floating point arithmetic and never land on 0.
|
||||
if (lenSq < DEGENERATE_SEGMENT_KM2) return { distanceKm: haversineKm(p, a), t: 0 }
|
||||
// Clamped so a point beside the segment's end measures to the end, not to the
|
||||
// infinite line — otherwise a stop 200 km past the destination looks "on the way".
|
||||
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq))
|
||||
const cx = ax + t * dx
|
||||
const cy = ay + t * dy
|
||||
return { distanceKm: Math.hypot(px - cx, py - cy), t }
|
||||
}
|
||||
|
||||
export interface CorridorHit {
|
||||
/** Kilometres from the route line at its closest point. */
|
||||
offRouteKm: number
|
||||
/** Kilometres travelled along the route before reaching that closest point. */
|
||||
alongKm: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a point sits relative to the route: how far off it is, and how far into the
|
||||
* drive it comes. The second number is what puts "lunch" before "the hotel" in a list
|
||||
* instead of sorting by name.
|
||||
*/
|
||||
export function projectOntoRoute(p: LatLng, line: LatLng[]): CorridorHit | null {
|
||||
if (line.length < 2) return null
|
||||
let best = Number.POSITIVE_INFINITY
|
||||
let bestAlong = 0
|
||||
let travelled = 0
|
||||
for (let i = 0; i < line.length - 1; i++) {
|
||||
const a = line[i]
|
||||
const b = line[i + 1]
|
||||
const segment = haversineKm(a, b)
|
||||
const { distanceKm: d, t } = projectOnSegment(p, a, b)
|
||||
if (d < best) {
|
||||
best = d
|
||||
bestAlong = travelled + t * segment
|
||||
}
|
||||
travelled += segment
|
||||
}
|
||||
return { offRouteKm: best, alongKm: bestAlong }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounding boxes covering the route, each no larger than `maxSpanDeg`.
|
||||
*
|
||||
* The server clamps any box bigger than that (MAX_BBOX_SPAN_DEG) so Overpass is not
|
||||
* asked to scan a continent, which means a long drive has to be cut into boxes here
|
||||
* rather than sent as one. Boxes are grown along the route until one would exceed the
|
||||
* span, so a straight motorway needs far fewer requests than a mountain road.
|
||||
*/
|
||||
/**
|
||||
* Splits a step that is longer than a box into even pieces.
|
||||
*
|
||||
* The simplifier is right to reduce a dead straight motorway stretch to its two ends,
|
||||
* but tiling walks point to point: two ends 100 km apart produce a box at each end and
|
||||
* nothing in between, so the middle of the drive would never be searched.
|
||||
*/
|
||||
function densify(line: LatLng[], maxStepDeg: number): LatLng[] {
|
||||
if (line.length < 2) return line
|
||||
const out: LatLng[] = [line[0]]
|
||||
for (let i = 1; i < line.length; i++) {
|
||||
const a = line[i - 1]
|
||||
const b = line[i]
|
||||
const span = Math.max(Math.abs(b.lat - a.lat), Math.abs(b.lng - a.lng))
|
||||
const steps = Math.max(1, Math.ceil(span / maxStepDeg))
|
||||
for (let s = 1; s <= steps; s++) {
|
||||
out.push({
|
||||
lat: a.lat + ((b.lat - a.lat) * s) / steps,
|
||||
lng: a.lng + ((b.lng - a.lng) * s) / steps,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function corridorTiles(line: LatLng[], widthKm: number, maxSpanDeg = 0.45): Bbox[] {
|
||||
if (line.length === 0) return []
|
||||
const padLat = widthKm / KM_PER_DEG_LAT
|
||||
const tiles: Bbox[] = []
|
||||
let current: Bbox | null = null
|
||||
|
||||
const padded = (b: Bbox): Bbox => {
|
||||
// Longitude degrees shrink towards the poles, so the padding has to grow with
|
||||
// latitude or the corridor is narrower than asked for in the north.
|
||||
const midLat = (b.north + b.south) / 2
|
||||
const padLng = padLat / Math.max(0.2, Math.cos(toRad(midLat)))
|
||||
return {
|
||||
south: b.south - padLat,
|
||||
north: b.north + padLat,
|
||||
west: b.west - padLng,
|
||||
east: b.east + padLng,
|
||||
}
|
||||
}
|
||||
|
||||
// The point the open box last swallowed. When a box closes, the next one starts
|
||||
// from it rather than from `p` alone, so the two overlap by exactly one step of
|
||||
// the walk. Starting at `p` left the stretch between them covered only by the
|
||||
// padding on either side — and the walk steps in up to maxSpanDeg/2 while the
|
||||
// padding is widthKm, so at the offered widths a band of several kilometres of
|
||||
// route fell into no box at all. Nothing downstream noticed: the panel counts
|
||||
// tiles asked and tiles answered, so it reported a finished, uncapped,
|
||||
// error-free search of a drive it had never fully looked at. "Find fuel along
|
||||
// the route" is the whole feature.
|
||||
let previous: LatLng | null = null
|
||||
|
||||
for (const p of densify(line, maxSpanDeg / 2)) {
|
||||
if (!current) {
|
||||
current = { south: p.lat, north: p.lat, west: p.lng, east: p.lng }
|
||||
previous = p
|
||||
continue
|
||||
}
|
||||
const next: Bbox = {
|
||||
south: Math.min(current.south, p.lat),
|
||||
north: Math.max(current.north, p.lat),
|
||||
west: Math.min(current.west, p.lng),
|
||||
east: Math.max(current.east, p.lng),
|
||||
}
|
||||
const grown = padded(next)
|
||||
if (grown.north - grown.south > maxSpanDeg || grown.east - grown.west > maxSpanDeg) {
|
||||
tiles.push(padded(current))
|
||||
const from = previous ?? p
|
||||
current = {
|
||||
south: Math.min(from.lat, p.lat),
|
||||
north: Math.max(from.lat, p.lat),
|
||||
west: Math.min(from.lng, p.lng),
|
||||
east: Math.max(from.lng, p.lng),
|
||||
}
|
||||
} else {
|
||||
current = next
|
||||
}
|
||||
previous = p
|
||||
}
|
||||
if (current) tiles.push(padded(current))
|
||||
return tiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Thins a route down to the points that still describe it, so the corridor is built
|
||||
* from tens of coordinates rather than the thousands a routed geometry carries.
|
||||
* Ramer-Douglas-Peucker with the tolerance expressed in kilometres.
|
||||
*/
|
||||
export function simplifyLine(line: LatLng[], toleranceKm: number): LatLng[] {
|
||||
if (line.length < 3) return line.slice()
|
||||
let maxDist = 0
|
||||
let index = 0
|
||||
const first = line[0]
|
||||
const last = line[line.length - 1]
|
||||
for (let i = 1; i < line.length - 1; i++) {
|
||||
const d = distanceToSegmentKm(line[i], first, last)
|
||||
if (d > maxDist) {
|
||||
maxDist = d
|
||||
index = i
|
||||
}
|
||||
}
|
||||
if (maxDist <= toleranceKm) return [first, last]
|
||||
const left = simplifyLine(line.slice(0, index + 1), toleranceKm)
|
||||
const right = simplifyLine(line.slice(index), toleranceKm)
|
||||
return [...left.slice(0, -1), ...right]
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { haversineKm, type LatLng } from './corridor'
|
||||
import {
|
||||
parseTrack, lineLengthKm, thin, nearestIndex, orient, stopIndices, trackGapKm, planTrack,
|
||||
} from './followTrack'
|
||||
|
||||
/**
|
||||
* The refinement is the interesting part, and it is testable without a router: what it
|
||||
* needs is something that answers "the road I would drive through these waypoints", and
|
||||
* the straight line between them is exactly the answer that makes the loop work for its
|
||||
* living. A track that bulges away from that chord is the shape every scenic route has.
|
||||
*/
|
||||
|
||||
/** A track arching north away from the straight line between its ends. */
|
||||
function arc(points = 120, bulgeDeg = 0.1): LatLng[] {
|
||||
return Array.from({ length: points + 1 }, (_, i) => ({
|
||||
lat: 52 + bulgeDeg * Math.sin((Math.PI * i) / points),
|
||||
lng: 13 + i / points,
|
||||
}))
|
||||
}
|
||||
|
||||
const START: LatLng = { lat: 52, lng: 13 }
|
||||
const END: LatLng = { lat: 52, lng: 14 }
|
||||
|
||||
/** A router with no idea about roads: it drives straight from waypoint to waypoint. */
|
||||
function straightRouter(): { route: (w: LatLng[]) => Promise<LatLng[]>; calls: LatLng[][] } {
|
||||
const calls: LatLng[][] = []
|
||||
return {
|
||||
calls,
|
||||
route: async (waypoints: LatLng[]) => {
|
||||
calls.push(waypoints)
|
||||
return waypoints
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseTrack', () => {
|
||||
it('FE-FOLLOWTRACK-001: reads pairs and triples, and steps over what is neither', () => {
|
||||
const geometry = JSON.stringify([[52, 13], [52.1, 13.1, 340], ['x', 1], [1], null, [53, 14]])
|
||||
expect(parseTrack(geometry)).toEqual([
|
||||
{ lat: 52, lng: 13 },
|
||||
{ lat: 52.1, lng: 13.1 },
|
||||
{ lat: 53, lng: 14 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-002: a track that is not a track is nothing, never a throw', () => {
|
||||
// Somebody else's column, filled by an importer that has seen every malformed GPX
|
||||
// there is. A day that cannot follow a track is a day; an exception here is a page.
|
||||
expect(parseTrack('not json')).toEqual([])
|
||||
expect(parseTrack('{"type":"LineString"}')).toEqual([])
|
||||
expect(parseTrack(null)).toEqual([])
|
||||
expect(parseTrack(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('thinning and measuring', () => {
|
||||
it('FE-FOLLOWTRACK-003: thinning keeps both ends, whatever the stride', () => {
|
||||
const line = arc(1000)
|
||||
const thinned = thin(line, 400)
|
||||
expect(thinned.length).toBeLessThanOrEqual(401)
|
||||
expect(thinned[0]).toEqual(line[0])
|
||||
expect(thinned[thinned.length - 1]).toEqual(line[line.length - 1])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-004: a line shorter than the cap comes back untouched', () => {
|
||||
const line = arc(10)
|
||||
expect(thin(line, 400)).toBe(line)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-005: an arc is longer than the chord it spans', () => {
|
||||
expect(lineLengthKm(arc())).toBeGreaterThan(haversineKm(START, END))
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-006: the nearest point of a track is found by position, not by order', () => {
|
||||
const line = arc(20)
|
||||
expect(nearestIndex(line, line[7])).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orientation', () => {
|
||||
it('FE-FOLLOWTRACK-007: a track recorded the other way round is turned', () => {
|
||||
// Half of Norway's scenic routes are published southbound. Without this the vias come
|
||||
// out in reverse order and the day drives the loop backwards.
|
||||
const backwards = [...arc(20)].reverse()
|
||||
const turned = orient(backwards, [START, END])
|
||||
expect(turned[0].lng).toBeLessThan(turned[turned.length - 1].lng)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-008: a track already pointing the right way is left alone', () => {
|
||||
const line = arc(20)
|
||||
expect(orient(line, [START, END])).toBe(line)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-009: stops never walk backwards along the track', () => {
|
||||
// A track that doubles back has two nearest points for the same stop, and the wrong
|
||||
// one would put a via on the leg before it.
|
||||
const line = arc(40)
|
||||
const stops = [START, { lat: 52.05, lng: 13.5 }, END]
|
||||
const idx = stopIndices(line, stops)
|
||||
expect(idx[0]).toBeLessThanOrEqual(idx[1])
|
||||
expect(idx[1]).toBeLessThanOrEqual(idx[2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('planTrack', () => {
|
||||
it('FE-FOLLOWTRACK-010: a road that already follows the track needs no vias', async () => {
|
||||
const track = arc()
|
||||
const plan = await planTrack({ track, stops: [START, END], route: async () => track })
|
||||
expect(plan.vias).toEqual([])
|
||||
expect(plan.rounds).toBe(1)
|
||||
expect(plan.capped).toBe(false)
|
||||
// Still names the leg it runs along, so applying it clears what an earlier pass left.
|
||||
expect(plan.legs).toEqual([0])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-011: it refines until the road is inside the tolerance', async () => {
|
||||
const { route, calls } = straightRouter()
|
||||
const plan = await planTrack({ track: arc(), stops: [START, END], route, options: { toleranceKm: 1 } })
|
||||
|
||||
expect(plan.capped).toBe(false)
|
||||
expect(plan.strayKm).toBeLessThanOrEqual(1)
|
||||
expect(plan.vias.length).toBeGreaterThan(0)
|
||||
// One route per round, and the waypoint list grows by exactly the via just added.
|
||||
expect(calls.length).toBe(plan.rounds)
|
||||
expect(calls[calls.length - 1].length).toBe(2 + plan.vias.length)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-012: every via lands on the track, in driving order', async () => {
|
||||
const { route } = straightRouter()
|
||||
const track = arc()
|
||||
const plan = await planTrack({ track, stops: [START, END], route, options: { toleranceKm: 1 } })
|
||||
|
||||
for (const via of plan.vias) {
|
||||
expect(track.some(p => haversineKm(p, via) < 0.001)).toBe(true)
|
||||
}
|
||||
const longitudes = plan.vias.map(v => v.lng)
|
||||
expect([...longitudes].sort((a, b) => a - b)).toEqual(longitudes)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-013: no via is laid down on top of a stop', async () => {
|
||||
// A via a hundred metres from a stop costs a waypoint to tell the router what the
|
||||
// stop already told it.
|
||||
const { route } = straightRouter()
|
||||
const plan = await planTrack({ track: arc(), stops: [START, END], route, options: { toleranceKm: 0.2, maxVias: 8 } })
|
||||
for (const via of plan.vias) {
|
||||
expect(haversineKm(via, START)).toBeGreaterThanOrEqual(0.6)
|
||||
expect(haversineKm(via, END)).toBeGreaterThanOrEqual(0.6)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-014: a via hangs behind the stop whose leg it shapes', async () => {
|
||||
const { route } = straightRouter()
|
||||
// Three stops, and the bulge is entirely on the second leg.
|
||||
const straight: LatLng[] = Array.from({ length: 40 }, (_, i) => ({ lat: 52, lng: 13 + i / 78 }))
|
||||
const bulge: LatLng[] = Array.from({ length: 81 }, (_, i) => ({
|
||||
lat: 52 + 0.1 * Math.sin((Math.PI * i) / 80),
|
||||
lng: 13.5 + i / 160,
|
||||
}))
|
||||
const middle: LatLng = { lat: 52, lng: 13.5 }
|
||||
const plan = await planTrack({
|
||||
track: [...straight, ...bulge],
|
||||
stops: [START, middle, END],
|
||||
route,
|
||||
options: { toleranceKm: 1 },
|
||||
})
|
||||
|
||||
expect(plan.vias.length).toBeGreaterThan(0)
|
||||
expect(plan.vias.every(v => v.after_order_index === 1)).toBe(true)
|
||||
expect(plan.legs).toEqual([0, 1])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-015: it gives up rather than spending the day on a road that will not fit', async () => {
|
||||
const { route, calls } = straightRouter()
|
||||
const plan = await planTrack({
|
||||
track: arc(),
|
||||
stops: [START, END],
|
||||
route,
|
||||
options: { toleranceKm: 0.0001, maxVias: 3 },
|
||||
})
|
||||
|
||||
expect(plan.capped).toBe(true)
|
||||
expect(plan.vias).toHaveLength(3)
|
||||
expect(calls.length).toBe(4)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-016: a track the day only brushes past is not a route for it', async () => {
|
||||
const { route, calls } = straightRouter()
|
||||
const plan = await planTrack({ track: [{ lat: 40, lng: 2 }, { lat: 40.001, lng: 2.001 }], stops: [START, END], route })
|
||||
expect(plan.vias).toEqual([])
|
||||
expect(plan.legs).toEqual([])
|
||||
// Never asks the router about a track that has nothing to do with the day.
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-017: nothing to route through is not an error', async () => {
|
||||
const { route } = straightRouter()
|
||||
expect((await planTrack({ track: arc(), stops: [START], route })).vias).toEqual([])
|
||||
expect((await planTrack({ track: [], stops: [START, END], route })).vias).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-018: a router that answers with no road says so', async () => {
|
||||
await expect(planTrack({ track: arc(), stops: [START, END], route: async () => [] }))
|
||||
.rejects.toThrow(/road/)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-019: an aborted plan stops where it is', async () => {
|
||||
const controller = new AbortController()
|
||||
const plan = await planTrack({
|
||||
track: arc(),
|
||||
stops: [START, END],
|
||||
route: async () => [START, END],
|
||||
signal: controller.signal,
|
||||
options: { toleranceKm: 0.001 },
|
||||
})
|
||||
controller.abort()
|
||||
// Ran to its own conclusion here; the point is that the signal is honoured at the top
|
||||
// of each round, which the aborted-before-start case proves.
|
||||
expect(plan.rounds).toBeGreaterThan(0)
|
||||
|
||||
const stopped = new AbortController()
|
||||
stopped.abort()
|
||||
const none = await planTrack({
|
||||
track: arc(),
|
||||
stops: [START, END],
|
||||
route: async () => [START, END],
|
||||
signal: stopped.signal,
|
||||
})
|
||||
expect(none.rounds).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWTRACK-020: how far a track runs from a day is what orders a list of them', async () => {
|
||||
const near = trackGapKm(arc(), [START, END])
|
||||
const far = trackGapKm(arc().map(p => ({ lat: p.lat + 3, lng: p.lng })), [START, END])
|
||||
expect(near).toBeLessThan(far)
|
||||
expect(trackGapKm([], [START])).toBe(Number.POSITIVE_INFINITY)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,318 @@
|
||||
import { haversineKm, projectOntoRoute, type LatLng } from './corridor'
|
||||
|
||||
/**
|
||||
* Making a day's drive follow an imported track.
|
||||
*
|
||||
* A GPX track is a shape, not a route: a list of positions with no idea which roads they
|
||||
* were recorded on. Drawing it beside the day is easy and useless — the distance, the
|
||||
* drive time and every warning still belong to the motorway the router picked, so the
|
||||
* plan says four hours while the road nobody is taking says seven. Norway publishes its
|
||||
* scenic routes this way, and so does every touring club worth importing from.
|
||||
*
|
||||
* What turns a shape into a route the day actually takes is a handful of vias placed on
|
||||
* it, because a via is the one thing a router treats as "go through here". Placing them
|
||||
* is the whole problem. Every point of the track would route exactly and take a hundred
|
||||
* requests; two would route in seconds and follow nothing.
|
||||
*
|
||||
* So this refines: route through what we have, find the point of the track the road
|
||||
* misses by the most, make that point a via, and go again. Each round roughly halves the
|
||||
* worst miss, because the new via lands in the middle of whatever the last one left
|
||||
* unfollowed — which is why a scenic route usually fits inside ten of them, and why the
|
||||
* loop stops when the road is close enough rather than after a fixed count.
|
||||
*
|
||||
* Everything here is pure apart from the injected router: no React, no store, no api.
|
||||
*/
|
||||
|
||||
/** Track points kept for measuring — enough to catch a hairpin, few enough to be fast. */
|
||||
const MEASURE_POINTS = 400
|
||||
|
||||
/** Routed points kept for measuring. Coarser: it is the thing being measured against. */
|
||||
const ROUTE_POINTS = 2000
|
||||
|
||||
/**
|
||||
* How close a via may sit to a stop, or to another via, before it is pointless.
|
||||
*
|
||||
* A via a hundred metres from a stop tells the router nothing it did not already know
|
||||
* from the stop, and two vias in the same lay-by cost a waypoint each to say one thing.
|
||||
*/
|
||||
const MIN_GAP_KM = 0.6
|
||||
|
||||
export interface FollowTrackOptions {
|
||||
/** How close the road must come to the track before the plan is done, in kilometres. */
|
||||
toleranceKm?: number
|
||||
/** The most vias the plan may lay down. */
|
||||
maxVias?: number
|
||||
/** The most routing round trips it may spend. */
|
||||
maxRounds?: number
|
||||
}
|
||||
|
||||
export interface TrackVia {
|
||||
/** The stop the via hangs behind, as an index into the day's stops. */
|
||||
after_order_index: number
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
export interface FollowTrackPlan {
|
||||
/** The vias to write, in driving order, each already on the leg it shapes. */
|
||||
vias: TrackVia[]
|
||||
/** The legs the track runs along — cleared first, so applying twice does not stack. */
|
||||
legs: number[]
|
||||
/** How far the road still strays from the track at its worst point, in kilometres. */
|
||||
strayKm: number
|
||||
/** Routing round trips spent. */
|
||||
rounds: number
|
||||
/** True when it ran out of vias or rounds before the road fitted the track. */
|
||||
capped: boolean
|
||||
}
|
||||
|
||||
const EMPTY_PLAN: FollowTrackPlan = { vias: [], legs: [], strayKm: 0, rounds: 0, capped: false }
|
||||
|
||||
/**
|
||||
* The points of a stored track.
|
||||
*
|
||||
* `route_geometry` is the column the GPX and KML importers write, holding `[lat, lng]`
|
||||
* pairs or `[lat, lng, ele]` triples depending on whether every point carried an
|
||||
* elevation. Anything else in there is somebody else's data — a malformed track is worth
|
||||
* skipping, never worth throwing over.
|
||||
*/
|
||||
export function parseTrack(routeGeometry: string | null | undefined): LatLng[] {
|
||||
if (!routeGeometry) return []
|
||||
try {
|
||||
const raw: unknown = JSON.parse(routeGeometry)
|
||||
if (!Array.isArray(raw)) return []
|
||||
const out: LatLng[] = []
|
||||
for (const point of raw) {
|
||||
if (!Array.isArray(point) || point.length < 2) continue
|
||||
const lat = Number(point[0])
|
||||
const lng = Number(point[1])
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue
|
||||
out.push({ lat, lng })
|
||||
}
|
||||
return out
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a polyline is, in kilometres. */
|
||||
export function lineLengthKm(line: LatLng[]): number {
|
||||
let km = 0
|
||||
for (let i = 1; i < line.length; i++) km += haversineKm(line[i - 1], line[i])
|
||||
return km
|
||||
}
|
||||
|
||||
/**
|
||||
* Every nth point, keeping both ends.
|
||||
*
|
||||
* Deliberately not the Douglas-Peucker in `corridor.ts`: that one is recursive and slices
|
||||
* the array at every step, which is fine for a routed leg and quadratic on a recorded
|
||||
* track of thirty thousand points. Taking a stride loses a little accuracy on a hairpin
|
||||
* and none of the shape, and the tolerance this feeds is measured in kilometres.
|
||||
*/
|
||||
export function thin(line: LatLng[], max: number): LatLng[] {
|
||||
if (line.length <= max) return line
|
||||
const stride = Math.ceil(line.length / max)
|
||||
const out: LatLng[] = []
|
||||
for (let i = 0; i < line.length; i += stride) out.push(line[i])
|
||||
const last = line[line.length - 1]
|
||||
if (out[out.length - 1] !== last) out.push(last)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Index of the point of `line` closest to `p`. */
|
||||
export function nearestIndex(line: LatLng[], p: LatLng): number {
|
||||
let best = Number.POSITIVE_INFINITY
|
||||
let at = 0
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const d = haversineKm(line[i], p)
|
||||
if (d < best) {
|
||||
best = d
|
||||
at = i
|
||||
}
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
/** How far a point lies from a line, in kilometres. Infinite for a line of one point. */
|
||||
export function offLineKm(line: LatLng[], p: LatLng): number {
|
||||
return projectOntoRoute(p, line)?.offRouteKm ?? Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
/**
|
||||
* The track pointing the way the day is driven.
|
||||
*
|
||||
* A recorded track has a direction, and it is whichever way the person who recorded it
|
||||
* happened to drive. Half of Norway's scenic routes are published southbound. Reversed
|
||||
* where the day's last stop comes before its first, so everything downstream can treat a
|
||||
* higher index as "further along the drive".
|
||||
*/
|
||||
export function orient(track: LatLng[], stops: LatLng[]): LatLng[] {
|
||||
if (track.length < 2 || stops.length < 2) return track
|
||||
const start = nearestIndex(track, stops[0])
|
||||
const end = nearestIndex(track, stops[stops.length - 1])
|
||||
return end < start ? [...track].reverse() : track
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each stop sits on the track, as an index.
|
||||
*
|
||||
* Forced not to go backwards. A stop nowhere near the track still gets its nearest point,
|
||||
* and on a track that doubles back that point can land before the previous stop's —
|
||||
* which would put a via on the wrong leg and send the day round the loop twice.
|
||||
*/
|
||||
export function stopIndices(track: LatLng[], stops: LatLng[]): number[] {
|
||||
let floor = 0
|
||||
return stops.map(stop => {
|
||||
const at = Math.max(floor, nearestIndex(track, stop))
|
||||
floor = at
|
||||
return at
|
||||
})
|
||||
}
|
||||
|
||||
/** The leg an anchor belongs to: the last stop it comes after. */
|
||||
function legFor(anchor: number, stopIdx: number[]): number {
|
||||
let leg = 0
|
||||
for (let k = 0; k + 1 < stopIdx.length; k++) if (stopIdx[k] <= anchor) leg = k
|
||||
return leg
|
||||
}
|
||||
|
||||
/** The stops with the anchors threaded in between, in the order they are driven. */
|
||||
function waypointsFor(stops: LatLng[], anchors: number[], track: LatLng[], stopIdx: number[]): LatLng[] {
|
||||
const out: LatLng[] = []
|
||||
for (let k = 0; k < stops.length; k++) {
|
||||
out.push(stops[k])
|
||||
if (k + 1 >= stops.length) continue
|
||||
for (const anchor of anchors) if (legFor(anchor, stopIdx) === k) out.push(track[anchor])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of the track the road misses by the most — among the ones worth fixing.
|
||||
*
|
||||
* Points already anchored are skipped because the router is going through them, and
|
||||
* points hard against a stop or an existing via are skipped because a via there would
|
||||
* buy nothing. What is left is the miss the next round can actually close.
|
||||
*/
|
||||
function worstMiss(
|
||||
track: LatLng[],
|
||||
road: LatLng[],
|
||||
span: { from: number; to: number },
|
||||
anchors: number[],
|
||||
stops: LatLng[],
|
||||
): { index: number; km: number } | null {
|
||||
const taken = new Set(anchors)
|
||||
let best: { index: number; km: number } | null = null
|
||||
for (let i = span.from; i <= span.to; i++) {
|
||||
if (taken.has(i)) continue
|
||||
const point = track[i]
|
||||
if (stops.some(s => haversineKm(s, point) < MIN_GAP_KM)) continue
|
||||
if (anchors.some(a => haversineKm(track[a], point) < MIN_GAP_KM)) continue
|
||||
const km = offLineKm(road, point)
|
||||
if (!Number.isFinite(km)) continue
|
||||
if (!best || km > best.km) best = { index: i, km }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export interface TrackPlanInput {
|
||||
/** The track's points, as stored. */
|
||||
track: LatLng[]
|
||||
/** The day's stops, in driving order. */
|
||||
stops: LatLng[]
|
||||
/** Routes through the given waypoints and answers with the road it drew. */
|
||||
route: (waypoints: LatLng[]) => Promise<LatLng[]>
|
||||
options?: FollowTrackOptions
|
||||
/** Called after each round, so a dialog can say how far it has got. */
|
||||
onRound?: (round: number, strayKm: number) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of a day a track has anything to say about.
|
||||
*
|
||||
* A trip full of imported tracks needs an order to offer them in, and "how far this one
|
||||
* runs from that drive" is the answer — measured from the stops rather than from the
|
||||
* routed line, so it holds for a day that has not routed yet.
|
||||
*/
|
||||
export function trackGapKm(track: LatLng[], stops: LatLng[]): number {
|
||||
if (!track.length || !stops.length) return Number.POSITIVE_INFINITY
|
||||
const line = thin(track, MEASURE_POINTS)
|
||||
if (line.length < 2) return haversineKm(line[0], stops[0])
|
||||
let best = Number.POSITIVE_INFINITY
|
||||
for (const stop of stops) best = Math.min(best, offLineKm(line, stop))
|
||||
return best
|
||||
}
|
||||
|
||||
/**
|
||||
* Vias that pull a day's drive onto a track.
|
||||
*
|
||||
* Routes the whole day per round rather than one leg at a time: the day is a single
|
||||
* request to begin with, and asking per leg would multiply every round by the number of
|
||||
* legs against a host that answers one request a second.
|
||||
*/
|
||||
export async function planTrack({
|
||||
track,
|
||||
stops,
|
||||
route,
|
||||
options = {},
|
||||
onRound,
|
||||
signal,
|
||||
}: TrackPlanInput): Promise<FollowTrackPlan> {
|
||||
const toleranceKm = options.toleranceKm ?? 1
|
||||
const maxVias = options.maxVias ?? 12
|
||||
const maxRounds = options.maxRounds ?? 10
|
||||
if (track.length < 2 || stops.length < 2) return EMPTY_PLAN
|
||||
|
||||
const measured = orient(thin(track, MEASURE_POINTS), stops)
|
||||
const stopIdx = stopIndices(measured, stops)
|
||||
const span = { from: stopIdx[0], to: stopIdx[stopIdx.length - 1] }
|
||||
// A track the day only touches at one point is a track for some other day.
|
||||
if (span.to - span.from < 2) return EMPTY_PLAN
|
||||
|
||||
// The legs the track runs along. Read off the stops, not off the anchors, so applying
|
||||
// a track twice clears what the first pass laid down instead of stacking on it — and
|
||||
// so a track that already fits still says which legs it owns.
|
||||
const legs: number[] = []
|
||||
for (let k = 0; k + 1 < stops.length; k++) {
|
||||
if (stopIdx[k + 1] > span.from && stopIdx[k] < span.to) legs.push(k)
|
||||
}
|
||||
|
||||
const anchors: number[] = []
|
||||
let strayKm = 0
|
||||
let rounds = 0
|
||||
let capped = false
|
||||
|
||||
for (;;) {
|
||||
if (signal?.aborted) break
|
||||
const drawn = await route(waypointsFor(stops, anchors, measured, stopIdx))
|
||||
if (drawn.length < 2) throw new Error('The router answered without a road')
|
||||
rounds += 1
|
||||
|
||||
const miss = worstMiss(measured, thin(drawn, ROUTE_POINTS), span, anchors, stops)
|
||||
// No candidate left to fix is as finished as being inside the tolerance: everything
|
||||
// that remains sits against a stop or against a via that is already there.
|
||||
strayKm = miss?.km ?? 0
|
||||
onRound?.(rounds, strayKm)
|
||||
if (!miss || miss.km <= toleranceKm) break
|
||||
if (anchors.length >= maxVias || rounds >= maxRounds) {
|
||||
capped = true
|
||||
break
|
||||
}
|
||||
anchors.push(miss.index)
|
||||
anchors.sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
return {
|
||||
vias: anchors.map(i => ({
|
||||
after_order_index: legFor(i, stopIdx),
|
||||
lat: measured[i].lat,
|
||||
lng: measured[i].lng,
|
||||
})),
|
||||
legs,
|
||||
strayKm,
|
||||
rounds,
|
||||
capped,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
reanchorAfterInsert,
|
||||
reanchorAfterRemove,
|
||||
reanchorByStopOrder,
|
||||
reanchorAfterReorder,
|
||||
isEmptyReanchoring,
|
||||
type AnchoredVia,
|
||||
} from './roadtripModel'
|
||||
|
||||
/**
|
||||
* A via lives at a position in the day's stop list, and that position is resolved fresh
|
||||
* every time the day is routed. These tests pin what happens to it when the list moves
|
||||
* under it — the arithmetic that decides whether a reshaped drive survives adding a
|
||||
* petrol station to it.
|
||||
*/
|
||||
const via = (id: number, after: number, lat = 53, lng = 10): AnchoredVia =>
|
||||
({ id, after_order_index: after, lat, lng })
|
||||
|
||||
/** The anchors as a plain map, so a test reads as "which via ended up where". */
|
||||
const anchors = (moved: { id: number; after_order_index: number }[]) =>
|
||||
Object.fromEntries(moved.map(v => [v.id, v.after_order_index]))
|
||||
|
||||
describe('reanchorAfterInsert', () => {
|
||||
it('FE-REANCHOR-001: a stop added past a via leaves it alone', () => {
|
||||
// Legs 0,1,2 — the via is on leg 0 and the new stop lands at position 3.
|
||||
const plan = reanchorAfterInsert([via(1, 0)], 3, () => true)
|
||||
expect(isEmptyReanchoring(plan)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-002: a stop added before a via pushes it one leg later', () => {
|
||||
const plan = reanchorAfterInsert([via(1, 2), via(2, 3)], 1, () => true)
|
||||
expect(anchors(plan.vias)).toEqual({ 1: 3, 2: 4 })
|
||||
expect(plan.remove).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-003: on the split leg, a via before the new stop keeps its leg', () => {
|
||||
// The reported bug: Hamburg → [via] → Berlin, and a fuel stop lands between them at
|
||||
// position 1. The via is passed before the fuel stop, so it belongs to the first half.
|
||||
const plan = reanchorAfterInsert([via(1, 0)], 1, () => true)
|
||||
expect(isEmptyReanchoring(plan)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-004: on the split leg, a via past the new stop moves to the second half', () => {
|
||||
// Same day, but the via sits beyond the fuel stop. Left at 0 it would bend the short
|
||||
// first half back on itself and leave the long second half unshaped — which is the
|
||||
// route snapping back to the old road.
|
||||
const plan = reanchorAfterInsert([via(1, 0)], 1, () => false)
|
||||
expect(anchors(plan.vias)).toEqual({ 1: 1 })
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-005: two vias on one split leg are told apart, not moved as a block', () => {
|
||||
const early = via(1, 1)
|
||||
const late = via(2, 1)
|
||||
const plan = reanchorAfterInsert([early, late], 2, v => v.id === early.id)
|
||||
expect(anchors(plan.vias)).toEqual({ 2: 2 })
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-006: inserting at the very front splits nothing and shifts everything', () => {
|
||||
const plan = reanchorAfterInsert([via(1, 0), via(2, 1)], 0, () => {
|
||||
throw new Error('nothing is split when the new stop leads the day')
|
||||
})
|
||||
expect(anchors(plan.vias)).toEqual({ 1: 1, 2: 2 })
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-007: appending at the end leaves every existing leg where it was', () => {
|
||||
// Four stops, legs 0..2, appended as the fifth.
|
||||
const plan = reanchorAfterInsert([via(1, 0), via(2, 2)], 4, () => true)
|
||||
expect(isEmptyReanchoring(plan)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-008: no vias means no request', () => {
|
||||
expect(isEmptyReanchoring(reanchorAfterInsert([], 2, () => true))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('reanchorAfterRemove', () => {
|
||||
it('FE-REANCHOR-009: the legs either side of a removed stop become one', () => {
|
||||
// Five stops, legs 0..3. Stop 2 goes; legs 1 and 2 merge into leg 1.
|
||||
const plan = reanchorAfterRemove([via(1, 1), via(2, 2), via(3, 3)], 2, 5)
|
||||
expect(anchors(plan.vias)).toEqual({ 2: 1, 3: 2 })
|
||||
expect(plan.remove).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-010: removing the first stop drops the vias on the leg leaving it', () => {
|
||||
const plan = reanchorAfterRemove([via(1, 0), via(2, 2)], 0, 4)
|
||||
expect(plan.remove).toEqual([1])
|
||||
expect(anchors(plan.vias)).toEqual({ 2: 1 })
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-011: removing the last stop drops the vias on the leg into it', () => {
|
||||
// Four stops, legs 0..2. Stop 3 goes, so leg 2 has nothing to lead to.
|
||||
const plan = reanchorAfterRemove([via(1, 0), via(2, 2)], 3, 4)
|
||||
expect(plan.remove).toEqual([2])
|
||||
expect(anchors(plan.vias)).toEqual({})
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-012: a day left with one stop has no drive, so no via has a leg', () => {
|
||||
const plan = reanchorAfterRemove([via(1, 0)], 1, 2)
|
||||
expect(plan.remove).toEqual([1])
|
||||
expect(plan.vias).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('reanchorAfterReorder', () => {
|
||||
it('FE-REANCHOR-013: moving a stop later pulls the vias between along with it', () => {
|
||||
// Five stops, legs 0..3. Stop 1 moves to position 3.
|
||||
const plan = reanchorAfterReorder([via(1, 0), via(2, 2), via(3, 3)], 1, 3, 5)
|
||||
// Legs 0 and 1 merge into 0, then the stop is put back at 3, splitting leg 2.
|
||||
expect(anchors(plan.vias)).toEqual({ 2: 1, 3: 2 })
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-014: moving a stop back to where it was changes nothing', () => {
|
||||
expect(isEmptyReanchoring(reanchorAfterReorder([via(1, 1)], 2, 2, 4))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-015: a via whose leg vanishes in the move is dropped, not left dangling', () => {
|
||||
// Moving the first stop kills the leg leaving it, and nothing later re-creates that
|
||||
// via's anchor — reporting it for deletion is the only honest answer.
|
||||
const plan = reanchorAfterReorder([via(1, 0)], 0, 2, 4)
|
||||
expect(plan.remove).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-016: no vias means no request', () => {
|
||||
expect(isEmptyReanchoring(reanchorAfterReorder([], 0, 2, 4))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-017: swapping the only two stops keeps every via', () => {
|
||||
// A reorder removes nothing. Leg 0 is still leg 0 after the swap, merely
|
||||
// driven the other way round, so the anchors are already right. Routing this
|
||||
// through the removal half asked the server to delete every via of the day —
|
||||
// and two stops is exactly the shape "follow this track" produces, up to
|
||||
// nine vias on that one leg, gone to a single drag with no prompt and no
|
||||
// undo.
|
||||
const vias = [via(11, 0), via(12, 0), via(13, 0)]
|
||||
|
||||
for (const [from, to] of [[0, 1], [1, 0]] as const) {
|
||||
const plan = reanchorAfterReorder(vias, from, to, 2)
|
||||
expect(plan.remove, `${from}->${to}`).toEqual([])
|
||||
expect(isEmptyReanchoring(plan), `${from}->${to}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-019: a wholesale reorder follows the stop, not the number', () => {
|
||||
// The plan-mode drag hands a whole new ordering rather than one move, so the
|
||||
// anchors are mapped by the stop they were pinned behind. Stops A,B,C,D with
|
||||
// a via on the C->D leg (anchor 2): move A to the end and C is now at index
|
||||
// 1, so the via belongs on leg 1.
|
||||
const plan = reanchorByStopOrder([via(1, 2)], [10, 20, 30, 40], [20, 30, 40, 10])
|
||||
expect(plan.vias).toEqual([{ id: 1, after_order_index: 1 }])
|
||||
expect(plan.remove).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-020: a via whose stop became the last one has no leg left', () => {
|
||||
// Nothing follows the final stop, so there is no drive for the via to bend.
|
||||
const plan = reanchorByStopOrder([via(1, 0)], [10, 20, 30], [20, 30, 10])
|
||||
expect(plan.remove).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-021: a stop that is gone takes its vias with it', () => {
|
||||
const plan = reanchorByStopOrder([via(1, 1)], [10, 20, 30], [10, 30])
|
||||
expect(plan.remove).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-022: an ordering that did not actually change asks for nothing', () => {
|
||||
expect(isEmptyReanchoring(reanchorByStopOrder([via(1, 0)], [10, 20, 30], [10, 20, 30]))).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-REANCHOR-018: removing a stop from a two-stop day still clears the leg', () => {
|
||||
// The other half of the same rule, and the reason the short-circuit exists:
|
||||
// take a stop away from a two-stop day and there is no drive left, so a via
|
||||
// has nowhere to sit.
|
||||
const plan = reanchorAfterRemove([via(11, 0), via(12, 0)], 1, 2)
|
||||
expect(plan.remove).toEqual([11, 12])
|
||||
expect(plan.vias).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,547 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
computeSchedule,
|
||||
formatClock,
|
||||
formatDurationShort,
|
||||
insertIndexForAlong,
|
||||
deriveDriveWarnings,
|
||||
legIndexForAlong,
|
||||
refuelsRange,
|
||||
sectionAnchors,
|
||||
parseClock,
|
||||
splitIntoRuns,
|
||||
sumLegSeconds,
|
||||
} from './roadtripModel'
|
||||
|
||||
describe('formatDurationShort', () => {
|
||||
it('prints minutes below an hour', () => {
|
||||
expect(formatDurationShort(45 * 60)).toBe('45 min')
|
||||
expect(formatDurationShort(0)).toBe('0 min')
|
||||
})
|
||||
|
||||
it('drops the zero minutes on a whole hour', () => {
|
||||
expect(formatDurationShort(3600)).toBe('1 h')
|
||||
expect(formatDurationShort(2 * 3600)).toBe('2 h')
|
||||
})
|
||||
|
||||
it('prints hours and minutes together', () => {
|
||||
expect(formatDurationShort(2 * 3600 + 10 * 60)).toBe('2 h 10 min')
|
||||
})
|
||||
|
||||
it('carries instead of printing sixty minutes', () => {
|
||||
// 1 h 59 min 40 s rounds the minutes to 60, which must become 2 h.
|
||||
expect(formatDurationShort(3600 + 59 * 60 + 40)).toBe('2 h')
|
||||
})
|
||||
|
||||
it('treats nonsense as nothing rather than throwing', () => {
|
||||
expect(formatDurationShort(Number.NaN)).toBe('0 min')
|
||||
expect(formatDurationShort(-90)).toBe('0 min')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseClock', () => {
|
||||
it('reads wall-clock times', () => {
|
||||
expect(parseClock('09:45')).toBe(9 * 60 + 45)
|
||||
expect(parseClock('9:05')).toBe(9 * 60 + 5)
|
||||
expect(parseClock('00:00')).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores anything trailing, as stored times sometimes carry seconds', () => {
|
||||
expect(parseClock('14:30:00')).toBe(14 * 60 + 30)
|
||||
})
|
||||
|
||||
it('rejects what is not a time', () => {
|
||||
expect(parseClock(null)).toBeNull()
|
||||
expect(parseClock('')).toBeNull()
|
||||
expect(parseClock('later')).toBeNull()
|
||||
expect(parseClock('25:00')).toBeNull()
|
||||
expect(parseClock('12:75')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatClock', () => {
|
||||
it('pads both halves', () => {
|
||||
expect(formatClock(9 * 60 + 5)).toBe('09:05')
|
||||
expect(formatClock(0)).toBe('00:00')
|
||||
})
|
||||
|
||||
it('wraps past midnight instead of printing a 25th hour', () => {
|
||||
expect(formatClock(25 * 60)).toBe('01:00')
|
||||
expect(formatClock(-30)).toBe('23:30')
|
||||
})
|
||||
})
|
||||
|
||||
describe('computeSchedule', () => {
|
||||
const hours = (h: number): number => h * 3600
|
||||
|
||||
it('walks arrival and departure forward from the first pinned time', () => {
|
||||
// The example from discussion #1797: leave at 8:00, drive 1 h 45, stay 2 h, drive 1 h 10.
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: '08:00', dwellMinutes: 0 },
|
||||
{ anchor: null, dwellMinutes: 120 },
|
||||
{ anchor: null, dwellMinutes: 45 },
|
||||
],
|
||||
[hours(1.75), hours(1) + 10 * 60],
|
||||
)
|
||||
expect(entries[0]).toMatchObject({ arrival: '08:00', departure: '08:00', anchored: true })
|
||||
expect(entries[1]).toMatchObject({ arrival: '09:45', departure: '11:45', anchored: false })
|
||||
expect(entries[2]).toMatchObject({ arrival: '12:55', departure: '13:40', anchored: false })
|
||||
})
|
||||
|
||||
it('leaves stops before the first pinned time blank rather than inventing one', () => {
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: null, dwellMinutes: 60 },
|
||||
{ anchor: '10:00', dwellMinutes: 30 },
|
||||
],
|
||||
[hours(1)],
|
||||
)
|
||||
expect(entries[0]).toMatchObject({ arrival: null, departure: null })
|
||||
expect(entries[1]).toMatchObject({ arrival: '10:00', anchored: true })
|
||||
})
|
||||
|
||||
it('restarts the cascade at a pinned stop instead of pushing it', () => {
|
||||
// The drive would arrive at 09:00, but the museum ticket says 11:00: everything
|
||||
// after it counts from 11:00, not from the drive.
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: '08:00', dwellMinutes: 0 },
|
||||
{ anchor: '11:00', dwellMinutes: 60 },
|
||||
{ anchor: null, dwellMinutes: 0 },
|
||||
],
|
||||
[hours(1), hours(1)],
|
||||
)
|
||||
expect(entries[1].arrival).toBe('11:00')
|
||||
expect(entries[2].arrival).toBe('13:00')
|
||||
})
|
||||
|
||||
it('ROADTRIP-MODEL-082: a night drive onto a pinned time after midnight is on time', () => {
|
||||
// Leave at 20:00, drive six hours, check in at 02:00. The anchor used to be
|
||||
// placed on the day the cascade had reached so far — and that only advances
|
||||
// once a computed arrival crosses midnight, which is read before this stop's
|
||||
// arrival is known. So the stop that does the crossing had its own pin put a
|
||||
// whole day early: the rail drew a warning-coloured "+24 h" on a plan that
|
||||
// was exactly on time, and dropped the overnight marker with it.
|
||||
const { entries, warnings } = computeSchedule(
|
||||
[
|
||||
{ anchor: '20:00', dwellMinutes: 0 },
|
||||
{ anchor: '02:00', dwellMinutes: 0 },
|
||||
],
|
||||
[hours(6)],
|
||||
)
|
||||
|
||||
expect(warnings.filter(w => w.code === 'late')).toEqual([])
|
||||
expect(entries[1]).toMatchObject({ arrival: '02:00', dayOffset: 1, anchored: true })
|
||||
// The day still turned over, so the rail keeps its carry badge.
|
||||
expect(warnings.some(w => w.code === 'overnight' && w.index === 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('ROADTRIP-MODEL-083: a ferry that really is missed is still reported late', () => {
|
||||
// The other half: picking the nearest occurrence must not swallow a genuine
|
||||
// delay. Leaving at 21:00 and driving five hours arrives at 02:00, half an
|
||||
// hour after the 01:30 ferry, and that is what it says.
|
||||
const { warnings } = computeSchedule(
|
||||
[
|
||||
{ anchor: '21:00', dwellMinutes: 0 },
|
||||
{ anchor: '01:30', dwellMinutes: 0 },
|
||||
],
|
||||
[hours(5)],
|
||||
)
|
||||
expect(warnings.find(w => w.code === 'late')).toMatchObject({ index: 1, minutes: 30 })
|
||||
})
|
||||
|
||||
it('ROADTRIP-MODEL-084: a pinned stop after an untimed leg keeps the day it is on', () => {
|
||||
// Nothing to be late against, but the day carried so far still applies —
|
||||
// otherwise the stop prints under a day-1 stop as though it happened first.
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: '20:00', dwellMinutes: 0 },
|
||||
{ anchor: '02:00', dwellMinutes: 0 },
|
||||
{ anchor: '09:00', dwellMinutes: 0 },
|
||||
],
|
||||
[hours(6), undefined],
|
||||
)
|
||||
expect(entries[2]).toMatchObject({ arrival: '09:00', dayOffset: 1 })
|
||||
})
|
||||
|
||||
it('reports a stop the drive cannot reach in time', () => {
|
||||
const { warnings } = computeSchedule(
|
||||
[
|
||||
{ anchor: '08:00', dwellMinutes: 0 },
|
||||
{ anchor: '09:00', dwellMinutes: 0 },
|
||||
],
|
||||
[hours(3)],
|
||||
)
|
||||
expect(warnings).toEqual([{ index: 1, code: 'late', minutes: 120 }])
|
||||
})
|
||||
|
||||
it('does not cry about a minute of rounding', () => {
|
||||
const { warnings } = computeSchedule(
|
||||
[
|
||||
{ anchor: '08:00', dwellMinutes: 0 },
|
||||
{ anchor: '09:00', dwellMinutes: 0 },
|
||||
],
|
||||
[hours(1) + 30],
|
||||
)
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('breaks the chain at a leg that never routed', () => {
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: '08:00', dwellMinutes: 30 },
|
||||
{ anchor: null, dwellMinutes: 30 },
|
||||
{ anchor: null, dwellMinutes: 0 },
|
||||
],
|
||||
[undefined, hours(1)],
|
||||
)
|
||||
expect(entries[0].arrival).toBe('08:00')
|
||||
expect(entries[1].arrival).toBeNull()
|
||||
expect(entries[2].arrival).toBeNull()
|
||||
})
|
||||
|
||||
it('flags the day rolling past midnight and keeps counting', () => {
|
||||
const { entries, warnings } = computeSchedule(
|
||||
[
|
||||
{ anchor: '22:00', dwellMinutes: 0 },
|
||||
{ anchor: null, dwellMinutes: 0 },
|
||||
],
|
||||
[hours(4)],
|
||||
)
|
||||
expect(entries[1].arrival).toBe('02:00')
|
||||
expect(entries[1].dayOffset).toBe(1)
|
||||
expect(warnings).toEqual([{ index: 1, code: 'overnight' }])
|
||||
})
|
||||
|
||||
it('handles an empty chain', () => {
|
||||
expect(computeSchedule([], [])).toEqual({ entries: [], warnings: [] })
|
||||
})
|
||||
|
||||
it('treats a missing dwell as no time spent', () => {
|
||||
const { entries } = computeSchedule(
|
||||
[
|
||||
{ anchor: '08:00', dwellMinutes: null },
|
||||
{ anchor: null, dwellMinutes: null },
|
||||
],
|
||||
[hours(1)],
|
||||
)
|
||||
expect(entries[0].departure).toBe('08:00')
|
||||
expect(entries[1].arrival).toBe('09:00')
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitIntoRuns', () => {
|
||||
const drive = (): string => 'driving'
|
||||
|
||||
it('asks for a whole day travelled one way in a single run', () => {
|
||||
const runs = splitIntoRuns(['a', 'b', 'c', 'd'], drive)
|
||||
expect(runs).toEqual([{ stops: ['a', 'b', 'c', 'd'], mode: 'driving' }])
|
||||
})
|
||||
|
||||
it('splits where the travel mode changes, and repeats the stop on both sides', () => {
|
||||
// Drive to b, walk to c, drive on to d: the walk is its own request, and b and c
|
||||
// each belong to two runs because they are the ends of neighbouring legs.
|
||||
const modes = ['driving', 'walking', 'driving']
|
||||
const runs = splitIntoRuns(['a', 'b', 'c', 'd'], (from) => modes[['a', 'b', 'c'].indexOf(from)])
|
||||
expect(runs).toEqual([
|
||||
{ stops: ['a', 'b'], mode: 'driving' },
|
||||
{ stops: ['b', 'c'], mode: 'walking' },
|
||||
{ stops: ['c', 'd'], mode: 'driving' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps consecutive legs of the same mode together across a change and back', () => {
|
||||
const modes = ['driving', 'driving', 'walking', 'walking']
|
||||
const runs = splitIntoRuns(['a', 'b', 'c', 'd', 'e'], (from) => modes[['a', 'b', 'c', 'd'].indexOf(from)])
|
||||
expect(runs).toEqual([
|
||||
{ stops: ['a', 'b', 'c'], mode: 'driving' },
|
||||
{ stops: ['c', 'd', 'e'], mode: 'walking' },
|
||||
])
|
||||
})
|
||||
|
||||
it('has nothing to ask for when there is no leg', () => {
|
||||
expect(splitIntoRuns([], drive)).toEqual([])
|
||||
expect(splitIntoRuns(['a'], drive)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sumLegSeconds', () => {
|
||||
it('adds only the legs that routed', () => {
|
||||
expect(sumLegSeconds([600, undefined, 1200])).toBe(1800)
|
||||
expect(sumLegSeconds([])).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertIndexForAlong', () => {
|
||||
// Hamburg 0, Lueneburg 50, Berlin 290 — the day's stops as distances driven.
|
||||
const stops = [0, 50, 290]
|
||||
|
||||
it('puts a hit between the two stops it falls between', () => {
|
||||
expect(insertIndexForAlong(stops, 120)).toBe(2)
|
||||
expect(insertIndexForAlong(stops, 20)).toBe(1)
|
||||
})
|
||||
|
||||
it('never lands before the stop the day starts from', () => {
|
||||
expect(insertIndexForAlong(stops, 0)).toBe(1)
|
||||
// A corridor is wider than the road, so a hit can project just behind the start.
|
||||
expect(insertIndexForAlong(stops, -3)).toBe(1)
|
||||
})
|
||||
|
||||
it('never lands after the stop the day ends at', () => {
|
||||
expect(insertIndexForAlong(stops, 290)).toBe(2)
|
||||
expect(insertIndexForAlong(stops, 400)).toBe(2)
|
||||
})
|
||||
|
||||
it('puts a hit exactly on a stop after it, not before', () => {
|
||||
expect(insertIndexForAlong(stops, 50)).toBe(2)
|
||||
})
|
||||
|
||||
it('has nowhere to insert on a day that is not a drive', () => {
|
||||
expect(insertIndexForAlong([], 10)).toBe(0)
|
||||
expect(insertIndexForAlong([0], 10)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('legIndexForAlong', () => {
|
||||
// Against the legs' own lengths rather than by projecting the stops as well: leg
|
||||
// distances come from the router and are exact, while two stops close together project
|
||||
// onto each other's stretch and would put a charging halt the wrong side of a town.
|
||||
const legs = [10_000, 25_000, 40_000]
|
||||
|
||||
it('FE-ROADTRIP-MODEL-050: a point inside a leg belongs to that leg', () => {
|
||||
expect(legIndexForAlong(legs, 0)).toBe(0)
|
||||
expect(legIndexForAlong(legs, 9_999)).toBe(0)
|
||||
expect(legIndexForAlong(legs, 10_000)).toBe(1)
|
||||
expect(legIndexForAlong(legs, 24_999)).toBe(1)
|
||||
expect(legIndexForAlong(legs, 25_000)).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-051: a point past the end lands in the last leg, not nowhere', () => {
|
||||
// A rounding edge, not a missing leg: the projection and the router measure the same
|
||||
// line two different ways and disagree by metres at the very end.
|
||||
expect(legIndexForAlong(legs, 40_000)).toBe(2)
|
||||
expect(legIndexForAlong(legs, 99_999)).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-052: a day with no routed leg has nowhere to put anything', () => {
|
||||
expect(legIndexForAlong([], 100)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveDriveWarnings', () => {
|
||||
// Worked through the way a traveller reads it: Hamburg, A1, HEM, Berlin. The badge on
|
||||
// a stop has to agree with the sum of the drive bands above it, or it looks made up.
|
||||
it('FE-ROADTRIP-MODEL-069: the figure on a stop is what the bands above it add up to', () => {
|
||||
const out = deriveDriveWarnings(
|
||||
[leg(20, 21.5), leg(62, 103.4), leg(155, 244.7)],
|
||||
[false, false, false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 100 },
|
||||
0,
|
||||
)
|
||||
// 21.5 + 103.4 on arrival at HEM, and then it keeps counting, because nothing on this
|
||||
// trip fills up.
|
||||
expect(out.warnings).toEqual([
|
||||
{ index: 2, code: 'range', sinceKm: 125 },
|
||||
{ index: 3, code: 'range', sinceKm: 370 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-070: arriving at a petrol station is the plan, not a warning', () => {
|
||||
// Same trip, but HEM is marked as fuel. Reaching it with an empty tank is exactly
|
||||
// what it is there for, so nothing is flagged on it; the budget still starts over,
|
||||
// and the next stretch is measured from there.
|
||||
const out = deriveDriveWarnings(
|
||||
[leg(20, 21.5), leg(62, 103.4), leg(155, 244.7)],
|
||||
[false, false, true, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 100 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 245 }])
|
||||
// 245 and not 370: HEM filled the tank, so Berlin counts from there.
|
||||
})
|
||||
|
||||
const noLimits = { legMinutes: null, dayMinutes: null, rangeKm: null }
|
||||
const leg = (minutes: number, km: number) => ({ duration: minutes * 60, distance: km * 1000 })
|
||||
|
||||
it('FE-ROADTRIP-MODEL-060: no limits set means nothing to report', () => {
|
||||
const out = deriveDriveWarnings([leg(600, 900)], [false, false], noLimits, 0)
|
||||
expect(out.warnings).toEqual([])
|
||||
expect(out.day).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-061: a leg over the limit reports how far over, on the stop it arrives at', () => {
|
||||
// On arrival, not on departure. Both findings are running totals that only become
|
||||
// true when you get there, and anchored to the departure the number reads as the
|
||||
// length of the NEXT leg, which is a different figure and the one that looked wrong.
|
||||
const out = deriveDriveWarnings(
|
||||
[leg(100, 90), leg(240, 200)],
|
||||
[false, false, false],
|
||||
{ ...noLimits, legMinutes: 180 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([{ index: 2, code: 'leg', overMinutes: 60 }])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-062: the day figure is the sum of the legs, and only that', () => {
|
||||
const out = deriveDriveWarnings(
|
||||
[leg(200, 180), leg(200, 180)],
|
||||
[false, false, false],
|
||||
{ ...noLimits, dayMinutes: 360 },
|
||||
0,
|
||||
)
|
||||
expect(out.day).toEqual({ code: 'dayDriving', minutes: 400, limitMinutes: 360 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-063: the figure keeps counting until something actually refuels', () => {
|
||||
// A warning is not a fill-up. Zeroing the budget at one made every figure after the
|
||||
// first wrong: the second stop would claim a fresh tank while the same one is still
|
||||
// in the car.
|
||||
const out = deriveDriveWarnings(
|
||||
[leg(60, 300), leg(60, 400), leg(60, 300), leg(60, 400)],
|
||||
[false, false, false, false, false],
|
||||
{ ...noLimits, rangeKm: 600 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([
|
||||
{ index: 2, code: 'range', sinceKm: 700 },
|
||||
{ index: 3, code: 'range', sinceKm: 1000 },
|
||||
{ index: 4, code: 'range', sinceKm: 1400 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-071: adding a fuel stop is what stops the run of warnings', () => {
|
||||
// The same drive with a charger at stop 2. Nothing is flagged there, because reaching
|
||||
// it on an empty tank is what it is for; everything after counts from zero again, so
|
||||
// the three-warning run above collapses to one.
|
||||
const out = deriveDriveWarnings(
|
||||
[leg(60, 300), leg(60, 400), leg(60, 300), leg(60, 400)],
|
||||
[false, false, true, false, false],
|
||||
{ ...noLimits, rangeKm: 600 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([{ index: 4, code: 'range', sinceKm: 700 }])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-064: filling up starts the budget over, resting does not', () => {
|
||||
const stops = [false, true, false]
|
||||
const out = deriveDriveWarnings(
|
||||
[leg(60, 500), leg(60, 500)],
|
||||
stops,
|
||||
{ ...noLimits, rangeKm: 600 },
|
||||
0,
|
||||
)
|
||||
// The tank is filled at stop 1, so the second 500 km starts from zero and neither leg
|
||||
// trips the limit. Without the refuel it would be 1000 km on a 600 km range.
|
||||
expect(out.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-065: only fuel and charging refuel', () => {
|
||||
expect(refuelsRange('fuel')).toBe(true)
|
||||
expect(refuelsRange('charging')).toBe(true)
|
||||
// A two-hour lunch fills no tank, and TREK does not know whether the restaurant has a
|
||||
// charger in its car park.
|
||||
expect(refuelsRange('restaurant')).toBe(false)
|
||||
expect(refuelsRange('rest_area')).toBe(false)
|
||||
expect(refuelsRange('campsite')).toBe(false)
|
||||
expect(refuelsRange(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-066: the budget carries into the next day, because a tank does not empty overnight', () => {
|
||||
const first = deriveDriveWarnings([leg(60, 400)], [false, false], { ...noLimits, rangeKm: 600 }, 0)
|
||||
expect(first.carryKm).toBe(400)
|
||||
|
||||
const second = deriveDriveWarnings([leg(60, 300)], [false, false], { ...noLimits, rangeKm: 600 }, first.carryKm)
|
||||
expect(second.warnings).toEqual([{ index: 1, code: 'range', sinceKm: 700 }])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-067: an unrouted leg gives the budget up rather than guessing', () => {
|
||||
const out = deriveDriveWarnings(
|
||||
[undefined, leg(60, 500)],
|
||||
[false, false, false],
|
||||
{ ...noLimits, rangeKm: 100 },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([])
|
||||
expect(out.carryKm).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-068: filling up at the last stop of the day still counts', () => {
|
||||
const out = deriveDriveWarnings([leg(60, 400)], [false, true], { ...noLimits, rangeKm: 600 }, 0)
|
||||
expect(out.carryKm).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sectionAnchors', () => {
|
||||
it('FE-ROADTRIP-MODEL-080: every stop, and the middle of every leg between them', () => {
|
||||
// The leg midpoints are the point of the feature: a break on a five-hour drive is
|
||||
// planned in the middle of the drive, not at either end of it.
|
||||
expect(sectionAnchors([0, 100, 250])).toEqual([
|
||||
{ kind: 'stop', index: 0, alongKm: 0 },
|
||||
{ kind: 'leg', index: 0, alongKm: 50 },
|
||||
{ kind: 'stop', index: 1, alongKm: 100 },
|
||||
{ kind: 'leg', index: 1, alongKm: 175 },
|
||||
{ kind: 'stop', index: 2, alongKm: 250 },
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-081: a single stop has no leg to sit in the middle of', () => {
|
||||
expect(sectionAnchors([42])).toEqual([{ kind: 'stop', index: 0, alongKm: 42 }])
|
||||
expect(sectionAnchors([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveDriveWarnings — what counts as driving', () => {
|
||||
const drive = (minutes: number, km: number) =>
|
||||
({ duration: minutes * 60, distance: km * 1000, mode: 'driving' })
|
||||
const onFoot = (minutes: number, km: number) =>
|
||||
({ duration: minutes * 60, distance: km * 1000, mode: 'walking' })
|
||||
|
||||
it('FE-ROADTRIP-MODEL-085: a walk to a viewpoint is not over the longest drive allowed', () => {
|
||||
// A day may legitimately mix modes — splitIntoRuns exists for exactly that —
|
||||
// and a two hour hike reported as a leg finding reads as a fault in a plan
|
||||
// that has none.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 80), onFoot(120, 6)],
|
||||
[false, false, false],
|
||||
{ legMinutes: 90, dayMinutes: null, rangeKm: null },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-086: walked kilometres are not on the tank', () => {
|
||||
// Nor does walking fill it: the budget is left exactly as the drive left it.
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 80), onFoot(120, 60), drive(30, 30)],
|
||||
[false, false, false, false],
|
||||
{ legMinutes: null, dayMinutes: null, rangeKm: 100 },
|
||||
0,
|
||||
)
|
||||
// 80 then 110 — the 60 walked kilometres never entered the budget.
|
||||
expect(out.warnings).toEqual([{ index: 3, code: 'range', sinceKm: 110 }])
|
||||
expect(out.carryKm).toBe(110)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-087: a walk does not count toward the driving time of the day', () => {
|
||||
const out = deriveDriveWarnings(
|
||||
[drive(60, 80), onFoot(180, 9)],
|
||||
[false, false, false],
|
||||
{ legMinutes: null, dayMinutes: 90, rangeKm: null },
|
||||
0,
|
||||
)
|
||||
expect(out.day).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-MODEL-088: a leg with no mode is still a drive', () => {
|
||||
// Every leg was a drive before the field existed, so an absent mode counts.
|
||||
// Reading it the other way round would silently switch the warnings off.
|
||||
const out = deriveDriveWarnings(
|
||||
[{ duration: 120 * 60, distance: 200 * 1000 }],
|
||||
[false, false],
|
||||
{ legMinutes: 90, dayMinutes: null, rangeKm: null },
|
||||
0,
|
||||
)
|
||||
expect(out.warnings).toEqual([{ index: 1, code: 'leg', overMinutes: 30 }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,641 @@
|
||||
/**
|
||||
* Pure road-trip arithmetic — no React, no network, no store. Everything here is a
|
||||
* function of the stops, the routed legs and the day's rules, so it can be unit tested
|
||||
* on its own and reused by the map, the rail and (later) the MCP tool.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The stop kinds that interrupt a drive rather than end it.
|
||||
*
|
||||
* Every kind the corridor search can find, and the list is meant to stay that way: what
|
||||
* you pick off "along the route" is by definition something you come across on the way,
|
||||
* so it belongs inside the leg it was found on rather than among the places the trip is
|
||||
* for. They are drawn on the dashed line with their own icon, carry no number, and are
|
||||
* left out of every stop count — four places with a charger, a rest stop and a bakery
|
||||
* between them is a four-stop day.
|
||||
*
|
||||
* Extended together with the corridor's own categories (`RoadtripCorridorPanel`) and the
|
||||
* enum in `@trek/shared`; a kind in one list and not the others is a stop that looks like
|
||||
* a destination on one screen and a pause on the next.
|
||||
*/
|
||||
export const SERVICE_STOP_TYPES = ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights'] as const
|
||||
|
||||
/**
|
||||
* What each kind of pause is coloured, on the rail, in the corridor list and on the map.
|
||||
*
|
||||
* Read as road signage rather than as a palette: a pump is petrol orange, a charger is
|
||||
* the yellow of electricity, a rest area takes the blue every parking sign in Europe is
|
||||
* printed in, a campsite is green. The last two are the colours the general place search
|
||||
* already gives food and sights, so a restaurant found along the route looks like the
|
||||
* restaurants found anywhere else.
|
||||
*
|
||||
* Deliberately literal and shared rather than tokenised: these mean the same thing in
|
||||
* both themes the way a motorway sign does, and one definition is what stops the rail,
|
||||
* the search result and the map pin from drifting into three different reds.
|
||||
*/
|
||||
export const SERVICE_COLORS: Record<string, string> = {
|
||||
fuel: '#E8590C',
|
||||
charging: '#CA8A04',
|
||||
rest_area: '#3B82F6',
|
||||
campsite: '#16A34A',
|
||||
restaurant: '#EF4444',
|
||||
sights: '#EC4899',
|
||||
}
|
||||
|
||||
/** The colour for a kind, or the neutral one for a kind nobody has a colour for yet. */
|
||||
export function serviceColor(stopType: string | null | undefined): string {
|
||||
return SERVICE_COLORS[stopType ?? ''] ?? '#64748B'
|
||||
}
|
||||
|
||||
/** Whether a stop breaks the drive. The rail's shape and every count turn on this. */
|
||||
export function isServiceStopType(stopType: string | null | undefined): boolean {
|
||||
return !!stopType && (SERVICE_STOP_TYPES as readonly string[]).includes(stopType)
|
||||
}
|
||||
|
||||
/** Seconds → "2 h 10 min" / "45 min", matching the wording of the map's route connectors. */
|
||||
export function formatDurationShort(seconds: number): string {
|
||||
const safe = Number.isFinite(seconds) && seconds > 0 ? seconds : 0
|
||||
const h = Math.floor(safe / 3600)
|
||||
const m = Math.round((safe % 3600) / 60)
|
||||
// 59.6 min must not print as "60 min"; carry it.
|
||||
if (m === 60) return `${h + 1} h`
|
||||
if (h > 0) return m > 0 ? `${h} h ${m} min` : `${h} h`
|
||||
return `${m} min`
|
||||
}
|
||||
|
||||
/** "09:45" → 585. Anything that is not a wall-clock time returns null. */
|
||||
export function parseClock(value: string | null | undefined): number | null {
|
||||
if (!value) return null
|
||||
const m = /^(\d{1,2}):(\d{2})/.exec(value.trim())
|
||||
if (!m) return null
|
||||
const h = Number(m[1])
|
||||
const min = Number(m[2])
|
||||
if (h > 23 || min > 59) return null
|
||||
return h * 60 + min
|
||||
}
|
||||
|
||||
/** 585 → "09:45". Minutes past midnight beyond a day wrap around and report the carry. */
|
||||
export function formatClock(minutes: number): string {
|
||||
const day = 24 * 60
|
||||
const wrapped = ((minutes % day) + day) % day
|
||||
const h = Math.floor(wrapped / 60)
|
||||
const m = Math.round(wrapped % 60)
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export interface ScheduleStop {
|
||||
/** Wall-clock time the user pinned on this stop, if any. */
|
||||
anchor: string | null
|
||||
/** Planned time spent here, in minutes. */
|
||||
dwellMinutes: number | null
|
||||
}
|
||||
|
||||
export interface ScheduleWarning {
|
||||
/**
|
||||
* Index of the stop the finding belongs to.
|
||||
*
|
||||
* For the two leg codes this is the stop the leg ARRIVES AT, not the one it leaves.
|
||||
* Both findings are a running total that only becomes true on arrival: "125 km since
|
||||
* the last fill-up" is what the tank reads when you get there, and the figure has to
|
||||
* agree with the one anybody adds up off the drive bands above it. Anchored to the
|
||||
* departure it read as the distance of the leg leaving that stop, which is a different
|
||||
* number entirely and the one that made the badge look wrong.
|
||||
*/
|
||||
index: number
|
||||
code: 'late' | 'overnight' | 'leg' | 'range'
|
||||
/** Minutes the computed arrival misses the anchor by (code 'late'). */
|
||||
minutes?: number
|
||||
/** Minutes this leg runs over the longest single drive allowed (code 'leg'). */
|
||||
overMinutes?: number
|
||||
/** Kilometres driven since the last fill-up when the range ran out (code 'range'). */
|
||||
sinceKm?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The two kinds of stop that put fuel or charge back.
|
||||
*
|
||||
* Deliberately shorter than `SERVICE_STOP_TYPES`: a two-hour lunch fills no tank, and
|
||||
* TREK knows nothing about whether the restaurant has a charger in its car park. Reusing
|
||||
* the service list here would quietly reset the range budget at every rest area and hide
|
||||
* the one finding this feature exists to produce.
|
||||
*/
|
||||
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
|
||||
|
||||
/** Whether stopping here starts the range budget over. */
|
||||
export function refuelsRange(stopType: string | null | undefined): boolean {
|
||||
return (REFUELLING_STOP_TYPES as readonly string[]).includes(stopType ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The three numbers a traveller can set. null means no limit, which is an explicit off
|
||||
* rather than a sentinel: zero is a legal thing to type and means the same as unset.
|
||||
*/
|
||||
export interface DriveLimits {
|
||||
/** Longest single drive between two stops, in minutes. */
|
||||
legMinutes: number | null
|
||||
/** Longest total driving in one day, in minutes. */
|
||||
dayMinutes: number | null
|
||||
/** How far one tank or charge goes, in kilometres. */
|
||||
rangeKm: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A finding about the day as a whole.
|
||||
*
|
||||
* Its own shape rather than a ScheduleWarning with index -1: a sentinel index would be
|
||||
* read by every consumer that filters findings by stop, and one of them would eventually
|
||||
* draw the day's finding on the first stop.
|
||||
*/
|
||||
export interface DayWarning {
|
||||
code: 'dayDriving'
|
||||
/** Driving time of the day in minutes, the same number the badge beside it shows. */
|
||||
minutes: number
|
||||
limitMinutes: number
|
||||
}
|
||||
|
||||
export interface ScheduleEntry {
|
||||
/** Computed arrival, or the anchor where the user pinned one. */
|
||||
arrival: string | null
|
||||
departure: string | null
|
||||
/** True when the time was pinned by the user rather than derived. */
|
||||
anchored: boolean
|
||||
/** Days past the first stop, so a chain running past midnight stays readable. */
|
||||
dayOffset: number
|
||||
}
|
||||
|
||||
export interface Schedule {
|
||||
entries: ScheduleEntry[]
|
||||
warnings: ScheduleWarning[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the chain forward: arrival = previous departure + driving time, departure =
|
||||
* arrival + time spent at the stop. A stop the user pinned a time on does not move —
|
||||
* it restarts the cascade from its own time, which is what makes a ferry, a museum
|
||||
* ticket or a hotel check-in usable as a fixed point (Furkot solves it the same way).
|
||||
*
|
||||
* `legSeconds[i]` is the drive from stop i to stop i+1; a leg that never routed is
|
||||
* `undefined` and breaks the chain rather than inventing a duration.
|
||||
*/
|
||||
/**
|
||||
* Where a stop's clock actually starts.
|
||||
*
|
||||
* A pinned time wins over whatever the drive before it worked out and restarts the
|
||||
* cascade from there. When it sits before the arrival the drive implies, the plan does
|
||||
* not fit: the caller is told by how much rather than the pinned time being moved.
|
||||
*/
|
||||
const DAY_MINUTES = 24 * 60
|
||||
|
||||
function resolveArrival(
|
||||
anchor: number | null,
|
||||
cursor: number | null,
|
||||
dayOffset: number,
|
||||
): { arrival: number | null; lateBy: number | null } {
|
||||
if (anchor === null) return { arrival: cursor, lateBy: null }
|
||||
// No drive has been timed yet, so there is nothing to be late against — but the
|
||||
// day carried so far still applies, or a pinned stop after an untimed leg
|
||||
// prints under a day-1 stop as though it happened first.
|
||||
if (cursor === null) return { arrival: anchor + dayOffset * DAY_MINUTES, lateBy: null }
|
||||
// The occurrence of that wall-clock time nearest the cursor, not the one on the
|
||||
// day the cascade has reached so far. `dayOffset` only advances once a computed
|
||||
// arrival crosses midnight, and it is read before this stop's arrival is known —
|
||||
// so when the stop that crosses midnight is the pinned one, its anchor was
|
||||
// placed a whole day early and the plan was reported as 24 hours late while
|
||||
// being exactly on time. Rounding also keeps the ordinary case: 08:00, a three
|
||||
// hour leg, a 09:00 pin still picks the same day and is two hours late.
|
||||
const k = Math.round((cursor - anchor) / DAY_MINUTES)
|
||||
const anchorAt = anchor + k * DAY_MINUTES
|
||||
return {
|
||||
arrival: anchorAt,
|
||||
lateBy: cursor > anchorAt + 1 ? Math.round(cursor - anchorAt) : null,
|
||||
}
|
||||
}
|
||||
|
||||
export function computeSchedule(stops: ScheduleStop[], legSeconds: (number | undefined)[]): Schedule {
|
||||
const entries: ScheduleEntry[] = []
|
||||
const warnings: ScheduleWarning[] = []
|
||||
// Minutes since the first stop's midnight, so a chain crossing midnight keeps counting.
|
||||
let cursor: number | null = null
|
||||
let dayOffset = 0
|
||||
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
const stop = stops[i]
|
||||
const anchor = parseClock(stop.anchor)
|
||||
const { arrival, lateBy } = resolveArrival(anchor, cursor, dayOffset)
|
||||
if (lateBy !== null) warnings.push({ index: i, code: 'late', minutes: lateBy })
|
||||
|
||||
if (arrival === null) {
|
||||
entries.push({ arrival: null, departure: null, anchored: false, dayOffset: 0 })
|
||||
continue
|
||||
}
|
||||
|
||||
const offset = Math.floor(arrival / (24 * 60))
|
||||
if (offset > dayOffset) {
|
||||
dayOffset = offset
|
||||
warnings.push({ index: i, code: 'overnight' })
|
||||
}
|
||||
|
||||
const dwell = stop.dwellMinutes ?? 0
|
||||
const departure = arrival + dwell
|
||||
entries.push({
|
||||
arrival: formatClock(arrival),
|
||||
departure: formatClock(departure),
|
||||
anchored: anchor !== null,
|
||||
dayOffset: offset,
|
||||
})
|
||||
|
||||
const leg = legSeconds[i]
|
||||
cursor = leg === undefined ? null : departure + Math.round(leg / 60)
|
||||
}
|
||||
|
||||
return { entries, warnings }
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a day's stops into the stretches that can be asked for in one routing request:
|
||||
* consecutive stops whose leg shares a travel mode.
|
||||
*
|
||||
* The router returns a leg for every consecutive pair of the waypoints it is handed, so
|
||||
* a day travelled one way is a single request rather than one per leg — the difference
|
||||
* between a road trip appearing at once and crawling in over ten seconds. A day that
|
||||
* mixes walking and driving splits at the change, because one request carries one mode.
|
||||
*/
|
||||
export function splitIntoRuns<T>(stops: T[], modeOfLeg: (from: T, to: T) => string): { stops: T[]; mode: string }[] {
|
||||
const runs: { stops: T[]; mode: string }[] = []
|
||||
let current: T[] = []
|
||||
let currentMode: string | null = null
|
||||
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const mode = modeOfLeg(stops[i], stops[i + 1])
|
||||
if (currentMode === null) {
|
||||
current = [stops[i], stops[i + 1]]
|
||||
currentMode = mode
|
||||
} else if (mode === currentMode) {
|
||||
current.push(stops[i + 1])
|
||||
} else {
|
||||
runs.push({ stops: current, mode: currentMode })
|
||||
current = [stops[i], stops[i + 1]]
|
||||
currentMode = mode
|
||||
}
|
||||
}
|
||||
if (currentMode !== null && current.length > 1) runs.push({ stops: current, mode: currentMode })
|
||||
return runs
|
||||
}
|
||||
|
||||
/** Total driving seconds of the legs that actually routed. */
|
||||
export function sumLegSeconds(legSeconds: (number | undefined)[]): number {
|
||||
return legSeconds.reduce<number>((sum, s) => sum + (s ?? 0), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a place found along the drive belongs in the day's chain.
|
||||
*
|
||||
* Both numbers are distances travelled along the same routed line, so the comparison is
|
||||
* "which stops has the car already passed when it reaches this one". A petrol station
|
||||
* 120 km into the day goes after every stop nearer than that and before the rest,
|
||||
* which is what makes adding one a single click rather than a drag afterwards.
|
||||
*
|
||||
* Clamped to sit inside the day: a corridor is wider than the road, so a hit can project
|
||||
* slightly before the first stop or past the last, and neither "before where you set off"
|
||||
* nor "after where you arrive" is a place a fuel stop can go.
|
||||
*/
|
||||
/**
|
||||
* Which leg a point on the drive belongs to, given where each leg ends.
|
||||
*
|
||||
* Measured against the legs' own lengths rather than by projecting the stops as well: the
|
||||
* router's leg distances are exact by construction, while two stops close together
|
||||
* project onto each other's stretch and would put a charging halt on the wrong side of a
|
||||
* town. A point past the end lands in the last leg rather than nowhere, because that is
|
||||
* a rounding edge and not a missing leg.
|
||||
*/
|
||||
/**
|
||||
* The findings about the driving itself: too long at the wheel, too long in one day, and
|
||||
* the tank running out before anywhere to fill it.
|
||||
*
|
||||
* Reads the legs and nothing else. The clock cascade is not available to lean on: with no
|
||||
* stop pinned to a time it produces no times at all, for any stop, so a limit expressed as
|
||||
* an hour of the day would simply never fire. All three limits are therefore durations and
|
||||
* kilometres, which exist as soon as a leg has routed.
|
||||
*
|
||||
* The range budget resets at a fuel or charging stop and NOWHERE ELSE. It carries across
|
||||
* midnight, because a tank does not empty overnight, and it keeps counting past a finding,
|
||||
* because a warning is not a fill-up. Zeroing it there made the second figure wrong and
|
||||
* every one after it: a day reading 245 km at one stop and 121 at the next claims the 121
|
||||
* is a fresh tank, when it is 366 on the same one. A run of warnings down a stretch with
|
||||
* no fuel on it is not noise; it is the answer, and it stops the moment a fuel or
|
||||
* charging stop is put in.
|
||||
*
|
||||
* An unrouted leg gives the budget up entirely rather than guessing, the same way the
|
||||
* schedule abandons its cursor: a distance we do not have cannot be added to one we do.
|
||||
*/
|
||||
export function deriveDriveWarnings(
|
||||
/**
|
||||
* `mode` decides whether a leg counts at all. A day may legitimately mix
|
||||
* modes — splitIntoRuns exists for exactly that — and a two hour walk to a
|
||||
* viewpoint is not two hours of driving, nor are its kilometres on the tank.
|
||||
* Counting them reported a hike as "over the longest drive allowed" and let a
|
||||
* ferry crossing trip a range warning. Anything that is not a driving profile
|
||||
* is skipped; an absent mode counts, because every leg was a drive before the
|
||||
* field existed.
|
||||
*/
|
||||
legs: ({ duration?: number; distance?: number; mode?: string } | undefined)[],
|
||||
/** Whether the stop at each index refuels. One entry longer than `legs`. */
|
||||
refuelsAt: boolean[],
|
||||
limits: DriveLimits,
|
||||
/** Kilometres already on the tank when the day starts; null when that is unknown. */
|
||||
carryKm: number | null,
|
||||
): { warnings: ScheduleWarning[]; day: DayWarning | null; carryKm: number | null } {
|
||||
const warnings: ScheduleWarning[] = []
|
||||
let budget = carryKm
|
||||
let totalSeconds = 0
|
||||
|
||||
for (let i = 0; i < legs.length; i++) {
|
||||
if (refuelsAt[i]) budget = 0
|
||||
const leg = legs[i]
|
||||
// The stop this leg arrives at. Both findings are about what is true on arrival.
|
||||
const at = i + 1
|
||||
// Not a drive: neither its minutes nor its kilometres belong in findings
|
||||
// about the driving. The budget is left exactly as it was — walking to a
|
||||
// viewpoint and back does not use fuel, and it does not fill the tank
|
||||
// either.
|
||||
if (leg && leg.mode !== undefined && leg.mode !== 'driving') continue
|
||||
const seconds = leg?.duration
|
||||
if (typeof seconds === 'number') {
|
||||
totalSeconds += seconds
|
||||
if (limits.legMinutes && seconds / 60 > limits.legMinutes) {
|
||||
warnings.push({ index: at, code: 'leg', overMinutes: Math.round(seconds / 60 - limits.legMinutes) })
|
||||
}
|
||||
}
|
||||
const metres = leg?.distance
|
||||
if (typeof metres !== 'number') {
|
||||
budget = null
|
||||
} else if (budget !== null) {
|
||||
budget += metres / 1000
|
||||
// Not on a stop that fills up: arriving at a petrol station with an empty tank is
|
||||
// the plan working, not a problem, and a warning there would sit on the one stop
|
||||
// that answers it. No reset either — a warning is not a fill-up, and the figure has
|
||||
// to keep counting until a fuel or charging stop actually puts one in.
|
||||
if (limits.rangeKm && budget > limits.rangeKm && !refuelsAt[at]) {
|
||||
warnings.push({ index: at, code: 'range', sinceKm: Math.round(budget) })
|
||||
}
|
||||
}
|
||||
}
|
||||
// The last stop of the day counts too: filling up on arrival is what makes the next
|
||||
// morning start with a full tank.
|
||||
if (refuelsAt[legs.length]) budget = 0
|
||||
|
||||
const minutes = Math.round(totalSeconds / 60)
|
||||
const day = limits.dayMinutes && minutes > limits.dayMinutes
|
||||
? { code: 'dayDriving' as const, minutes, limitMinutes: limits.dayMinutes }
|
||||
: null
|
||||
|
||||
return { warnings, day, carryKm: budget }
|
||||
}
|
||||
|
||||
export function legIndexForAlong(legEndMeters: number[], alongMeters: number): number {
|
||||
if (!legEndMeters.length) return -1
|
||||
for (let i = 0; i < legEndMeters.length; i++) {
|
||||
if (alongMeters < legEndMeters[i]) return i
|
||||
}
|
||||
return legEndMeters.length - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* The places on a day somebody would plan a break around.
|
||||
*
|
||||
* Every stop, and the middle of every leg between two of them. The leg midpoints are the
|
||||
* point of the whole thing: a break on a five-hour drive is planned in the middle of the
|
||||
* drive, not at either end of it, and offering only the stops would mean asking "what is
|
||||
* near Berlin" when the question is "what is near the halfway mark".
|
||||
*/
|
||||
export interface SectionAnchor {
|
||||
kind: 'stop' | 'leg'
|
||||
/** Index of the stop, or of the stop the leg leaves. */
|
||||
index: number
|
||||
alongKm: number
|
||||
}
|
||||
|
||||
export function sectionAnchors(stopsAlongKm: number[]): SectionAnchor[] {
|
||||
const out: SectionAnchor[] = []
|
||||
for (let i = 0; i < stopsAlongKm.length; i++) {
|
||||
out.push({ kind: 'stop', index: i, alongKm: stopsAlongKm[i] })
|
||||
if (i + 1 < stopsAlongKm.length) {
|
||||
out.push({ kind: 'leg', index: i, alongKm: (stopsAlongKm[i] + stopsAlongKm[i + 1]) / 2 })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function insertIndexForAlong(stopsAlongKm: number[], hitAlongKm: number): number {
|
||||
if (stopsAlongKm.length < 2) return stopsAlongKm.length
|
||||
let i = 0
|
||||
while (i < stopsAlongKm.length && stopsAlongKm[i] <= hitAlongKm) i++
|
||||
return Math.min(Math.max(i, 1), stopsAlongKm.length - 1)
|
||||
}
|
||||
|
||||
/* ── Via re-anchoring ────────────────────────────────────────────────────────
|
||||
*
|
||||
* A via records which stop it follows as a POSITION in the day's stop list, and that
|
||||
* position is resolved fresh every time the day is routed. So the moment the list
|
||||
* changes shape — a stop inserted, removed, reordered, pushed to another day — every
|
||||
* anchor at or past the change means a different leg than it did when it was written.
|
||||
* The visible symptom is the route quietly snapping back to the road the user had
|
||||
* steered it away from, with the via handle still sitting on the map where they left it.
|
||||
*
|
||||
* Nothing about that is repairable after the fact: once the stops have moved there is no
|
||||
* record of which leg a via was drawn for. So the correction has to be computed against
|
||||
* the list as it stands and applied with the same change.
|
||||
*
|
||||
* These are pure index arithmetic on purpose. The one judgement that needs geometry —
|
||||
* which half of a split leg a via belongs to — is asked of the caller, because only the
|
||||
* caller has the routed line to measure against.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A via, reduced to what re-anchoring needs to know about it.
|
||||
*
|
||||
* The coordinates are part of it because the one decision that is not pure arithmetic —
|
||||
* which half of a split leg a via belongs to — is answered by measuring where it sits on
|
||||
* the road, and the caller is handed the whole via to measure.
|
||||
*/
|
||||
export interface AnchoredVia {
|
||||
id: number
|
||||
after_order_index: number
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
|
||||
/** Where one via ends up. */
|
||||
export interface ReanchoredVia {
|
||||
id: number
|
||||
after_order_index: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The corrected anchoring, and the vias that no longer have a leg at all.
|
||||
*
|
||||
* `vias` holds only what actually moved: sending the unchanged ones would be a larger
|
||||
* write for no reason, and it keeps the diff readable when something goes wrong.
|
||||
*/
|
||||
export interface Reanchoring {
|
||||
vias: ReanchoredVia[]
|
||||
remove: number[]
|
||||
}
|
||||
|
||||
const EMPTY_REANCHORING: Reanchoring = { vias: [], remove: [] }
|
||||
|
||||
/** Only what moved, so an operation that changes nothing costs no request. */
|
||||
function collect(vias: AnchoredVia[], at: (index: number) => number | null): Reanchoring {
|
||||
const moved: ReanchoredVia[] = []
|
||||
const remove: number[] = []
|
||||
for (const via of vias) {
|
||||
const next = at(via.after_order_index)
|
||||
if (next === null) remove.push(via.id)
|
||||
else if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next })
|
||||
}
|
||||
return { vias: moved, remove }
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop was inserted at `position`, so everything from there on sits one place later.
|
||||
*
|
||||
* The leg the new stop lands in is SPLIT in two, and its vias have to be told apart: one
|
||||
* before the new stop stays on the first half, one after it moves to the second. That is
|
||||
* the whole reason a petrol station added in the middle of a reshaped drive used to undo
|
||||
* the reshaping — the via went to the first half by accident of arithmetic, the second
|
||||
* half got none, and the router took its own road again for the rest of the leg.
|
||||
*
|
||||
* `liesBeforeInsert` is asked only about vias on the split leg, and only when there is
|
||||
* one: inserting at the very front or the very end splits nothing.
|
||||
*/
|
||||
export function reanchorAfterInsert(
|
||||
vias: AnchoredVia[],
|
||||
position: number,
|
||||
liesBeforeInsert: (via: AnchoredVia) => boolean,
|
||||
): Reanchoring {
|
||||
if (!vias.length) return EMPTY_REANCHORING
|
||||
const split = position - 1
|
||||
const moved: ReanchoredVia[] = []
|
||||
for (const via of vias) {
|
||||
const i = via.after_order_index
|
||||
let next = i
|
||||
if (i === split) next = liesBeforeInsert(via) ? i : i + 1
|
||||
else if (i >= position) next = i + 1
|
||||
if (next !== i) moved.push({ id: via.id, after_order_index: next })
|
||||
}
|
||||
return { vias: moved, remove: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop was taken out at `position`, so the legs either side of it become one.
|
||||
*
|
||||
* A via on either half keeps bending the same drive, so both end up on the merged leg —
|
||||
* dropping them would throw away a decision the user made about a road that still exists.
|
||||
* The exception is the ends: remove the first stop and the leg leaving it is gone, remove
|
||||
* the last and the leg into it is gone, and a via pinned to one of those has nowhere left
|
||||
* to sit. Those are reported for deletion rather than left pointing past the day.
|
||||
*
|
||||
* `stopCount` is the number of stops BEFORE the removal.
|
||||
*/
|
||||
export function reanchorAfterRemove(vias: AnchoredVia[], position: number, stopCount: number): Reanchoring {
|
||||
if (!vias.length) return EMPTY_REANCHORING
|
||||
// Two stops become one: the day has no drive left, so no via has a leg.
|
||||
if (stopCount <= 2) return { vias: [], remove: vias.map(v => v.id) }
|
||||
return collect(vias, i => {
|
||||
if (position === 0) return i === 0 ? null : i - 1
|
||||
if (position === stopCount - 1) return i === position - 1 ? null : i
|
||||
if (i === position) return position - 1
|
||||
return i > position ? i - 1 : i
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop moved from `from` to `to` inside its own day.
|
||||
*
|
||||
* Composed from the two operations it actually is — taken out, put back — because that
|
||||
* is the only reading under which the answer stays consistent with the other two. No
|
||||
* geometry is asked for: a reorder changes which stops are neighbours, so the roads
|
||||
* themselves are about to be different and there is nothing meaningful to measure a via
|
||||
* against. Vias keep the earlier half of a split leg, which is the reading that leaves
|
||||
* them where they were relative to the stop they were drawn after.
|
||||
*
|
||||
* `stopCount` is the number of stops BEFORE the move. `to` is the index in the list as
|
||||
* it looks AFTER the stop has been taken out.
|
||||
*/
|
||||
/**
|
||||
* Re-pin a day's vias after its stops were reordered wholesale.
|
||||
*
|
||||
* The single-move helpers above take a from/to pair, which is what the rail
|
||||
* hands them. The plan-mode drag hands a whole new ordering instead, and any
|
||||
* permutation is possible — so this maps by the stop each anchor refers to
|
||||
* rather than by arithmetic on the index. A via anchored after a stop stays
|
||||
* anchored after that same stop, wherever it ended up.
|
||||
*
|
||||
* Both lists are in the rail's index space, i.e. only stops that can be routed.
|
||||
* A via whose stop is gone, or which lands on the last stop and so has no leg
|
||||
* left to bend, is reported for removal.
|
||||
*/
|
||||
export function reanchorByStopOrder(
|
||||
vias: AnchoredVia[],
|
||||
previousIds: number[],
|
||||
nextIds: number[],
|
||||
): Reanchoring {
|
||||
if (!vias.length) return EMPTY_REANCHORING
|
||||
const moved: ReanchoredVia[] = []
|
||||
const remove: number[] = []
|
||||
for (const via of vias) {
|
||||
const stopId = previousIds[via.after_order_index]
|
||||
const next = stopId === undefined ? -1 : nextIds.indexOf(stopId)
|
||||
// Gone, or now the last stop of the day: either way there is no leg for it.
|
||||
if (next === -1 || next >= nextIds.length - 1) {
|
||||
remove.push(via.id)
|
||||
continue
|
||||
}
|
||||
if (next !== via.after_order_index) moved.push({ id: via.id, after_order_index: next })
|
||||
}
|
||||
return { vias: moved, remove }
|
||||
}
|
||||
|
||||
export function reanchorAfterReorder(
|
||||
vias: AnchoredVia[],
|
||||
from: number,
|
||||
to: number,
|
||||
stopCount: number,
|
||||
): Reanchoring {
|
||||
if (!vias.length || from === to) return EMPTY_REANCHORING
|
||||
// A reorder is modelled as remove-then-insert, and the removal half refuses to
|
||||
// treat a two-stop day as still having a drive: below three stops it reports
|
||||
// every via for deletion, which is right when a stop really goes away and
|
||||
// catastrophic here. Nothing is removed by a reorder — swapping the only two
|
||||
// stops leaves leg 0 exactly where it was, merely driven the other way round —
|
||||
// so the anchors are already correct and there is nothing to re-pin.
|
||||
//
|
||||
// Two stops is the shape "follow this track" produces: it lays up to nine vias
|
||||
// on the single leg between one pair, and one drag in the rail deleted all of
|
||||
// them, with no prompt and no undo.
|
||||
if (stopCount <= 2) return EMPTY_REANCHORING
|
||||
const afterRemove = reanchorAfterRemove(vias, from, stopCount)
|
||||
const dropped = new Set(afterRemove.remove)
|
||||
const movedTo = new Map(afterRemove.vias.map(v => [v.id, v.after_order_index] as const))
|
||||
// The list the insert acts on is the one the removal left behind, so each via is
|
||||
// carried through at whatever index it holds by then.
|
||||
const shifted: AnchoredVia[] = vias
|
||||
.filter(v => !dropped.has(v.id))
|
||||
.map(v => ({ ...v, after_order_index: movedTo.get(v.id) ?? v.after_order_index }))
|
||||
const afterInsert = reanchorAfterInsert(shifted, to, () => true)
|
||||
const finalIndex = new Map(afterInsert.vias.map(v => [v.id, v.after_order_index] as const))
|
||||
|
||||
const result: ReanchoredVia[] = []
|
||||
for (const via of vias) {
|
||||
if (dropped.has(via.id)) continue
|
||||
const mid = movedTo.get(via.id) ?? via.after_order_index
|
||||
const end = finalIndex.get(via.id) ?? mid
|
||||
if (end !== via.after_order_index) result.push({ id: via.id, after_order_index: end })
|
||||
}
|
||||
return { vias: result, remove: afterRemove.remove }
|
||||
}
|
||||
|
||||
/** Whether a re-anchoring has anything to write at all. */
|
||||
export function isEmptyReanchoring(r: Reanchoring): boolean {
|
||||
return r.vias.length === 0 && r.remove.length === 0
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { serviceMarkerHtml, serviceMarkerOuter, SERVICE_MARKER_SIZE, SERVICE_MARKER_SIZE_SELECTED } from './serviceMarker'
|
||||
import { serviceColor } from './roadtripModel'
|
||||
|
||||
/**
|
||||
* FE-SVCMARK-001..007 — the disc a service stop gets on the map.
|
||||
*
|
||||
* Both renderers need the same string: one hands it to a Leaflet divIcon, the
|
||||
* other to an element it attaches itself. What is worth pinning is that an
|
||||
* ordinary place gets nothing at all — a petrol station is something the drive
|
||||
* passes through, and giving it a place-sized marker made a day look like it had
|
||||
* twice the stops it has — and that the anchor arithmetic includes the border,
|
||||
* or the pin sits a couple of pixels off the coordinate it marks.
|
||||
*/
|
||||
|
||||
describe('serviceMarkerHtml', () => {
|
||||
it('FE-SVCMARK-001: an ordinary place gets no service marker', () => {
|
||||
// Null is the signal to fall back to the place marker, so this is what keeps
|
||||
// a destination from being drawn as a fuel stop.
|
||||
expect(serviceMarkerHtml(null, false)).toBeNull()
|
||||
expect(serviceMarkerHtml(undefined, false)).toBeNull()
|
||||
expect(serviceMarkerHtml('', false)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-SVCMARK-002: each kind of stop gets a disc in its own colour', () => {
|
||||
// All six, not only the four the corridor picker offers: a meal and a
|
||||
// viewpoint are pauses on the way too, and the map draws them the same way.
|
||||
for (const kind of ['fuel', 'charging', 'rest_area', 'campsite', 'restaurant', 'sights']) {
|
||||
const html = serviceMarkerHtml(kind, false)
|
||||
expect(html, kind).toBeTruthy()
|
||||
expect(html, kind).toContain(serviceColor(kind))
|
||||
expect(html, kind).toContain('border-radius:50%')
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-SVCMARK-003: an unknown service kind is still not drawn as one', () => {
|
||||
// isServiceStopType is the gate; a value the picker never offers must not
|
||||
// reach the renderer and produce a disc in an undefined colour.
|
||||
expect(serviceMarkerHtml('banana', false)).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-SVCMARK-004: selecting one grows it, the way a place marker confirms a click', () => {
|
||||
const plain = serviceMarkerHtml('fuel', false)!
|
||||
const selected = serviceMarkerHtml('fuel', true)!
|
||||
|
||||
expect(plain).toContain(`width:${SERVICE_MARKER_SIZE}px`)
|
||||
expect(selected).toContain(`width:${SERVICE_MARKER_SIZE_SELECTED}px`)
|
||||
expect(SERVICE_MARKER_SIZE_SELECTED).toBeGreaterThan(SERVICE_MARKER_SIZE)
|
||||
})
|
||||
|
||||
it('FE-SVCMARK-005: selected carries the heavier ring, unselected the plain shadow', () => {
|
||||
expect(serviceMarkerHtml('fuel', true)!).toContain('0 0 0 3px')
|
||||
expect(serviceMarkerHtml('fuel', false)!).not.toContain('0 0 0 3px')
|
||||
})
|
||||
|
||||
it('FE-SVCMARK-006: the icon is drawn white and inline, never a logo or a photo', () => {
|
||||
// A fuel stop used to come back from the place search carrying the
|
||||
// operator's logo, so the map showed a brand roundel where every other
|
||||
// road-trip surface showed a pump.
|
||||
const html = serviceMarkerHtml('fuel', false)!
|
||||
expect(html).toContain('<svg')
|
||||
expect(html).toContain('#fff')
|
||||
expect(html).not.toContain('<img')
|
||||
expect(html).not.toContain('background-image')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serviceMarkerOuter', () => {
|
||||
it('FE-SVCMARK-007: the box an anchor centres on includes the border', () => {
|
||||
// content-box plus a 2px border on each side. Measuring the inner size would
|
||||
// leave every service pin two pixels off the coordinate it marks.
|
||||
expect(serviceMarkerOuter(false)).toBe(SERVICE_MARKER_SIZE + 4)
|
||||
expect(serviceMarkerOuter(true)).toBe(SERVICE_MARKER_SIZE_SELECTED + 4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createElement } from 'react'
|
||||
import { ParkingSquare } from 'lucide-react'
|
||||
import { renderIconMarkup } from '../../utils/iconMarkup'
|
||||
import { isServiceStopType, serviceColor } from './roadtripModel'
|
||||
import { STOP_KIND_BY_KEY } from './stopKinds'
|
||||
|
||||
/**
|
||||
* Smaller than a place marker on purpose.
|
||||
*
|
||||
* A petrol station is not a destination — it is something the drive passes through — and
|
||||
* a marker the same size as the places the trip is for made a day look like it had twice
|
||||
* as many stops as it has. Selected grows a little, the same way a place marker does, so
|
||||
* clicking one still confirms itself.
|
||||
*/
|
||||
export const SERVICE_MARKER_SIZE = 24
|
||||
export const SERVICE_MARKER_SIZE_SELECTED = 30
|
||||
const BORDER = 2
|
||||
|
||||
/** The full box a service marker occupies, border included — what an anchor centres on. */
|
||||
export function serviceMarkerOuter(selected: boolean): number {
|
||||
return (selected ? SERVICE_MARKER_SIZE_SELECTED : SERVICE_MARKER_SIZE) + BORDER * 2
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker for a stop that interrupts the drive, or null for an ordinary place.
|
||||
*
|
||||
* One flat disc in the kind's own colour with a white icon — never the place's photo or
|
||||
* its brand logo. A fuel stop used to come back from the place search carrying the
|
||||
* operator's logo, so the map showed an Esso roundel where every other road-trip surface
|
||||
* showed a pump: unreadable at marker size, different for every brand, and nothing at all
|
||||
* for the ones with no logo on file.
|
||||
*
|
||||
* Built as markup rather than as a component because both renderers need a string: one
|
||||
* feeds it to a Leaflet `divIcon`, the other to an element it attaches itself.
|
||||
*/
|
||||
export function serviceMarkerHtml(stopType: string | null | undefined, selected: boolean): string | null {
|
||||
if (!isServiceStopType(stopType)) return null
|
||||
const size = selected ? SERVICE_MARKER_SIZE_SELECTED : SERVICE_MARKER_SIZE
|
||||
const Icon = STOP_KIND_BY_KEY[stopType ?? '']?.Icon ?? ParkingSquare
|
||||
const svg = renderIconMarkup(createElement(Icon, {
|
||||
size: Math.round(size * 0.56),
|
||||
color: '#fff',
|
||||
strokeWidth: 2.4,
|
||||
}))
|
||||
const ring = selected
|
||||
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 12px rgba(0,0,0,0.3)'
|
||||
: '0 2px 6px rgba(0,0,0,0.25)'
|
||||
return `<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
background:${serviceColor(stopType)};
|
||||
border:${BORDER}px solid #fff;box-shadow:${ring};
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
box-sizing:content-box;cursor:pointer;
|
||||
">${svg}</div>`
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { roadtripStopTypeSchema } from '@trek/shared'
|
||||
import { SERVICE_STOP_TYPES, SERVICE_COLORS } from './roadtripModel'
|
||||
import {
|
||||
STOP_KINDS, STOP_KIND_BY_KEY, CORRIDOR_CATEGORIES, CORRIDOR_CATEGORY_BY_KEY, CORRIDOR_CATEGORY_KEYS,
|
||||
SERVICE_KIND_KEYS, REFUELLING_STOP_TYPES, isOvernightCategory,
|
||||
} from './stopKinds'
|
||||
|
||||
/**
|
||||
* The table exists to stop nine lists from drifting apart, and these are the assertions
|
||||
* that make that true rather than intended. Each one pins the table against a list that
|
||||
* cannot import it: the Zod enum lives in `@trek/shared` and must not depend on the
|
||||
* client, and `SERVICE_STOP_TYPES` lives in the React-free model.
|
||||
*/
|
||||
describe('stop kinds', () => {
|
||||
it('FE-STOPKIND-001: covers exactly the kinds the shared enum allows', () => {
|
||||
const fromSchema = [...roadtripStopTypeSchema.options].sort()
|
||||
const fromTable = STOP_KINDS.map(k => k.key).sort()
|
||||
expect(fromTable).toEqual(fromSchema)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-002: agrees with the model about which kinds interrupt a drive', () => {
|
||||
expect([...SERVICE_KIND_KEYS].sort()).toEqual([...SERVICE_STOP_TYPES].sort())
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-003: every kind carries the colour the model gives it', () => {
|
||||
for (const kind of STOP_KINDS) expect(kind.color).toBe(SERVICE_COLORS[kind.key])
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-004: a refuelling kind is a kind, so a range budget cannot reset on a typo', () => {
|
||||
for (const key of REFUELLING_STOP_TYPES) expect(STOP_KIND_BY_KEY[key]).toBeDefined()
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-005: the corridor offers every category in a stable order', () => {
|
||||
// Sleeping sits next to sleeping: hotel follows campsite rather than landing wherever
|
||||
// the stop-kind table happens to end.
|
||||
expect(CORRIDOR_CATEGORY_KEYS).toEqual(['fuel', 'charging', 'rest_area', 'campsite', 'hotel', 'restaurant', 'sights'])
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-008: a hotel is a category without being a stop kind', () => {
|
||||
// The whole reason the two lists are separate. Sleeping somewhere ends the day rather
|
||||
// than interrupting the drive, so a hotel keeps its number in the chain and never
|
||||
// becomes a stop_type.
|
||||
expect(CORRIDOR_CATEGORY_BY_KEY.hotel.stopKind).toBeNull()
|
||||
expect(STOP_KIND_BY_KEY.hotel).toBeUndefined()
|
||||
expect(SERVICE_KIND_KEYS).not.toContain('hotel')
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-009: a night can be booked at a campsite too, so the popup asks', () => {
|
||||
expect(isOvernightCategory('hotel')).toBe(true)
|
||||
expect(isOvernightCategory('campsite')).toBe(true)
|
||||
expect(isOvernightCategory('fuel')).toBe(false)
|
||||
expect(isOvernightCategory(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-010: every category that is a stop kind names the kind it creates', () => {
|
||||
for (const category of CORRIDOR_CATEGORIES) {
|
||||
if (category.stopKind === null) continue
|
||||
expect(category.stopKind).toBe(category.key)
|
||||
expect(STOP_KIND_BY_KEY[category.key]).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-006: two kinds are named under a key of their own, and that is on purpose', () => {
|
||||
// Spelled out rather than derived: the corridor calls a rest area rest_area and names
|
||||
// it under rest, a restaurant restaurant and names it under food. A derived label key
|
||||
// would silently ask for two translations that do not exist.
|
||||
expect(STOP_KIND_BY_KEY.rest_area.labelKey).toBe('roadtrip.poi.rest')
|
||||
expect(STOP_KIND_BY_KEY.restaurant.labelKey).toBe('roadtrip.poi.food')
|
||||
for (const kind of STOP_KINDS) {
|
||||
if (kind.key !== 'rest_area' && kind.key !== 'restaurant') {
|
||||
expect(kind.labelKey).toBe(`roadtrip.poi.${kind.key}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-STOPKIND-007: every kind offers a dwell the popup can pre-fill', () => {
|
||||
for (const kind of STOP_KINDS) {
|
||||
expect(kind.defaultMinutes).toBeGreaterThan(0)
|
||||
expect(Number.isInteger(kind.defaultMinutes)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Fuel, Zap, ParkingSquare, Tent, Utensils, Camera, BedDouble, type LucideIcon } from 'lucide-react'
|
||||
import { SERVICE_COLORS, SERVICE_STOP_TYPES } from './roadtripModel'
|
||||
import type { RoadtripStopType } from '@trek/shared'
|
||||
|
||||
/**
|
||||
* One row per kind of stop a drive can have, and the only place a new one is added.
|
||||
*
|
||||
* The six kinds used to be written out in nine separate lists: the enum in `@trek/shared`,
|
||||
* `SERVICE_STOP_TYPES` and `SERVICE_COLORS` here in the model, the corridor's category
|
||||
* keys, the map's `ROADTRIP_POI_CATEGORIES`, the popup's own `STOP_KINDS`, two lookups in
|
||||
* the rail, one in the map markers and one in the corridor panel. Every one of those
|
||||
* carried a comment telling the next person to keep the others in step, and it still came
|
||||
* apart: a rest area was a parking sign in the popup, on the map pill and in the panel,
|
||||
* and a coffee cup on the rail disc and the map marker, for the same stop. That is fixed
|
||||
* here in favour of the parking sign, because the colour is already documented as "the
|
||||
* blue every parking sign in Europe is printed in".
|
||||
*
|
||||
* The table lives beside the model rather than in it because an icon is a React component
|
||||
* and `roadtripModel.ts` is deliberately free of React so it can be unit tested on its
|
||||
* own. The colours and the service list stay there and are read from here.
|
||||
*
|
||||
* `isService` and `isCorridorCategory` are not the same question, and a coming lodging
|
||||
* kind is why they are separate: the corridor can find a hotel, but sleeping somewhere
|
||||
* ends the day rather than interrupting the drive, so it would be a category without
|
||||
* being a service stop.
|
||||
*/
|
||||
export interface StopKind {
|
||||
key: RoadtripStopType
|
||||
/** Two of the six do not match their key: `rest_area` is named under `rest`, `restaurant` under `food`. */
|
||||
labelKey: string
|
||||
Icon: LucideIcon
|
||||
color: string
|
||||
/** What the popup offers before anyone changes it — how long this kind usually takes. */
|
||||
defaultMinutes: number
|
||||
/** Interrupts the drive: drawn on the dashed line, no number, left out of every stop count. */
|
||||
isService: boolean
|
||||
/** Offered as a category in the corridor search. */
|
||||
isCorridorCategory: boolean
|
||||
}
|
||||
|
||||
export const STOP_KINDS: StopKind[] = [
|
||||
{ key: 'fuel', labelKey: 'roadtrip.poi.fuel', Icon: Fuel, color: SERVICE_COLORS.fuel, defaultMinutes: 10, isService: true, isCorridorCategory: true },
|
||||
{ key: 'charging', labelKey: 'roadtrip.poi.charging', Icon: Zap, color: SERVICE_COLORS.charging, defaultMinutes: 30, isService: true, isCorridorCategory: true },
|
||||
{ key: 'rest_area', labelKey: 'roadtrip.poi.rest', Icon: ParkingSquare, color: SERVICE_COLORS.rest_area, defaultMinutes: 20, isService: true, isCorridorCategory: true },
|
||||
{ key: 'campsite', labelKey: 'roadtrip.poi.campsite', Icon: Tent, color: SERVICE_COLORS.campsite, defaultMinutes: 60, isService: true, isCorridorCategory: true },
|
||||
{ key: 'restaurant', labelKey: 'roadtrip.poi.food', Icon: Utensils, color: SERVICE_COLORS.restaurant, defaultMinutes: 45, isService: true, isCorridorCategory: true },
|
||||
{ key: 'sights', labelKey: 'roadtrip.poi.sights', Icon: Camera, color: SERVICE_COLORS.sights, defaultMinutes: 30, isService: true, isCorridorCategory: true },
|
||||
]
|
||||
|
||||
/** By key, for the lookups the rail, the markers and the panel used to keep themselves. */
|
||||
export const STOP_KIND_BY_KEY: Record<string, StopKind> = Object.fromEntries(
|
||||
STOP_KINDS.map(k => [k.key, k]),
|
||||
)
|
||||
|
||||
/**
|
||||
* A category the corridor search offers, which is not the same question as a stop kind.
|
||||
*
|
||||
* Every stop kind is a category, but not every category is a stop kind: somewhere to
|
||||
* sleep is something the search can find, and staying there ends the day rather than
|
||||
* interrupting the drive. A hotel therefore keeps its number in the chain and gets a row
|
||||
* in `day_accommodations`; it never becomes a `stop_type`.
|
||||
*/
|
||||
export interface CorridorCategory {
|
||||
key: string
|
||||
labelKey: string
|
||||
Icon: LucideIcon
|
||||
color: string
|
||||
/** The kind a hit of this category becomes, or null when it becomes something else. */
|
||||
stopKind: RoadtripStopType | null
|
||||
}
|
||||
|
||||
/** Somewhere to sleep. Blue rather than the campsite's green: both are a night, but one
|
||||
* of them has a roof, and telling the two apart on a map matters more than grouping them.
|
||||
* theme-lint-disable — the road-signage palette, shared with the general place search so
|
||||
* a hotel found along a route looks like the hotels found anywhere else. */
|
||||
export const HOTEL_COLOR = '#2563EB'
|
||||
|
||||
/**
|
||||
* The categories, in the order the panel shows them.
|
||||
*
|
||||
* Written out rather than derived, because the order is a judgement: sleeping sits next
|
||||
* to sleeping, so `hotel` follows `campsite` instead of landing wherever the stop-kind
|
||||
* table happens to end.
|
||||
*/
|
||||
export const CORRIDOR_CATEGORIES: CorridorCategory[] = [
|
||||
...STOP_KINDS.filter(k => k.isCorridorCategory && k.key !== 'restaurant' && k.key !== 'sights')
|
||||
.map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })),
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: HOTEL_COLOR, stopKind: null },
|
||||
...STOP_KINDS.filter(k => k.key === 'restaurant' || k.key === 'sights')
|
||||
.map(k => ({ key: k.key as string, labelKey: k.labelKey, Icon: k.Icon, color: k.color, stopKind: k.key })),
|
||||
]
|
||||
|
||||
export const CORRIDOR_CATEGORY_BY_KEY: Record<string, CorridorCategory> = Object.fromEntries(
|
||||
CORRIDOR_CATEGORIES.map(c => [c.key, c]),
|
||||
)
|
||||
|
||||
/** The keys the corridor search offers, in the order the panel shows them. */
|
||||
export const CORRIDOR_CATEGORY_KEYS = CORRIDOR_CATEGORIES.map(c => c.key)
|
||||
|
||||
/** Categories whose hit becomes a night rather than a pause. */
|
||||
export function isOvernightCategory(category: string | null | undefined): boolean {
|
||||
// Campsite is both: it is a service stop today and stays one, but a night can be
|
||||
// booked there just as well, so the popup offers the choice rather than deciding.
|
||||
return category === 'hotel' || category === 'campsite'
|
||||
}
|
||||
|
||||
/**
|
||||
* A guard against the drift this file exists to end.
|
||||
*
|
||||
* `SERVICE_STOP_TYPES` is the model's own list and stays there, because the arithmetic
|
||||
* must not depend on anything that imports React. The two are checked against each other
|
||||
* in `stopKinds.test.ts` rather than derived, so a kind added to one and forgotten in the
|
||||
* other fails a test instead of quietly counting as a destination.
|
||||
*/
|
||||
export const SERVICE_KIND_KEYS = STOP_KINDS.filter(k => k.isService).map(k => k.key)
|
||||
|
||||
/** The kinds where a tank or a battery is filled, so a range budget starts over. */
|
||||
export const REFUELLING_STOP_TYPES = ['fuel', 'charging'] as const
|
||||
|
||||
export { SERVICE_STOP_TYPES }
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The road trip's type scale, pinned to the day plan's.
|
||||
*
|
||||
* Both road trip panels sit beside the day plan and the tab switcher flips between
|
||||
* them, so a road trip whose names ran a size larger than the day plan's read as a
|
||||
* different app.
|
||||
* The two anchors are the day plan's own: a day title is 14px and a place name 12.5px
|
||||
* (`DayPlanSidebar`), and everything here is derived from those. The design handoff was
|
||||
* drawn a fifth larger throughout — its proportions are kept, its absolute sizes are not.
|
||||
*
|
||||
* Written as `calc(px * var(--fs-scale-<tier>))` rather than as a tier utility because
|
||||
* the tiers land on 24/18/14/12px and this rail needs the steps between them; the
|
||||
* multiplier is what keeps the user's per-tier text-size setting reaching them. A value
|
||||
* and its unit always share one tier, so scaling one tier cannot break a pair apart.
|
||||
*/
|
||||
export const FS = {
|
||||
/** A total in the head. */
|
||||
total: 'calc(20px * var(--fs-scale-title, 1))',
|
||||
/** The unit hanging off one. */
|
||||
totalUnit: 'calc(11.5px * var(--fs-scale-title, 1))',
|
||||
/** A day's name — the day plan's own size. */
|
||||
dayTitle: 'calc(14px * var(--fs-scale-body, 1))',
|
||||
/** A stop's name — the day plan's own size. */
|
||||
name: 'calc(12.5px * var(--fs-scale-body, 1))',
|
||||
/** A clock reading, and a service stop's dwell. */
|
||||
time: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
/** A drive band, a date, a line of explanation. */
|
||||
meta: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
/** The number in a marker. */
|
||||
marker: 'calc(10.5px * var(--fs-scale-caption, 1))',
|
||||
/** A badge, a caption over a total. */
|
||||
label: 'calc(9px * var(--fs-scale-caption, 1))',
|
||||
/** The word "Stay" — the smallest thing in the rail. */
|
||||
micro: 'calc(8px * var(--fs-scale-caption, 1))',
|
||||
/** The name of a panel — "Along the route". The largest text either panel carries. */
|
||||
panelTitle: 'calc(15px * var(--fs-scale-subtitle, 1))',
|
||||
/** A pill, a segment, a line of progress: anything you press but do not read as prose. */
|
||||
control: 'calc(10.5px * var(--fs-scale-body, 1))',
|
||||
} as const
|
||||
@@ -0,0 +1,187 @@
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { LatLng } from './corridor'
|
||||
|
||||
const { pois } = vi.hoisted(() => ({ pois: vi.fn() }))
|
||||
vi.mock('../../api/client', () => ({ mapsApi: { pois } }))
|
||||
vi.mock('../../i18n', () => ({ useTranslation: () => ({ locale: 'en-US' }) }))
|
||||
|
||||
import { useCorridorPois } from './useCorridorPois'
|
||||
|
||||
/** A short drive west out of Hamburg — enough route to tile, few enough boxes to be quick. */
|
||||
const LINE: LatLng[] = [
|
||||
{ lat: 53.55, lng: 9.99 },
|
||||
{ lat: 53.5, lng: 9.8 },
|
||||
{ lat: 53.45, lng: 9.6 },
|
||||
]
|
||||
|
||||
const hit = (id: string, lat: number, lng: number, category = 'fuel') => ({
|
||||
osm_id: id,
|
||||
name: id,
|
||||
lat,
|
||||
lng,
|
||||
category,
|
||||
poi_type: 'amenity=fuel',
|
||||
address: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
opening_hours: null,
|
||||
cuisine: null,
|
||||
source: 'openstreetmap',
|
||||
})
|
||||
|
||||
const answer = (...items: ReturnType<typeof hit>[]) => ({ pois: items, source: 'openstreetmap', truncated: false, clamped: false })
|
||||
|
||||
beforeEach(() => {
|
||||
pois.mockReset()
|
||||
pois.mockResolvedValue(answer())
|
||||
})
|
||||
|
||||
describe('useCorridorPois', () => {
|
||||
it('FE-ROADTRIP-CORRIDOR-001: asks each box once for every category together', async () => {
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel', 'charging', 'rest_area'], 10))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(pois).toHaveBeenCalled()
|
||||
// The whole point of the batching: the kinds ride along in one query per box.
|
||||
for (const call of pois.mock.calls) expect(call[0]).toBe('fuel,charging,rest_area')
|
||||
expect(result.current.progress.total).toBe(pois.mock.calls.length)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-002: keeps what is near the route and drops what is not', async () => {
|
||||
// One beside the line, one far north of it.
|
||||
pois.mockResolvedValue(answer(hit('near', 53.5, 9.81), hit('far', 54.6, 9.8)))
|
||||
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 5))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.results.map(r => r.osm_id)).toEqual(['near'])
|
||||
expect(result.current.results[0].offRouteKm).toBeLessThan(5)
|
||||
expect(result.current.results[0].alongKm).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-003: orders hits by how far along the drive they are', async () => {
|
||||
pois.mockResolvedValue(answer(
|
||||
hit('late', 53.45, 9.61),
|
||||
hit('early', 53.55, 9.98),
|
||||
hit('middle', 53.5, 9.8),
|
||||
))
|
||||
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 8))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.results.map(r => r.osm_id)).toEqual(['early', 'middle', 'late'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-004: the same place found in two overlapping boxes is listed once', async () => {
|
||||
pois.mockResolvedValue(answer(hit('shared', 53.5, 9.81)))
|
||||
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.results.filter(r => r.osm_id === 'shared')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-005: a box that times out is asked again before it counts as unsearched', async () => {
|
||||
let first = true
|
||||
pois.mockImplementation(async () => {
|
||||
if (first) { first = false; throw new Error('502') }
|
||||
return answer(hit('found', 53.5, 9.81))
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.failedAreas).toBe(0)
|
||||
expect(result.current.error).toBe(false)
|
||||
expect(result.current.results.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-006: a box that fails twice is reported rather than silently skipped', async () => {
|
||||
pois.mockRejectedValue(new Error('502'))
|
||||
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.failedAreas).toBeGreaterThan(0)
|
||||
// Nothing answered at all, so this is an outage and not a partial result.
|
||||
expect(result.current.error).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-007: a drive longer than the request budget says the tail went unsearched', async () => {
|
||||
const long: LatLng[] = Array.from({ length: 40 }, (_, i) => ({ lat: 53.5 - i * 0.25, lng: 9.9 + i * 0.35 }))
|
||||
|
||||
const { result } = renderHook(() => useCorridorPois(long, ['fuel'], 5))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
|
||||
expect(result.current.capped).toBe(true)
|
||||
expect(result.current.progress.total).toBeLessThanOrEqual(16)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-008: changing what is wanted drops the answers to the old question', async () => {
|
||||
pois.mockResolvedValue(answer(hit('a', 53.5, 9.81)))
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ categories }: { categories: string[] }) => useCorridorPois(LINE, categories, 10),
|
||||
{ initialProps: { categories: ['fuel'] } },
|
||||
)
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.results.length).toBeGreaterThan(0))
|
||||
|
||||
rerender({ categories: ['campsite'] })
|
||||
await waitFor(() => expect(result.current.results).toEqual([]))
|
||||
expect(result.current.progress).toEqual({ done: 0, total: 0 })
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-009: a route too short to search asks for nothing', async () => {
|
||||
const { result } = renderHook(() => useCorridorPois([LINE[0]], ['fuel'], 10))
|
||||
act(() => { result.current.search() })
|
||||
await act(async () => {})
|
||||
|
||||
expect(pois).not.toHaveBeenCalled()
|
||||
expect(result.current.loading).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-010: with nothing selected there is nothing to look for', async () => {
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, [], 10))
|
||||
act(() => { result.current.search() })
|
||||
await act(async () => {})
|
||||
|
||||
expect(pois).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-011: clearing stops the run and empties the list', async () => {
|
||||
pois.mockResolvedValue(answer(hit('a', 53.5, 9.81)))
|
||||
|
||||
const { result } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(result.current.results.length).toBeGreaterThan(0))
|
||||
|
||||
act(() => { result.current.clear() })
|
||||
expect(result.current.results).toEqual([])
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(result.current.failedAreas).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDOR-012: leaving the view aborts the boxes still in flight', async () => {
|
||||
let seen: AbortSignal | undefined
|
||||
pois.mockImplementation((_c: string, _b: unknown, _l: string, signal: AbortSignal) => {
|
||||
seen = signal
|
||||
return new Promise(() => {})
|
||||
})
|
||||
|
||||
const { result, unmount } = renderHook(() => useCorridorPois(LINE, ['fuel'], 10))
|
||||
act(() => { result.current.search() })
|
||||
await waitFor(() => expect(seen).toBeDefined())
|
||||
|
||||
unmount()
|
||||
expect(seen!.aborted).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { corridorTiles, projectOntoRoute, simplifyLine, type Bbox, type LatLng } from './corridor'
|
||||
import type { Poi } from '../Map/poiCategories'
|
||||
|
||||
/** A POI that is actually on the way, with the two numbers that make it one. */
|
||||
export interface CorridorPoi extends Poi {
|
||||
offRouteKm: number
|
||||
alongKm: number
|
||||
}
|
||||
|
||||
export interface CorridorSearch {
|
||||
results: CorridorPoi[]
|
||||
/** Areas answered so far, out of how many the route was cut into. */
|
||||
progress: { done: number; total: number }
|
||||
loading: boolean
|
||||
/** True when the route was longer than the request budget and the tail was left out. */
|
||||
capped: boolean
|
||||
/** Boxes the place search could not answer — a partial result is still shown. */
|
||||
failedAreas: number
|
||||
/**
|
||||
* Boxes where the server hit its own per-request ceiling and dropped the rest.
|
||||
* Distinct from `capped`, which is about stretches never asked for at all: here the
|
||||
* stretch was searched and the answer came back short. Without saying so, filtering
|
||||
* the results cannot tell "nothing of that name on the way" from "cut off before it".
|
||||
*/
|
||||
truncatedAreas: number
|
||||
error: boolean
|
||||
/** The thinned line every `alongKm` above is measured along. */
|
||||
spine: LatLng[]
|
||||
search: () => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* How many bounding boxes one search may ask Overpass for. A German-to-Italian drive
|
||||
* would otherwise be dozens of requests against a shared public mirror; past this the
|
||||
* search stops and says so rather than quietly covering half the route.
|
||||
*/
|
||||
const MAX_TILES = 16
|
||||
/** Gap after a request before the same worker starts the next — the mirrors are shared. */
|
||||
const REQUEST_SPACING_MS = 250
|
||||
/** How many boxes are in flight at once. */
|
||||
const CONCURRENT_REQUESTS = 5
|
||||
|
||||
const sleep = (ms: number, signal: AbortSignal): Promise<void> =>
|
||||
new Promise(resolve => {
|
||||
if (signal.aborted) { resolve(); return }
|
||||
const timer = setTimeout(done, ms)
|
||||
function done(): void {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener('abort', done)
|
||||
resolve()
|
||||
}
|
||||
signal.addEventListener('abort', done, { once: true })
|
||||
})
|
||||
|
||||
/**
|
||||
* "What is on the way" for a drawn route.
|
||||
*
|
||||
* Deliberately manual: the search runs when the user asks for it, never on pan or on
|
||||
* every route change, because each run is several Overpass requests and the mirrors are
|
||||
* shared. Results stay until the categories or the route change.
|
||||
*/
|
||||
export function useCorridorPois(line: LatLng[], categories: string[], widthKm: number): CorridorSearch {
|
||||
const { locale } = useTranslation()
|
||||
const [results, setResults] = useState<CorridorPoi[]>([])
|
||||
const [progress, setProgress] = useState({ done: 0, total: 0 })
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [capped, setCapped] = useState(false)
|
||||
const [failedAreas, setFailedAreas] = useState(0)
|
||||
const [truncatedAreas, setTruncatedAreas] = useState(0)
|
||||
const [error, setError] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const runIdRef = useRef(0)
|
||||
|
||||
/**
|
||||
* The routed line, thinned. A corridor built from every vertex would make hundreds of
|
||||
* overlapping boxes out of what a handful describe just as well.
|
||||
*
|
||||
* Shared rather than local to the search, because every `alongKm` in the results is a
|
||||
* distance along THIS line. Anything comparing a hit against the day's own stops has to
|
||||
* project them onto the same one, or the two sets of numbers do not mean the same thing.
|
||||
*/
|
||||
const spine = useMemo(() => simplifyLine(line, Math.max(1, widthKm / 3)), [line, widthKm])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
setResults([])
|
||||
setProgress({ done: 0, total: 0 })
|
||||
setLoading(false)
|
||||
setCapped(false)
|
||||
setFailedAreas(0)
|
||||
setTruncatedAreas(0)
|
||||
setError(false)
|
||||
}, [])
|
||||
|
||||
// A changed route or category set makes the old answers wrong, not stale — drop them.
|
||||
const routeKey = line.length ? `${line.length}:${line[0].lat.toFixed(3)},${line[0].lng.toFixed(3)}` : ''
|
||||
const catKey = categories.join(',')
|
||||
useEffect(() => { clear() }, [routeKey, catKey, widthKm, clear])
|
||||
|
||||
const search = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
if (spine.length < 2 || categories.length === 0) return
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const runId = ++runIdRef.current
|
||||
|
||||
const allTiles = corridorTiles(spine, widthKm)
|
||||
const tiles = allTiles.slice(0, MAX_TILES)
|
||||
|
||||
setCapped(allTiles.length > tiles.length)
|
||||
setFailedAreas(0)
|
||||
setTruncatedAreas(0)
|
||||
setError(false)
|
||||
setResults([])
|
||||
setLoading(true)
|
||||
setProgress({ done: 0, total: tiles.length })
|
||||
|
||||
// All the wanted kinds in one query per box. Asking per kind was the same answer for
|
||||
// four times the requests, and every extra request is another chance for a shared
|
||||
// mirror to time out — which is what made a search both slow and patchy.
|
||||
const wanted = categories.join(',')
|
||||
const jobs = [...tiles]
|
||||
|
||||
void (async () => {
|
||||
const seen = new Map<string, CorridorPoi>()
|
||||
let failures = 0
|
||||
let shortened = 0
|
||||
let done = 0
|
||||
let next = 0
|
||||
|
||||
// Several at a time. Following the real road rather than the line between stops
|
||||
// means more boxes to cover the same drive, and asking for them one after another
|
||||
// made a search take the better part of a minute. The server races several
|
||||
// Overpass mirrors per request and caches what comes back, so the boxes overlap
|
||||
// cheaply; the small gap afterwards keeps one worker from queueing straight up.
|
||||
const collect = async (tile: Bbox): Promise<boolean> => {
|
||||
try {
|
||||
const data = await mapsApi.pois(wanted, tile, locale, controller.signal)
|
||||
// The server answers with at most a few hundred per box and says so. A hit
|
||||
// dropped there is invisible here, so the count has to reach the surface.
|
||||
if (data.truncated) setTruncatedAreas(++shortened)
|
||||
for (const poi of data.pois) {
|
||||
if (seen.has(poi.osm_id)) continue
|
||||
const hit = projectOntoRoute({ lat: poi.lat, lng: poi.lng }, spine)
|
||||
if (!hit || hit.offRouteKm > widthKm) continue
|
||||
seen.set(poi.osm_id, { ...poi, offRouteKm: hit.offRouteKm, alongKm: hit.alongKm })
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const publish = (): void => {
|
||||
setProgress({ done, total: jobs.length })
|
||||
setResults([...seen.values()].sort((a, b) => a.alongKm - b.alongKm))
|
||||
}
|
||||
|
||||
const retryable: Bbox[] = []
|
||||
const worker = async (): Promise<void> => {
|
||||
while (next < jobs.length) {
|
||||
if (controller.signal.aborted || runId !== runIdRef.current) return
|
||||
const job = jobs[next++]
|
||||
if (!(await collect(job))) retryable.push(job)
|
||||
done++
|
||||
if (controller.signal.aborted || runId !== runIdRef.current) return
|
||||
publish()
|
||||
await sleep(REQUEST_SPACING_MS, controller.signal)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENT_REQUESTS, jobs.length) }, worker))
|
||||
if (controller.signal.aborted || runId !== runIdRef.current) return
|
||||
|
||||
// One more pass over what did not answer. Overpass mirrors time out under load far
|
||||
// more often than they are truly empty, and reporting "4 stretches went unsearched"
|
||||
// when a second ask would have answered them is the wrong end of the trade.
|
||||
for (const job of retryable) {
|
||||
if (controller.signal.aborted || runId !== runIdRef.current) return
|
||||
if (await collect(job)) continue
|
||||
failures++
|
||||
setFailedAreas(failures)
|
||||
}
|
||||
publish()
|
||||
setError(failures === jobs.length && failures > 0)
|
||||
setLoading(false)
|
||||
})()
|
||||
}, [spine, categories, widthKm, locale])
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), [])
|
||||
|
||||
return { results, progress, loading, capped, failedAreas, truncatedAreas, error, spine, search, clear }
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
|
||||
import type { RoadtripVias } from './useRoadtripVias'
|
||||
import type { TrackPlace } from './useFollowTrack'
|
||||
|
||||
const { calculateRouteWithLegs } = vi.hoisted(() => ({ calculateRouteWithLegs: vi.fn() }))
|
||||
vi.mock('../Map/RouteCalculator', () => ({ calculateRouteWithLegs }))
|
||||
|
||||
import { useFollowTrack } from './useFollowTrack'
|
||||
|
||||
/**
|
||||
* The hook is the part that talks to two services and has to survive both of them going
|
||||
* wrong halfway. The refinement itself is `followTrack`'s business and tested there; what
|
||||
* matters here is what reaches the day and what reaches the traveller.
|
||||
*/
|
||||
|
||||
const START = { lat: 52, lng: 13 }
|
||||
const END = { lat: 52, lng: 14 }
|
||||
|
||||
/** A track arching north away from the straight line between the day's two stops. */
|
||||
function arc(points = 60, bulgeDeg = 0.1): [number, number][] {
|
||||
return Array.from({ length: points + 1 }, (_, i) => [
|
||||
52 + bulgeDeg * Math.sin((Math.PI * i) / points),
|
||||
13 + i / points,
|
||||
])
|
||||
}
|
||||
|
||||
function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
return {
|
||||
dayId: 7, dayNumber: 1, date: null, title: null,
|
||||
stops: [
|
||||
{ assignmentId: 1, placeId: 10, name: 'A', lat: START.lat, lng: START.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 2, placeId: 20, name: 'B', lat: END.lat, lng: END.lng, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
],
|
||||
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
|
||||
geometry: [[START.lat, START.lng], [END.lat, END.lng]],
|
||||
distance: 0, duration: 0,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
|
||||
days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
|
||||
})
|
||||
|
||||
function viasStub(over: Partial<RoadtripVias> = {}): RoadtripVias {
|
||||
return {
|
||||
byDay: {},
|
||||
trackByDay: {},
|
||||
stale: false,
|
||||
editable: true,
|
||||
add: vi.fn(),
|
||||
addMany: vi.fn().mockResolvedValue(undefined),
|
||||
move: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
reanchor: vi.fn(),
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const TRACK: TrackPlace = { id: 3, name: 'Atlantic Road', route_geometry: JSON.stringify(arc()), route_color: '#0ea5e9' }
|
||||
const PLAIN: TrackPlace = { id: 4, name: 'A hotel', route_geometry: null }
|
||||
const FAR: TrackPlace = { id: 5, name: 'Somewhere else', route_geometry: JSON.stringify(arc().map(([la, ln]) => [la + 4, ln])) }
|
||||
|
||||
/**
|
||||
* A router with no idea about roads: it drives straight from waypoint to waypoint.
|
||||
*
|
||||
* Which is exactly what makes the refinement work for its living — each via added bends
|
||||
* the answer a little closer to the arc, so the loop converges instead of running to its
|
||||
* cap the way a fixed answer would.
|
||||
*/
|
||||
function answersStraight(): void {
|
||||
calculateRouteWithLegs.mockImplementation(async (waypoints: { lat: number; lng: number }[]) => ({
|
||||
coordinates: waypoints.map(w => [w.lat, w.lng]),
|
||||
distance: 0, duration: 0, legs: [],
|
||||
}))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calculateRouteWithLegs.mockReset()
|
||||
})
|
||||
|
||||
describe('useFollowTrack', () => {
|
||||
it('FE-FOLLOWHOOK-001: nothing is parsed while the dialog is closed', () => {
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK, PLAIN], routes([day()]), viasStub()))
|
||||
expect(result.current.dayId).toBeNull()
|
||||
// Tens of thousands of points per track, for a dialog nobody opened.
|
||||
expect(result.current.tracks).toEqual([])
|
||||
// But it still knows there is something to open, without parsing a single point.
|
||||
expect(result.current.available).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-011: the track a day follows is read back from the day, not remembered', () => {
|
||||
// The whole point of storing it: a field that only ever held what this session did
|
||||
// would look right until somebody refreshed the page.
|
||||
const vias = viasStub({ trackByDay: { 7: { day_id: 7, place_id: 3, stray_km: 0.8 } } });
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
|
||||
expect(result.current.namesByDay).toEqual({ 7: 'Atlantic Road' })
|
||||
act(() => { result.current.open(7) })
|
||||
expect(result.current.current).toEqual({ name: 'Atlantic Road', strayKm: 0.8 })
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-012: a stored track whose place is gone names nothing', () => {
|
||||
// The row cascades with the place, so this is belt and braces — but a label that
|
||||
// outlived its line would be a day claiming to follow something invisible.
|
||||
const vias = viasStub({ trackByDay: { 7: { day_id: 7, place_id: 999, stray_km: null } } })
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
expect(result.current.current).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-010: a trip that imported nothing offers no way in', () => {
|
||||
const { result } = renderHook(() => useFollowTrack(1, [PLAIN], routes([day()]), viasStub()))
|
||||
expect(result.current.available).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-002: opening lists only the places that carry a line, nearest first', () => {
|
||||
const { result } = renderHook(() => useFollowTrack(1, [FAR, PLAIN, TRACK], routes([day()]), viasStub()))
|
||||
act(() => { result.current.open(7) })
|
||||
|
||||
expect(result.current.tracks.map(t => t.id)).toEqual([3, 5])
|
||||
expect(result.current.tracks[0].name).toBe('Atlantic Road')
|
||||
expect(result.current.tracks[0].color).toBe('#0ea5e9')
|
||||
expect(result.current.tracks[0].lengthKm).toBeGreaterThan(60)
|
||||
expect(result.current.tracks[0].gapKm).toBeLessThan(result.current.tracks[1].gapKm)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-003: applying writes the plan onto the day, legs and all', async () => {
|
||||
answersStraight()
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
expect(vias.addMany).toHaveBeenCalledTimes(1)
|
||||
const [dayId, written, legs, track] = (vias.addMany as ReturnType<typeof vi.fn>).mock.calls[0]
|
||||
expect(dayId).toBe(7)
|
||||
expect(legs).toEqual([0])
|
||||
// The chain and the name it came from land in one write: a day claiming to follow a
|
||||
// road whose vias never arrived would be worse than a day claiming nothing.
|
||||
expect(track).toMatchObject({ place_id: 3 })
|
||||
expect(written.length).toBeGreaterThan(0)
|
||||
// Every via hangs behind the first stop, because the day has exactly one leg.
|
||||
expect(written.every((v: { after_order_index: number }) => v.after_order_index === 0)).toBe(true)
|
||||
expect(result.current.outcome?.vias).toBe(written.length)
|
||||
expect(result.current.busy).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-004: a track the drive already follows writes nothing but says so', async () => {
|
||||
// The router answers with the track itself, so there is nothing left to pull onto.
|
||||
calculateRouteWithLegs.mockResolvedValue({ coordinates: arc(), distance: 0, duration: 0, legs: [] })
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
expect(result.current.outcome).toEqual({ vias: 0, strayKm: 0, capped: false })
|
||||
// Still a write: the legs are cleared so a second track does not stack, and the name
|
||||
// is recorded even though the drive needed no bending to follow it.
|
||||
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0], { place_id: 3, stray_km: 0 })
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-005: a routing outage is reported rather than written as an empty plan', async () => {
|
||||
calculateRouteWithLegs.mockRejectedValue(new Error('429'))
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
expect(result.current.error).toBe('route')
|
||||
expect(result.current.busy).toBe(false)
|
||||
expect(vias.addMany).not.toHaveBeenCalled()
|
||||
// One retry, because a refinement is several calls against a rate-limited host.
|
||||
expect(calculateRouteWithLegs).toHaveBeenCalledTimes(2)
|
||||
}, 10000)
|
||||
|
||||
it('FE-FOLLOWHOOK-006: a save that fails says so instead of claiming the day changed', async () => {
|
||||
answersStraight()
|
||||
const vias = viasStub({ addMany: vi.fn().mockRejectedValue(new Error('boom')) })
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
expect(result.current.error).toBe('save')
|
||||
expect(result.current.outcome).toBeNull()
|
||||
expect(result.current.busy).toBe(false)
|
||||
}, 10000)
|
||||
|
||||
it('FE-FOLLOWHOOK-007: clearing drops the vias of every leg of the day', async () => {
|
||||
const vias = viasStub({ byDay: { 7: [{ id: 1 }, { id: 2 }] as never } })
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
expect(result.current.viaCount).toBe(2)
|
||||
|
||||
await act(async () => { await result.current.clear() })
|
||||
// null, not nothing: dropping the points is also dropping the claim.
|
||||
expect(vias.addMany).toHaveBeenCalledWith(7, [], [0], null)
|
||||
expect(result.current.outcome).toEqual({ vias: 0, strayKm: 0, capped: false })
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-008: closing abandons a run rather than letting it land afterwards', async () => {
|
||||
let release: ((value: unknown) => void) | null = null
|
||||
calculateRouteWithLegs.mockImplementation(() => new Promise(resolve => { release = resolve }))
|
||||
const vias = viasStub()
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([day()]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
|
||||
let running: Promise<void> | null = null
|
||||
act(() => { running = result.current.apply(3) })
|
||||
await waitFor(() => { expect(result.current.busy).toBe(true) })
|
||||
|
||||
act(() => { result.current.close() })
|
||||
await act(async () => {
|
||||
release?.({ coordinates: [[START.lat, START.lng], [END.lat, END.lng]], distance: 0, duration: 0, legs: [] })
|
||||
await running
|
||||
})
|
||||
|
||||
// The day is left exactly as it was: an abandoned plan is not half a chain of vias.
|
||||
expect(vias.addMany).not.toHaveBeenCalled()
|
||||
expect(result.current.dayId).toBeNull()
|
||||
expect(result.current.error).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-FOLLOWHOOK-009: a day with nothing to drive between is not applied to', async () => {
|
||||
answersStraight()
|
||||
const vias = viasStub()
|
||||
const lonely = day({ stops: [day().stops[0]] })
|
||||
const { result } = renderHook(() => useFollowTrack(1, [TRACK], routes([lonely]), vias))
|
||||
act(() => { result.current.open(7) })
|
||||
await act(async () => { await result.current.apply(3) })
|
||||
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
expect(vias.addMany).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs } from '../Map/RouteCalculator'
|
||||
import { lineLengthKm, parseTrack, planTrack, trackGapKm, type FollowTrackPlan } from './followTrack'
|
||||
import type { LatLng } from './corridor'
|
||||
import type { RoadtripRoutes } from './useRoadtripRoutes'
|
||||
import type { RoadtripVias } from './useRoadtripVias'
|
||||
|
||||
/**
|
||||
* The dialog state behind "this day follows that track".
|
||||
*
|
||||
* Kept out of the dialog because applying one is a long job — a round trip to the routing
|
||||
* host per refinement — and a component that unmounts halfway would leave the day with
|
||||
* half a chain of vias on it. Here it can be aborted, and here it can say how far it has
|
||||
* got while it runs.
|
||||
*/
|
||||
|
||||
/** A track of this trip, ready to be offered. */
|
||||
export interface TrackChoice {
|
||||
id: number
|
||||
name: string
|
||||
/** The colour the map already draws it in, so the list and the line agree. */
|
||||
color: string | null
|
||||
points: LatLng[]
|
||||
lengthKm: number
|
||||
/** How far the track runs from the open day at its closest — what orders the list. */
|
||||
gapKm: number
|
||||
}
|
||||
|
||||
/** What became of an applied track. */
|
||||
export interface FollowTrackOutcome {
|
||||
vias: number
|
||||
strayKm: number
|
||||
capped: boolean
|
||||
}
|
||||
|
||||
export interface FollowTrack {
|
||||
/** The day the dialog is open for, or null when it is closed. */
|
||||
dayId: number | null
|
||||
open: (dayId: number) => void
|
||||
close: () => void
|
||||
/** The trip's tracks, nearest to this day first. Empty until the dialog opens. */
|
||||
tracks: TrackChoice[]
|
||||
busy: boolean
|
||||
/** Which refinement round is running, for a dialog that has to say something. */
|
||||
round: number
|
||||
error: 'route' | 'save' | null
|
||||
outcome: FollowTrackOutcome | null
|
||||
apply: (trackId: number) => Promise<void>
|
||||
/** Drops every via of the open day, so it drives the way the router would. */
|
||||
clear: () => Promise<void>
|
||||
/** How many vias the open day carries — what makes clearing worth offering. */
|
||||
viaCount: number
|
||||
/**
|
||||
* The track the open day follows, if it follows one.
|
||||
*
|
||||
* Read back from the day rather than remembered from the run that applied it: the point
|
||||
* of storing it at all is that it survives a reload, and a field that only ever held
|
||||
* what this session did would look right until somebody refreshed.
|
||||
*/
|
||||
current: { name: string; strayKm: number | null } | null
|
||||
/** The name of the track each day follows, for the rail's badge. */
|
||||
namesByDay: Record<number, string>
|
||||
/**
|
||||
* Whether the trip holds a track at all.
|
||||
*
|
||||
* Read off the column rather than off `tracks`, which is empty until the dialog opens
|
||||
* and would therefore never let it be opened. It is what keeps the rail from offering
|
||||
* a control whose only possible answer is "no tracks in this trip".
|
||||
*/
|
||||
available: boolean
|
||||
}
|
||||
|
||||
/** A place is a track when it carries a line. */
|
||||
export interface TrackPlace {
|
||||
id: number
|
||||
name?: string | null
|
||||
route_geometry?: string | null
|
||||
route_color?: string | null
|
||||
}
|
||||
|
||||
/** Waiting a beat between rounds: the public routing hosts allow about one call a second. */
|
||||
export const ROUND_PAUSE_MS = 400
|
||||
|
||||
/** How long to wait before the one retry a round gets. */
|
||||
export const RETRY_PAUSE_MS = 1500
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise(resolve => { setTimeout(resolve, ms) })
|
||||
|
||||
export function useFollowTrack(
|
||||
tripId: number | string | null,
|
||||
places: TrackPlace[],
|
||||
routes: RoadtripRoutes,
|
||||
vias: RoadtripVias,
|
||||
): FollowTrack {
|
||||
const [dayId, setDayId] = useState<number | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [round, setRound] = useState(0)
|
||||
const [error, setError] = useState<'route' | 'save' | null>(null)
|
||||
const [outcome, setOutcome] = useState<FollowTrackOutcome | null>(null)
|
||||
const abort = useRef<AbortController | null>(null)
|
||||
|
||||
const day = useMemo(() => routes.days.find(d => d.dayId === dayId), [routes.days, dayId])
|
||||
const stops = useMemo<LatLng[]>(() => (day?.stops ?? []).map(s => ({ lat: s.lat, lng: s.lng })), [day])
|
||||
|
||||
/**
|
||||
* Parsed only while the dialog is open.
|
||||
*
|
||||
* A track runs to tens of thousands of points and a trip can hold a dozen of them, so
|
||||
* parsing them to render a rail nobody opened would cost more than the rail does. The
|
||||
* memo hangs off `dayId` as well as `places` for exactly that reason.
|
||||
*/
|
||||
const tracks = useMemo<TrackChoice[]>(() => {
|
||||
if (dayId === null) return []
|
||||
const found: TrackChoice[] = []
|
||||
for (const place of places) {
|
||||
const points = parseTrack(place.route_geometry)
|
||||
if (points.length < 2) continue
|
||||
found.push({
|
||||
id: place.id,
|
||||
name: place.name || '',
|
||||
color: place.route_color ?? null,
|
||||
points,
|
||||
lengthKm: lineLengthKm(points),
|
||||
gapKm: trackGapKm(points, stops),
|
||||
})
|
||||
}
|
||||
// Nearest first: a trip carrying every track of a three-week tour should still open
|
||||
// on the one that runs along today's drive.
|
||||
return found.sort((a, b) => a.gapKm - b.gapKm)
|
||||
}, [dayId, places, stops])
|
||||
|
||||
const open = useCallback((next: number) => {
|
||||
setDayId(next)
|
||||
setOutcome(null)
|
||||
setError(null)
|
||||
setRound(0)
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => {
|
||||
abort.current?.abort()
|
||||
abort.current = null
|
||||
setDayId(null)
|
||||
setBusy(false)
|
||||
setRound(0)
|
||||
}, [])
|
||||
|
||||
const apply = useCallback(async (trackId: number) => {
|
||||
const track = tracks.find(t => t.id === trackId)
|
||||
if (!track || dayId === null || stops.length < 2 || busy) return
|
||||
|
||||
const controller = new AbortController()
|
||||
abort.current = controller
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setOutcome(null)
|
||||
setRound(0)
|
||||
|
||||
let plan: FollowTrackPlan
|
||||
let spent = 0
|
||||
try {
|
||||
plan = await planTrack({
|
||||
track: track.points,
|
||||
stops,
|
||||
signal: controller.signal,
|
||||
onRound: n => { setRound(n) },
|
||||
route: async waypoints => {
|
||||
// Paced before the call rather than after it, so a plan that fits on its first
|
||||
// round costs nothing in waiting — and given one second chance, because a
|
||||
// refinement is several calls in a row against a host that allows about one a
|
||||
// second, and losing the whole loop to a single 429 would restart it at nothing.
|
||||
if (spent > 0) await sleep(ROUND_PAUSE_MS)
|
||||
spent += 1
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const r = await calculateRouteWithLegs(waypoints, {
|
||||
signal: controller.signal,
|
||||
profile: 'driving',
|
||||
tripId,
|
||||
dayId,
|
||||
})
|
||||
return r.coordinates.map(([lat, lng]) => ({ lat, lng }))
|
||||
} catch (err) {
|
||||
if (attempt >= 1 || controller.signal.aborted) throw err
|
||||
await sleep(RETRY_PAUSE_MS)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
if (!controller.signal.aborted) setError('route')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) { setBusy(false); return }
|
||||
|
||||
try {
|
||||
// The chain and the name it came from, in one write: a day claiming to follow a
|
||||
// road whose vias never landed would be worse than a day claiming nothing.
|
||||
await vias.addMany(dayId, plan.vias, plan.legs, { place_id: trackId, stray_km: plan.strayKm })
|
||||
setOutcome({ vias: plan.vias.length, strayKm: plan.strayKm, capped: plan.capped })
|
||||
} catch {
|
||||
setError('save')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [tracks, dayId, stops, busy, tripId, vias])
|
||||
|
||||
const viaCount = dayId === null ? 0 : (vias.byDay[dayId]?.length ?? 0)
|
||||
|
||||
/**
|
||||
* The name behind each day's stored track id.
|
||||
*
|
||||
* A lookup over `places` rather than a parse: naming a line costs a row, and the rail
|
||||
* asks for this on every render.
|
||||
*/
|
||||
const namesByDay = useMemo(() => {
|
||||
const names: Record<number, string> = {}
|
||||
const byId = new Map(places.map(p => [p.id, p]))
|
||||
for (const [day, track] of Object.entries(vias.trackByDay)) {
|
||||
const place = byId.get(track.place_id)
|
||||
if (place?.name) names[Number(day)] = place.name
|
||||
}
|
||||
return names
|
||||
}, [places, vias.trackByDay])
|
||||
|
||||
const current = useMemo(() => {
|
||||
if (dayId === null) return null
|
||||
const track = vias.trackByDay[dayId]
|
||||
const name = namesByDay[dayId]
|
||||
return track && name ? { name, strayKm: track.stray_km } : null
|
||||
}, [dayId, vias.trackByDay, namesByDay])
|
||||
// A cheap look at the column, not a parse: this runs on every planner render, and the
|
||||
// question is only whether anything at all was ever imported.
|
||||
const available = useMemo(() => places.some(p => (p.route_geometry?.length ?? 0) > 2), [places])
|
||||
|
||||
const clear = useCallback(async () => {
|
||||
if (dayId === null || busy) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
// Every leg of the day, so nothing is left behind on a leg the track never reached.
|
||||
const legs = Array.from({ length: Math.max(0, stops.length - 1) }, (_, i) => i)
|
||||
// null rather than nothing: dropping the points is also dropping the claim.
|
||||
await vias.addMany(dayId, [], legs, null)
|
||||
setOutcome({ vias: 0, strayKm: 0, capped: false })
|
||||
} catch {
|
||||
setError('save')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [dayId, busy, stops.length, vias])
|
||||
|
||||
return { dayId, open, close, tracks, busy, round, error, outcome, apply, clear, viaCount, available, current, namesByDay }
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { CorridorPoi, CorridorSearch } from './useCorridorPois'
|
||||
import type { RoadtripDay, RoadtripRoutes } from './useRoadtripRoutes'
|
||||
|
||||
const { useCorridorPois } = vi.hoisted(() => ({ useCorridorPois: vi.fn() }))
|
||||
vi.mock('./useCorridorPois', () => ({ useCorridorPois }))
|
||||
|
||||
import { useRoadtripCorridor } from './useRoadtripCorridor'
|
||||
|
||||
function poi(over: Partial<CorridorPoi> & { osm_id: string; name: string }): CorridorPoi {
|
||||
return {
|
||||
lat: 53, lng: 10, category: 'fuel', poi_type: 'fuel', address: null, website: null,
|
||||
phone: null, opening_hours: null, cuisine: null, source: 'openstreetmap',
|
||||
offRouteKm: 1, alongKm: 10, brand: null, brand_wikidata: null,
|
||||
...over,
|
||||
} as CorridorPoi
|
||||
}
|
||||
|
||||
function day(over: Partial<RoadtripDay> = {}): RoadtripDay {
|
||||
return {
|
||||
dayId: 1, dayNumber: 1, date: null, title: null,
|
||||
stops: [
|
||||
{ assignmentId: 1, placeId: 10, name: 'A', lat: 53.5, lng: 9.9, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
{ assignmentId: 2, placeId: 20, name: 'B', lat: 52.5, lng: 13.4, time: null, dwellMinutes: null, legMode: null, incomingLegMode: null, stopType: null },
|
||||
],
|
||||
legs: [], legVias: [], driveWarnings: [], dayWarning: null, schedule: { entries: [], warnings: [] },
|
||||
geometry: [[53.5, 9.9], [53.0, 11.0], [52.5, 13.4]],
|
||||
distance: 0, duration: 0,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const routes = (days: RoadtripDay[]): RoadtripRoutes => ({
|
||||
days, lines: [], segments: [], accessLines: [], vias: [], totalDistance: 0, totalDuration: 0, totalStops: 0, quietDays: [], loading: false,
|
||||
})
|
||||
|
||||
/** The search state the mocked hook hands back, results included. */
|
||||
function searchWith(results: CorridorPoi[]): CorridorSearch {
|
||||
return {
|
||||
results,
|
||||
progress: { done: 0, total: 0 },
|
||||
loading: false,
|
||||
capped: false,
|
||||
failedAreas: 0,
|
||||
truncatedAreas: 0,
|
||||
error: false,
|
||||
// The thinned line the day's geometry becomes; hits and stops are measured along it.
|
||||
spine: [{ lat: 53.5, lng: 9.9 }, { lat: 53.0, lng: 11.0 }, { lat: 52.5, lng: 13.4 }],
|
||||
search: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useCorridorPois.mockReset()
|
||||
})
|
||||
|
||||
describe('useRoadtripCorridor', () => {
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-001: with no filter everything found is visible', () => {
|
||||
const hits = [poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]
|
||||
useCorridorPois.mockReturnValue(searchWith(hits))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
|
||||
expect(result.current.visible).toBe(hits)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-002: the filter matches part of a name, ignoring case', () => {
|
||||
useCorridorPois.mockReturnValue(searchWith([
|
||||
poi({ osm_id: 'a', name: 'Aral Autohof' }),
|
||||
poi({ osm_id: 'b', name: 'Shell Nord' }),
|
||||
]))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
act(() => result.current.setNameFilter('shell'))
|
||||
|
||||
expect(result.current.visible.map(p => p.osm_id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-003: the brand counts as a name, because the server folds the operator into it', () => {
|
||||
useCorridorPois.mockReturnValue(searchWith([
|
||||
poi({ osm_id: 'a', name: 'Autohof Nord', brand: 'Shell' }),
|
||||
poi({ osm_id: 'b', name: 'Raststätte Süd', brand: 'Aral' }),
|
||||
]))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
act(() => result.current.setNameFilter('Shell'))
|
||||
|
||||
expect(result.current.visible.map(p => p.osm_id)).toEqual(['a'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-004: whitespace alone is not a filter', () => {
|
||||
const hits = [poi({ osm_id: 'a', name: 'Aral' })]
|
||||
useCorridorPois.mockReturnValue(searchWith(hits))
|
||||
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
act(() => result.current.setNameFilter(' '))
|
||||
|
||||
expect(result.current.visible).toBe(hits)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-005: starting a new search clears the old question', () => {
|
||||
useCorridorPois.mockReturnValue(searchWith([poi({ osm_id: 'a', name: 'Aral' })]))
|
||||
|
||||
const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
act(() => result.current.setNameFilter('aral'))
|
||||
expect(result.current.nameFilter).toBe('aral')
|
||||
|
||||
// A search begins: keeping the term would hide the new hits behind the old query.
|
||||
useCorridorPois.mockReturnValue({ ...searchWith([]), loading: true })
|
||||
rerender()
|
||||
|
||||
expect(result.current.nameFilter).toBe('')
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-007: typing while the search is still running survives the next batch', () => {
|
||||
// Hits arrive box by box and publish as they land. Clearing on every batch instead of
|
||||
// on the start of a search wiped out whatever had been typed in the meantime.
|
||||
useCorridorPois.mockReturnValue({ ...searchWith([poi({ osm_id: 'a', name: 'Aral' })]), loading: true })
|
||||
|
||||
const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
act(() => result.current.setNameFilter('shell'))
|
||||
|
||||
useCorridorPois.mockReturnValue({
|
||||
...searchWith([poi({ osm_id: 'a', name: 'Aral' }), poi({ osm_id: 'b', name: 'Shell' })]),
|
||||
loading: true,
|
||||
})
|
||||
rerender()
|
||||
|
||||
expect(result.current.nameFilter).toBe('shell')
|
||||
expect(result.current.visible.map(p => p.osm_id)).toEqual(['b'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-006: the visible list keeps its identity while nothing changes', () => {
|
||||
useCorridorPois.mockReturnValue(searchWith([poi({ osm_id: 'a', name: 'Aral' })]))
|
||||
|
||||
const { result, rerender } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
const before = result.current.visible
|
||||
rerender()
|
||||
|
||||
// Both map renderers rebuild every marker when this array's identity changes.
|
||||
expect(result.current.visible).toBe(before)
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-020: narrowing to one point keeps only what is near it', () => {
|
||||
// A seven-hundred-kilometre day turns up hits along the whole of it. Picking the point
|
||||
// you mean to break at is a subtraction, because both figures are measured against
|
||||
// the same thinned line rather than being asked for separately.
|
||||
useCorridorPois.mockReturnValue(searchWith([
|
||||
poi({ osm_id: 'near', name: 'Near', alongKm: 30 }),
|
||||
poi({ osm_id: 'far', name: 'Far', alongKm: 600 }),
|
||||
]))
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
|
||||
act(() => { result.current.setSectionKm(50) })
|
||||
act(() => { result.current.setSection({ dayId: 1, kind: 'stop', index: 0 }) })
|
||||
|
||||
// Stop 0 sits at the start of the drive, so only the hit within 50 km survives.
|
||||
expect(result.current.visible.map(p => p.osm_id)).toEqual(['near'])
|
||||
})
|
||||
|
||||
it('FE-ROADTRIP-CORRIDORSTATE-021: an anchor belonging to another day narrows nothing', () => {
|
||||
// The section names a day as well as an index, so switching days cannot silently
|
||||
// apply yesterday's break to today's drive.
|
||||
const hits = [poi({ osm_id: 'a', name: 'A', alongKm: 10 }), poi({ osm_id: 'b', name: 'B', alongKm: 600 })]
|
||||
useCorridorPois.mockReturnValue(searchWith(hits))
|
||||
const { result } = renderHook(() => useRoadtripCorridor(routes([day()])))
|
||||
|
||||
act(() => { result.current.setSection({ dayId: 99, kind: 'stop', index: 0 }) })
|
||||
|
||||
expect(result.current.visible).toBe(hits)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user