mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-27 09:08:30 +00:00
Compare commits
205 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c735c80a4 | |||
| a8e4c45576 | |||
| 87c73e50fb | |||
| 1128bcd1c5 | |||
| 7946c2f749 | |||
| 5ef60f5641 | |||
| 9ce8b7ea9e | |||
| a9907e2f10 | |||
| a00cc889ed | |||
| 3f0eb210d8 | |||
| bd6e6312e1 | |||
| 96ab748f34 | |||
| aaf6559d5d | |||
| 5b147af0b6 | |||
| 95d1752a3d | |||
| c9e0fe52d5 | |||
| bf1900c3a0 | |||
| 59b7c35e87 | |||
| 77162aa8d5 | |||
| 29fc586930 | |||
| 7ba7338f5f | |||
| 2f71549413 | |||
| fab3d2bdf8 | |||
| 00cac02665 | |||
| 756e0fb908 | |||
| d30b916f9c | |||
| 88129e4546 | |||
| a46d3fe88a | |||
| 149b87fd47 | |||
| 15beb0ea40 | |||
| f283254bb0 | |||
| 8acfaea3c4 | |||
| a463246c1c | |||
| 2a38e79431 | |||
| 2d2f1e88bd | |||
| 1e2a5e3862 | |||
| 7e3b9fd039 | |||
| 07bb899c50 | |||
| 9543fdc3f2 | |||
| 59a1756529 | |||
| 4611f0ab7a | |||
| 6a3d4b1281 | |||
| 06ecc5135c | |||
| b2a196d1e7 | |||
| f5d6af9c77 | |||
| 9fa0ceaf49 | |||
| 900e4806ad | |||
| 4a88dbcbcf | |||
| 1761f2bcd5 | |||
| 8be594d34c | |||
| 5ac6ce40be | |||
| 2d7889c237 | |||
| cfae0a1f78 | |||
| 31061751b0 | |||
| 559b7624d0 | |||
| 2de9de8c3a | |||
| d609aefe8f | |||
| 1792fc17d0 | |||
| 72935d7e40 | |||
| 569f0ca1a5 | |||
| 5d0d106c70 | |||
| c04e740ff1 | |||
| 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 | |||
| 515398f8ee | |||
| e4f2eaf336 | |||
| 16fee0800d | |||
| 888cd4ee08 | |||
| 8fe90d1783 | |||
| 4cda3c4b2e | |||
| bc85ec1eea | |||
| 04c22b34af | |||
| 56a23cb809 | |||
| 251378be1d | |||
| 973ce5f8ff | |||
| bbc32310e9 | |||
| ad6e51c574 | |||
| 6da11994d9 | |||
| eef1f16836 | |||
| 4a8d77c59c | |||
| a70c2eb9e0 | |||
| 99b040f684 | |||
| 944e0a0970 | |||
| b9f69b0c76 | |||
| fcefc7e771 | |||
| 6f0a171c74 | |||
| 3e1c06a0ee | |||
| 1aafab83a5 | |||
| c1d0435480 | |||
| 798859517f | |||
| d3fdf78db6 | |||
| 1b71b8633d | |||
| d77f1c1758 | |||
| e09c0688ae | |||
| 0e66ae72ca | |||
| 61bef334d5 | |||
| 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')
|
||||
@@ -367,6 +387,33 @@ describe('client > dev-only contract drift checks', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-037: search-provider hits are appended to the core results and name their index', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
|
||||
http.get('/api/plugin-search', () =>
|
||||
HttpResponse.json({ places: [{ name: 'Plugin hit', source: 'plugin:demo' }] })),
|
||||
)
|
||||
|
||||
// Appended, not interleaved: the core list keeps the order it earned.
|
||||
await expect(mapsApi.search('Rome')).resolves.toEqual({
|
||||
places: [{ name: 'Core hit' }, { name: 'Plugin hit', source: 'plugin:demo' }],
|
||||
source: 'openstreetmap+plugin:demo',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-038: a failing search provider leaves the search exactly as it was', async () => {
|
||||
server.use(
|
||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [{ name: 'Core hit' }], source: 'openstreetmap' })),
|
||||
http.get('/api/plugin-search', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
||||
)
|
||||
|
||||
// The optional index being unwell must never cost the search that worked.
|
||||
await expect(mapsApi.search('Rome')).resolves.toEqual({
|
||||
places: [{ name: 'Core hit' }],
|
||||
source: 'openstreetmap',
|
||||
})
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-023: a drifting maps response is reported under its own label', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
server.use(http.post('/api/maps/search', () => HttpResponse.json({ nonsense: true })))
|
||||
|
||||
@@ -413,9 +413,22 @@ describe('client > endpoint wiring', () => {
|
||||
expect(rec.url).toBe('/api/integrations/memories/synologyphotos/albums/alb-2/photos?passphrase=p%2Fw%3F')
|
||||
})
|
||||
|
||||
/**
|
||||
* The one call in this file that is deliberately TWO requests, which is why it is not
|
||||
* in the list above: a search asks TREK's own indexes and any installed search-provider
|
||||
* plugin at the same time, and the caller gets one list back (#2221).
|
||||
*/
|
||||
it('FE-APISURF-055: mapsApi.search asks the core index and the plugin providers side by side', async () => {
|
||||
log = []
|
||||
await mapsApi.search('Rome')
|
||||
expect(log.map(r => `${r.method} ${r.url.split('?')[0]}`).sort()).toEqual([
|
||||
'GET /api/plugin-search',
|
||||
'POST /api/maps/search',
|
||||
])
|
||||
})
|
||||
|
||||
it('FE-APISURF-016: mapsApi and airportsApi map the geo endpoints', async () => {
|
||||
await assertCalls([
|
||||
{ n: 'maps.search', r: () => mapsApi.search('Rome'), e: 'POST /api/maps/search' },
|
||||
{ n: 'maps.autocomplete', r: () => mapsApi.autocomplete('Rom'), e: 'POST /api/maps/autocomplete' },
|
||||
{ n: 'maps.details', r: () => mapsApi.details('place/1'), e: 'GET /api/maps/details/place%2F1' },
|
||||
{ n: 'maps.placePhoto', r: () => mapsApi.placePhoto('place/1'), e: 'GET /api/maps/place-photo/place%2F1' },
|
||||
|
||||
+283
-13
@@ -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,16 @@ 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),
|
||||
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 +1052,193 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What plugins implementing `searchProvider` found for the same query (#2221).
|
||||
*
|
||||
* Its own request beside the core one rather than a branch inside it: the core search
|
||||
* is TREK's own indexes and must not wait on, or fail with, somebody's plugin. This
|
||||
* answers with an empty list for every failure there is — no provider installed, one
|
||||
* that timed out, a 404 on an older server, the network gone — because a search that
|
||||
* breaks when an optional index is unwell is worse than one without it.
|
||||
*/
|
||||
/**
|
||||
* How long a plugin index may keep the search list waiting, in milliseconds.
|
||||
*
|
||||
* The host gives a provider two seconds to answer and this gives the round trip a
|
||||
* little more. It is a ceiling on the WAIT, not on the provider: with no search
|
||||
* plugin installed the route answers immediately without reaching any of them, so
|
||||
* the normal case costs one local round trip and nothing else.
|
||||
*
|
||||
* A deadline rather than patience, because the alternative is a search that feels
|
||||
* broken. A list that arrives without an optional index is a smaller answer; a list
|
||||
* that arrives four seconds late is no answer at all.
|
||||
*/
|
||||
const PLUGIN_SEARCH_DEADLINE_MS = 2500
|
||||
|
||||
async function pluginSearchPlaces(
|
||||
query: string,
|
||||
lang?: string,
|
||||
near?: { lat: number; lng: number },
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const stop = new AbortController()
|
||||
const timer = setTimeout(() => stop.abort(), PLUGIN_SEARCH_DEADLINE_MS)
|
||||
try {
|
||||
const r = await apiClient.get('/plugin-search', {
|
||||
params: { q: query, lang: lang || 'en', lat: near?.lat, lng: near?.lng },
|
||||
signal: stop.signal,
|
||||
})
|
||||
const places = (r.data as { places?: unknown })?.places
|
||||
return Array.isArray(places) ? (places as Record<string, unknown>[]) : []
|
||||
} catch {
|
||||
return []
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* Plugin indexes are asked at the same time and their hits are appended, so every
|
||||
* caller of this one function gets them without knowing they exist. Appended rather
|
||||
* than interleaved: the core list is ordered by relevance and has earned that order,
|
||||
* and a plugin's row carries its own `source` for a caller that wants to mark it.
|
||||
*/
|
||||
search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }) =>
|
||||
withCachedPlaces(query, (places) => ({ places, source: 'offline-cache' }), async () => {
|
||||
// Side by side, so the wait is the slower of the two rather than their sum. Only
|
||||
// the core call may reject: that rejection is what hands withCachedPlaces the
|
||||
// offline path, and a plugin failure must never trigger it.
|
||||
const [core, extra] = await Promise.all([
|
||||
apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query, locationBias }).then(r => checkInDev(mapsSearchResultSchema, r.data, 'maps.search')),
|
||||
pluginSearchPlaces(query, lang, locationBias),
|
||||
])
|
||||
if (extra.length === 0) return core
|
||||
const from = [...new Set(extra.map(p => String(p.source ?? 'plugin')))].join('+')
|
||||
return { places: [...core.places, ...extra], source: `${core.source}+${from}` }
|
||||
}),
|
||||
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 +1258,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),
|
||||
@@ -1179,13 +1441,19 @@ export const collabApi = {
|
||||
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
|
||||
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => postMultipart(`/trips/${tripId}/collab/notes/${noteId}/files`, formData),
|
||||
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
|
||||
getLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/links`).then(r => r.data),
|
||||
createLink: (tripId: number | string, data: { title: string; url: string; pinned?: boolean }) => apiClient.post(`/trips/${tripId}/collab/links`, data).then(r => r.data),
|
||||
updateLink: (tripId: number | string, id: number, data: { title?: string; url?: string; pinned?: boolean }) => apiClient.put(`/trips/${tripId}/collab/links/${id}`, data).then(r => r.data),
|
||||
deleteLink: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/links/${id}`).then(r => r.data),
|
||||
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
|
||||
createPoll: (tripId: number | string, data: CollabPollCreateRequest) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
||||
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex } satisfies CollabPollVoteRequest).then(r => r.data),
|
||||
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
|
||||
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
|
||||
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: CollabMessageCreateRequest | FormData, opts?: UploadOptions) => data instanceof FormData
|
||||
? postMultipart(`/trips/${tripId}/collab/messages`, data, opts)
|
||||
: apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
|
||||
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji } satisfies CollabReactionRequest).then(r => r.data),
|
||||
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).then(r => r.data),
|
||||
@@ -1219,11 +1487,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.
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { RouteUsageEntry, RouteUsageSummaryResult } from '@trek/shared'
|
||||
import { apiClient } from './client'
|
||||
|
||||
/**
|
||||
* Reports the routing counters this browser has tallied up.
|
||||
*
|
||||
* Fire and forget: it never throws and never blocks the route it is describing. A
|
||||
* failed report is a lost count, which is a far smaller problem than a map that waits
|
||||
* on a measurement.
|
||||
*
|
||||
* No client-side switch to check, unlike the shadow log. Counting is on unless an
|
||||
* operator turned it off, and that is a server setting the server enforces — a report
|
||||
* to a switched-off instance is answered with `recorded: false` and costs one request.
|
||||
*/
|
||||
export const routeUsageApi = {
|
||||
report: (entries: RouteUsageEntry[]): Promise<void> =>
|
||||
entries.length
|
||||
? apiClient.post('/route-usage/report', { entries }).then(() => undefined)
|
||||
: Promise.resolve(),
|
||||
|
||||
/** Admin only. The figures behind "could we host a router ourselves". */
|
||||
summary: (): Promise<RouteUsageSummaryResult> =>
|
||||
apiClient.get('/route-usage/summary').then(r => r.data),
|
||||
}
|
||||
@@ -20,6 +20,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'collab:note:created',
|
||||
'collab:note:updated',
|
||||
'collab:note:deleted',
|
||||
'collab:link:created',
|
||||
'collab:link:updated',
|
||||
'collab:link:deleted',
|
||||
'collab:poll:created',
|
||||
'collab:poll:voted',
|
||||
'collab:poll:closed',
|
||||
@@ -27,6 +30,11 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
|
||||
'collab:message:created',
|
||||
'collab:message:reacted',
|
||||
'collab:message:deleted',
|
||||
// Road trip shaping — components/Roadtrip/useRoadtripVias. The points a drive is
|
||||
// routed through live in that hook and nowhere else; a store slice for them would be a
|
||||
// second copy of the same list to keep in step.
|
||||
'roadtripVia:changed',
|
||||
'roadtripTrack:changed',
|
||||
// In-app notifications — hooks/useInAppNotificationListener
|
||||
'notification:new',
|
||||
'notification:updated',
|
||||
|
||||
@@ -74,11 +74,12 @@ const TYPE_META: Record<string, { icon: ComponentType<{ size?: number }>; labelK
|
||||
integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' },
|
||||
}
|
||||
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean }
|
||||
interface CollabFeatures { chat: boolean; notes: boolean; links?: boolean; polls: boolean; whatsnext: boolean }
|
||||
|
||||
const COLLAB_SUB_FEATURES = [
|
||||
{ key: 'chat', icon: MessageCircle, titleKey: 'admin.collab.chat.title', subtitleKey: 'admin.collab.chat.subtitle' },
|
||||
{ key: 'notes', icon: StickyNote, titleKey: 'admin.collab.notes.title', subtitleKey: 'admin.collab.notes.subtitle' },
|
||||
{ key: 'links', icon: Link2, titleKey: 'collab.tabs.links', subtitleKey: 'collab.links.empty' },
|
||||
{ key: 'polls', icon: BarChart3, titleKey: 'admin.collab.polls.title', subtitleKey: 'admin.collab.polls.subtitle' },
|
||||
{ key: 'whatsnext', icon: Sparkles, titleKey: 'admin.collab.whatsnext.title', subtitleKey: 'admin.collab.whatsnext.subtitle' },
|
||||
] as const
|
||||
|
||||
@@ -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
|
||||
@@ -259,6 +262,7 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag
|
||||
if (perms.includes('hook:photo-provider')) out.push({ icon: Image, label: t('admin.plugins.cap.photos') })
|
||||
if (perms.includes('hook:calendar-source')) out.push({ icon: CalendarDays, label: t('admin.plugins.cap.calendar') })
|
||||
if (perms.includes('hook:place-detail-provider')) out.push({ icon: MapPin, label: t('admin.plugins.cap.placeDetails') })
|
||||
if (perms.includes('hook:search-provider')) out.push({ icon: Search, label: t('admin.plugins.cap.search') })
|
||||
if (perms.includes('hook:trip-warning-provider')) out.push({ icon: AlertTriangle, label: t('admin.plugins.cap.warnings') })
|
||||
if (perms.includes('hook:map-layer-provider')) out.push({ icon: Route, label: t('admin.plugins.cap.mapLayers') })
|
||||
if (perms.includes('hook:route-provider')) out.push({ icon: Navigation, label: t('admin.plugins.cap.routing') })
|
||||
@@ -273,7 +277,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 +295,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 +316,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 +405,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 +481,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 +550,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 +621,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 +638,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 +698,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 +719,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 +846,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 +968,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 +1014,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 +1452,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 +1563,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 +1584,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 +1619,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 +1649,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 +1719,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 +1741,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">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { Settings2 } from 'lucide-react'
|
||||
import { Map as MapIcon, Settings2 } from 'lucide-react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -43,6 +43,8 @@ type Defaults = {
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
carto_api_key?: string
|
||||
routing_base_url?: string
|
||||
valhalla_base_url?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
@@ -119,6 +121,8 @@ 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 [valhallaBase, setValhallaBase] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
@@ -128,6 +132,8 @@ 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 || '')
|
||||
setValhallaBase(data.valhalla_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 +156,8 @@ 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 === 'valhalla_base_url') setValhallaBase('')
|
||||
if (key === 'mapbox_style' || key === 'maplibre_style') {
|
||||
const provider = normalizeProvider(defaults.map_provider)
|
||||
setMapboxStyle(provider === 'leaflet' ? '' : defaultStyleForProvider(provider))
|
||||
@@ -219,11 +227,23 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
<p className="text-sm text-content-faint" style={{ marginTop: -8 }}>
|
||||
<div>
|
||||
<p className="text-sm text-content-faint mb-4">
|
||||
{t('admin.defaultSettings.description')}
|
||||
</p>
|
||||
|
||||
{/* Two columns from xl up, the same idea as the Settings tab: as one flat list
|
||||
the map fields sat a screen below the units while the right half of the page
|
||||
stayed empty. Grouped by subject rather than by height — what a user ends up
|
||||
seeing on the left, everything that configures the map on the right.
|
||||
The columns are deliberately uneven: the left one never needs more than the
|
||||
widest option row, while the right one holds four text fields whose values
|
||||
are 60-character tile URLs. Only the column gap is set, because Section
|
||||
carries its own bottom margin and a row gap would double it once the grid
|
||||
collapses. Two cards can sit straight in the grid; a third would need the
|
||||
explicit per-column stacks the Settings tab uses, or it leaves a hole. */}
|
||||
<div className="grid grid-cols-1 gap-x-6 xl:grid-cols-[minmax(0,1fr)_minmax(0,1.35fr)] xl:items-start">
|
||||
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
|
||||
{/* Color Mode */}
|
||||
<OptionRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
|
||||
{([
|
||||
@@ -321,7 +341,9 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
</Section>
|
||||
|
||||
<Section title={t('settings.map')} icon={MapIcon}>
|
||||
{/* Map Tile URL */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
@@ -365,6 +387,54 @@ 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>
|
||||
)}
|
||||
{/* The second engine, next to the first because it is the same kind of decision
|
||||
and carries the same restart caveat. Only ever asked to leave a road class
|
||||
out of one leg, which is the one question the OSRM above refuses. */}
|
||||
{!managed && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('settings.valhallaBase')}
|
||||
<ResetButton field="valhalla_base_url" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={valhallaBase}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValhallaBase(e.target.value)}
|
||||
onBlur={() => save({ valhalla_base_url: valhallaBase.trim() })}
|
||||
placeholder="https://valhalla.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.valhallaBaseHint')}</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, {
|
||||
@@ -490,6 +560,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Fragment, useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2, AlertCircle, Download, StickyNote, ChevronDown, Receipt, Paperclip } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -8,10 +8,11 @@ import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import { saveWithReceipts } from './receiptUploads'
|
||||
import { useExchangeRates } from '../../hooks/useExchangeRates'
|
||||
import { useIsMobile } from '../../hooks/useIsMobile'
|
||||
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters'
|
||||
import { downloadBlob } from '../../utils/fileDownload'
|
||||
import { downloadBlob, openFile } from '../../utils/fileDownload'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
@@ -19,7 +20,8 @@ import { localToday } from '../Planner/today'
|
||||
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
|
||||
import type { BudgetItem, BudgetItemReceipt } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
import GuestBadge from '../shared/GuestBadge'
|
||||
import { NumericInput } from '../shared/NumericInput'
|
||||
@@ -84,6 +86,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
const [dayFilter, setDayFilter] = useState('') // '' = all days, else YYYY-MM-DD
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||
const [previewReceipts, setPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
|
||||
// One note open at a time: two expanded rows next to each other read as a mess,
|
||||
// and the point of the collapse is that the list stays scannable.
|
||||
const [expandedNoteId, setExpandedNoteId] = useState<number | null>(null)
|
||||
@@ -123,7 +126,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 +143,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)
|
||||
@@ -489,6 +495,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
||||
)}
|
||||
|
||||
{previewReceipts && (
|
||||
<ReceiptPreviewModal
|
||||
receipts={previewReceipts.receipts}
|
||||
initialIndex={previewReceipts.initialIndex}
|
||||
onClose={() => setPreviewReceipts(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.costs-root {
|
||||
--c-bg: #f8fafc; --c-bg2: oklch(0.965 0.01 70);
|
||||
@@ -759,6 +773,32 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
{t('costs.unfinished')}
|
||||
</span>
|
||||
)}
|
||||
{(e.receipts || []).length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation()
|
||||
setPreviewReceipts({ receipts: e.receipts!, initialIndex: 0 })
|
||||
}}
|
||||
title={t('costs.viewReceipt')}
|
||||
className="bg-surface-secondary border border-edge text-content-muted hover:text-content hover:border-content-faint transition-all"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 999,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Receipt size={12} className="text-content-muted" />
|
||||
<span>{t('costs.receipts') || 'Beleg'}{e.receipts!.length > 1 ? ` (${e.receipts!.length})` : ''}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{cur !== base && (
|
||||
<div className="text-content-faint" style={{ marginTop: 4, fontSize: 'calc(12px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
@@ -1119,6 +1159,24 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
return m
|
||||
})
|
||||
|
||||
const [receipts, setReceipts] = useState<BudgetItemReceipt[]>(() => editing?.receipts || [])
|
||||
const [pendingReceiptFiles, setPendingReceiptFiles] = useState<File[]>([])
|
||||
const [uploadingReceipt, setUploadingReceipt] = useState(false)
|
||||
const [modalPreviewReceipts, setModalPreviewReceipts] = useState<{ receipts: BudgetItemReceipt[]; initialIndex: number } | null>(null)
|
||||
|
||||
const handleReceiptFileSelect = (files: FileList | File[] | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
setPendingReceiptFiles(prev => [...prev, ...Array.from(files)])
|
||||
}
|
||||
|
||||
const handleRemoveReceipt = (receiptId: number) => {
|
||||
setReceipts(prev => prev.filter(r => r.id !== receiptId))
|
||||
}
|
||||
|
||||
const handleRemovePendingReceipt = (index: number) => {
|
||||
setPendingReceiptFiles(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const isTicketMode = splitMode === 'ticket'
|
||||
@@ -1315,12 +1373,23 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
...(!editing && prefill?.placeId ? { place_id: prefill.placeId } : {}),
|
||||
}
|
||||
try {
|
||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||
else await addBudgetItem(tripId, data)
|
||||
setUploadingReceipt(pendingReceiptFiles.length > 0)
|
||||
await saveWithReceipts(tripId, pendingReceiptFiles, editing ? editing.id : null, ids => (
|
||||
editing
|
||||
? updateBudgetItem(tripId, editing.id, { ...data, receipt_file_ids: [...receipts.map(r => r.id), ...ids] })
|
||||
: addBudgetItem(tripId, { ...data, receipt_file_ids: ids })
|
||||
))
|
||||
// Only cleared once the save went through, so a retry after a failure
|
||||
// does not upload a second copy of every file.
|
||||
setPendingReceiptFiles([])
|
||||
onSaved()
|
||||
} catch {
|
||||
toast.error(t('common.unknownError'))
|
||||
} catch (err) {
|
||||
// A receipt the rollback could not remove is still on the trip, and the
|
||||
// user is the only one who can clear it out of the Files tab.
|
||||
const stuck = (err as { stuckReceiptIds?: number[] })?.stuckReceiptIds
|
||||
toast.error(stuck?.length ? t('costs.receiptLeftBehind', { count: stuck.length }) : t('common.unknownError'))
|
||||
} finally {
|
||||
setUploadingReceipt(false)
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
@@ -1630,8 +1699,91 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
placeholder={t('costs.notePlaceholder')} maxLength={NOTE_MAX}
|
||||
className={inputCls} style={{ borderRadius: 10, padding: '10px 13px', fontSize: 'calc(13.5px * var(--fs-scale-body, 1))', outline: 'none', resize: 'vertical', minHeight: 68, width: '100%', boxSizing: 'border-box', fontFamily: 'inherit', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
<div className={panelCls}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.receiptsTitle') || t('costs.receipts')}</label>
|
||||
<label style={{ display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer', fontSize: 'calc(12px * var(--fs-scale-caption, 1))', fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*,application/pdf"
|
||||
style={{ display: 'none' }}
|
||||
onChange={e => {
|
||||
handleReceiptFileSelect(e.target.files)
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<span className="bg-surface-card border border-edge text-content-muted hover:text-content transition-colors" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 999 }}>
|
||||
<Plus size={13} /> {t('costs.attachReceipt')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{uploadingReceipt && (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-caption, 1))', marginBottom: 8 }}>
|
||||
{t('common.saving')}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{receipts.length === 0 && pendingReceiptFiles.length === 0 ? (
|
||||
<div className="text-content-faint" style={{ fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', padding: '6px 0' }}>
|
||||
{t('costs.noReceipts')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{receipts.map((r, rIdx) => (
|
||||
<div key={r.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalPreviewReceipts({ receipts, initialIndex: rIdx })}
|
||||
className="text-content hover:underline"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, background: 'none', border: 0, padding: 0, cursor: 'pointer', textAlign: 'left', fontFamily: 'inherit', fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}
|
||||
>
|
||||
<Receipt size={14} className="text-content-faint flex-shrink-0" />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.original_name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveReceipt(r.id)}
|
||||
title={t('costs.deleteReceipt')}
|
||||
className="text-content-muted hover:text-red-500 transition-colors"
|
||||
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{pendingReceiptFiles.map((file, idx) => (
|
||||
<div key={idx} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '7px 11px', borderRadius: 10 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 7, minWidth: 0, fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>
|
||||
<Paperclip size={14} className="text-content-faint flex-shrink-0" />
|
||||
<span className="text-content" style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemovePendingReceipt(idx)}
|
||||
title={t('costs.deleteReceipt')}
|
||||
className="text-content-muted hover:text-red-500 transition-colors"
|
||||
style={{ background: 'none', border: 0, padding: 3, cursor: 'pointer', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalPreviewReceipts && (
|
||||
<ReceiptPreviewModal
|
||||
receipts={modalPreviewReceipts.receipts}
|
||||
initialIndex={modalPreviewReceipts.initialIndex}
|
||||
onClose={() => setModalPreviewReceipts(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// FE-BUDGET-PREVIEW-001 to FE-BUDGET-PREVIEW-008: the receipt viewer that opens
|
||||
// on top of the expense form.
|
||||
import { vi } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render'
|
||||
import { ReceiptPreviewModal } from './ReceiptPreviewModal'
|
||||
import type { BudgetItemReceipt } from '../../types'
|
||||
|
||||
const authUrl = vi.hoisted(() => ({ get: vi.fn() }))
|
||||
vi.mock('../../api/authUrl', () => ({ getAuthUrl: (...a: unknown[]) => authUrl.get(...a) }))
|
||||
|
||||
function receipt(over: Partial<BudgetItemReceipt> = {}): BudgetItemReceipt {
|
||||
return {
|
||||
id: 1,
|
||||
filename: 'stored.jpg',
|
||||
original_name: 'lunch.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
file_size: 100,
|
||||
url: '/uploads/files/stored.jpg',
|
||||
...over,
|
||||
} as BudgetItemReceipt
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authUrl.get.mockReset().mockResolvedValue('/signed/stored.jpg')
|
||||
})
|
||||
|
||||
describe('ReceiptPreviewModal', () => {
|
||||
it('FE-BUDGET-PREVIEW-001: renders an image receipt once the signed url is in', async () => {
|
||||
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getByRole('img')).toHaveAttribute('src', '/signed/stored.jpg'))
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-002: renders a PDF in a frame rather than an image', async () => {
|
||||
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/pdf', original_name: 'bill.pdf' })]} onClose={vi.fn()} />)
|
||||
// The viewer renders through a portal, so it lives on document.body.
|
||||
await waitFor(() => expect(document.body.querySelector('object[type="application/pdf"]')).not.toBeNull())
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-003: a type it cannot show falls back instead of rendering an empty frame', async () => {
|
||||
render(<ReceiptPreviewModal receipts={[receipt({ mime_type: 'application/zip', original_name: 'receipts.zip' })]} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
|
||||
expect(document.body.querySelector('object')).toBeNull()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
expect(screen.getAllByText('receipts.zip').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-004: a rejected signed url leaves the viewer up rather than blank-screening', async () => {
|
||||
authUrl.get.mockRejectedValue(new Error('403'))
|
||||
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
|
||||
expect(screen.getAllByText('lunch.jpg').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-005: arrows walk the list and stop at both ends', async () => {
|
||||
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
|
||||
render(<ReceiptPreviewModal receipts={two} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0))
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowLeft' })
|
||||
expect(screen.getAllByText('one.jpg').length).toBeGreaterThan(0)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
|
||||
|
||||
fireEvent.keyDown(window, { key: 'ArrowRight' })
|
||||
expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-006: an out-of-range initialIndex is clamped instead of rendering nothing', async () => {
|
||||
const two = [receipt({ id: 1, original_name: 'one.jpg' }), receipt({ id: 2, original_name: 'two.jpg' })]
|
||||
render(<ReceiptPreviewModal receipts={two} initialIndex={99} onClose={vi.fn()} />)
|
||||
await waitFor(() => expect(screen.getAllByText('two.jpg').length).toBeGreaterThan(0))
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-007: Escape closes the viewer and is stopped before the form behind it sees it', async () => {
|
||||
const onClose = vi.fn()
|
||||
const parent = vi.fn()
|
||||
document.addEventListener('keydown', parent)
|
||||
try {
|
||||
render(<ReceiptPreviewModal receipts={[receipt()]} onClose={onClose} />)
|
||||
await waitFor(() => expect(authUrl.get).toHaveBeenCalled())
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
// The expense form registers its own Escape handler on `document`; one
|
||||
// keypress must not close both and throw away the user's edits.
|
||||
expect(parent).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
document.removeEventListener('keydown', parent)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-BUDGET-PREVIEW-008: an empty list renders nothing at all', () => {
|
||||
render(<ReceiptPreviewModal receipts={[]} onClose={vi.fn()} />)
|
||||
expect(document.body.querySelector('object')).toBeNull()
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,405 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ExternalLink, Download, X, FileText, ChevronLeft, ChevronRight, FileImage } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getAuthUrl } from '../../api/authUrl'
|
||||
import { openFile as openFileUrl } from '../../utils/fileDownload'
|
||||
import { triggerDownload, isImage } from '../Files/FileManager.helpers'
|
||||
import type { BudgetItemReceipt } from '../../types'
|
||||
|
||||
interface ReceiptPreviewModalProps {
|
||||
receipts: BudgetItemReceipt[]
|
||||
initialIndex?: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ReceiptPreviewModal({ receipts, initialIndex = 0, onClose }: ReceiptPreviewModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const [index, setIndex] = useState(Math.max(0, Math.min(initialIndex, receipts.length - 1)))
|
||||
const [signedUrl, setSignedUrl] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const current = receipts[index]
|
||||
const isImg = isImage(current?.mime_type)
|
||||
const isPdf = current?.mime_type === 'application/pdf' || current?.original_name?.toLowerCase().endsWith('.pdf')
|
||||
|
||||
useEffect(() => {
|
||||
if (!current) return
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setSignedUrl('')
|
||||
|
||||
getAuthUrl(current.url, 'download')
|
||||
.then(url => {
|
||||
if (!cancelled) {
|
||||
setSignedUrl(url)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [current?.url])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
// The preview opens on top of the expense form, and both the desktop
|
||||
// Modal and the phone MSheet close themselves on Escape from a listener
|
||||
// on `document`. Without this the one keypress closed the form too and
|
||||
// threw away whatever the user had typed into it.
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (e.key === 'ArrowLeft' && index > 0) setIndex(i => i - 1)
|
||||
if (e.key === 'ArrowRight' && index < receipts.length - 1) setIndex(i => i + 1)
|
||||
}
|
||||
// Capture phase, so this runs before the listeners the parents put on
|
||||
// `document`; stopPropagation on the bubble phase would be too late.
|
||||
window.addEventListener('keydown', handleKey, true)
|
||||
return () => window.removeEventListener('keydown', handleKey, true)
|
||||
}, [index, receipts.length, onClose])
|
||||
|
||||
if (!current) return null
|
||||
|
||||
const handleOpenTab = () => {
|
||||
openFileUrl(current.url, current.original_name).catch(() => {})
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
triggerDownload(current.url, current.original_name)
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0, 0, 0, 0.85)',
|
||||
zIndex: 10000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
}}
|
||||
onClick={e => {
|
||||
if (e.target === e.currentTarget) onClose()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 950,
|
||||
height: '94vh',
|
||||
background: 'var(--bg-card)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Header - identical to PdfPreviewModal */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid var(--border-primary)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600,
|
||||
color: 'var(--text-primary)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{current.original_name}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenTab}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
color: 'var(--text-muted)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
|
||||
>
|
||||
<ExternalLink size={13} /> {t('files.openTab')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
color: 'var(--text-muted)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-muted)')}
|
||||
>
|
||||
<Download size={13} /> {t('files.download') || 'Download'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--text-faint)',
|
||||
display: 'flex',
|
||||
padding: 4,
|
||||
borderRadius: 6,
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--text-faint)')}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab strip if multiple receipts */}
|
||||
{receipts.length > 1 && (
|
||||
<div
|
||||
className="bg-surface-secondary"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 14px',
|
||||
borderBottom: '1px solid var(--border-primary)',
|
||||
overflowX: 'auto',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{receipts.map((r, i) => {
|
||||
const active = i === index
|
||||
const isRImg = isImage(r.mime_type)
|
||||
const Icon = isRImg ? FileImage : FileText
|
||||
return (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
className={active ? 'bg-surface-card text-content font-semibold shadow-sm' : 'text-content-muted hover:text-content'}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '5px 11px',
|
||||
borderRadius: 8,
|
||||
border: active ? '1px solid var(--border-primary)' : '1px solid transparent',
|
||||
background: active ? 'var(--bg-card)' : 'none',
|
||||
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))',
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
fontFamily: 'inherit',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<Icon size={12} className={active ? 'text-content' : 'text-content-faint'} />
|
||||
<span style={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{r.original_name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
opacity: active ? 0.8 : 0.5,
|
||||
marginLeft: 2,
|
||||
}}
|
||||
>
|
||||
#{i + 1}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Body */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
background: isImg ? '#0f141c' : 'var(--bg-card)',
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
{receipts.length > 1 && index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex(i => i - 1)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 14,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{receipts.length > 1 && index < receipts.length - 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIndex(i => i + 1)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 14,
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
zIndex: 10,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isPdf ? (
|
||||
<object
|
||||
data={signedUrl ? `${signedUrl}#view=FitH` : undefined}
|
||||
type="application/pdf"
|
||||
style={{ flex: 1, width: '100%', height: '100%', border: 'none' }}
|
||||
title={current.original_name}
|
||||
>
|
||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenTab}
|
||||
style={{
|
||||
color: 'var(--text-primary)',
|
||||
textDecoration: 'underline',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('files.downloadPdf')}
|
||||
</button>
|
||||
</p>
|
||||
</object>
|
||||
) : isImg ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{signedUrl ? (
|
||||
<img
|
||||
src={signedUrl}
|
||||
alt={current.original_name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 6,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-content-faint" style={{ padding: 48 }}>
|
||||
{loading ? `${t('common.loading')}...` : current.original_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: 40, textAlign: 'center' }}>
|
||||
<FileText size={44} className="text-content-muted mx-auto" style={{ marginBottom: 16 }} />
|
||||
<p className="text-content font-medium" style={{ marginBottom: 14 }}>
|
||||
{current.original_name}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
padding: '9px 18px',
|
||||
borderRadius: 10,
|
||||
border: 0,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 600,
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t('files.download')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// FE-BUDGET-RECEIPT-001 to FE-BUDGET-RECEIPT-008: uploading receipts with an
|
||||
// expense, and taking the uploads back out when the expense fails to save.
|
||||
import { vi } from 'vitest'
|
||||
import { uploadReceipts, discardReceipts, saveWithReceipts } from './receiptUploads'
|
||||
|
||||
const files = vi.hoisted(() => ({
|
||||
upload: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
permanentDelete: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../api/client', () => ({ filesApi: files }))
|
||||
|
||||
function file(name: string) {
|
||||
return new File(['x'], name, { type: 'image/jpeg' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
files.upload.mockReset().mockImplementation(() => Promise.resolve({ file: { id: 7 } }))
|
||||
files.delete.mockReset().mockResolvedValue({})
|
||||
files.permanentDelete.mockReset().mockResolvedValue({})
|
||||
})
|
||||
|
||||
describe('uploadReceipts', () => {
|
||||
it('FE-BUDGET-RECEIPT-001: names the expense on the form data when there is one', async () => {
|
||||
await uploadReceipts(3, [file('a.jpg')], 42)
|
||||
const fd = files.upload.mock.calls[0][1] as FormData
|
||||
expect(fd.get('budget_item_id')).toBe('42')
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-002: leaves it out for an expense that does not exist yet', async () => {
|
||||
await uploadReceipts(3, [file('a.jpg')], null)
|
||||
const fd = files.upload.mock.calls[0][1] as FormData
|
||||
expect(fd.get('budget_item_id')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-003: returns the ids in order and skips a response without one', async () => {
|
||||
files.upload
|
||||
.mockResolvedValueOnce({ file: { id: 11 } })
|
||||
.mockResolvedValueOnce({})
|
||||
.mockResolvedValueOnce({ file: { id: 13 } })
|
||||
expect(await uploadReceipts(3, [file('a'), file('b'), file('c')], null)).toEqual([11, 13])
|
||||
})
|
||||
})
|
||||
|
||||
describe('discardReceipts', () => {
|
||||
it('FE-BUDGET-RECEIPT-004: trashes before it deletes, because the permanent route only accepts a trashed file', async () => {
|
||||
const order: string[] = []
|
||||
files.delete.mockImplementation(() => { order.push('delete'); return Promise.resolve({}) })
|
||||
files.permanentDelete.mockImplementation(() => { order.push('permanent'); return Promise.resolve({}) })
|
||||
|
||||
expect(await discardReceipts(3, [9])).toEqual([])
|
||||
expect(order).toEqual(['delete', 'permanent'])
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-005: reports the ids it could not remove instead of swallowing them', async () => {
|
||||
files.delete.mockRejectedValueOnce(new Error('403'))
|
||||
expect(await discardReceipts(3, [9, 10])).toEqual([9])
|
||||
})
|
||||
})
|
||||
|
||||
describe('saveWithReceipts', () => {
|
||||
it('FE-BUDGET-RECEIPT-006: hands the uploaded ids to the save and returns its result', async () => {
|
||||
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
|
||||
const save = vi.fn().mockResolvedValue('saved')
|
||||
const out = await saveWithReceipts(3, [file('a'), file('b')], null, save)
|
||||
expect(save).toHaveBeenCalledWith([5, 6])
|
||||
expect(out.result).toBe('saved')
|
||||
expect(files.delete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-007: a failing save takes every upload back out again', async () => {
|
||||
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockResolvedValueOnce({ file: { id: 6 } })
|
||||
const save = vi.fn().mockRejectedValue(new Error('nope'))
|
||||
|
||||
await expect(saveWithReceipts(3, [file('a'), file('b')], null, save)).rejects.toThrow('nope')
|
||||
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5, 6])
|
||||
expect(files.permanentDelete.mock.calls.map(c => c[1])).toEqual([5, 6])
|
||||
})
|
||||
|
||||
it('FE-BUDGET-RECEIPT-008: a failure partway through the upload rolls back what already went up, and says what stuck', async () => {
|
||||
files.upload.mockResolvedValueOnce({ file: { id: 5 } }).mockRejectedValueOnce(new Error('offline'))
|
||||
files.delete.mockRejectedValueOnce(new Error('403'))
|
||||
const save = vi.fn()
|
||||
|
||||
const err = await saveWithReceipts(3, [file('a'), file('b')], null, save).catch(e => e)
|
||||
expect(save).not.toHaveBeenCalled()
|
||||
expect(files.delete.mock.calls.map(c => c[1])).toEqual([5])
|
||||
expect(err.stuckReceiptIds).toEqual([5])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
import { filesApi } from '../../api/client'
|
||||
|
||||
/**
|
||||
* Uploading receipts alongside an expense, and undoing it when the expense
|
||||
* itself fails to save.
|
||||
*
|
||||
* One module for both shells. The desktop form and the phone sheet each had
|
||||
* their own copy of this, three near-identical try/catch blocks apiece, which is
|
||||
* six places for the same bug to sit and six copies against the duplication
|
||||
* budget the PR gate measures.
|
||||
*/
|
||||
|
||||
/** Upload each file and return the ids the server handed back, in order. */
|
||||
export async function uploadReceipts(
|
||||
tripId: number | string,
|
||||
files: readonly File[],
|
||||
budgetItemId: number | null,
|
||||
): Promise<number[]> {
|
||||
const ids: number[] = []
|
||||
for (const file of files) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
// Only an existing expense can be named here. A new one has no id yet, so
|
||||
// its receipts are linked by the create call instead.
|
||||
if (budgetItemId != null) fd.append('budget_item_id', String(budgetItemId))
|
||||
const res = await filesApi.upload(tripId, fd)
|
||||
if (res?.file?.id) ids.push(res.file.id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo an upload after the save it belonged to failed.
|
||||
*
|
||||
* Two calls per file, and the order matters: the permanent route only accepts a
|
||||
* file that is already in the trash (`deleted_at IS NOT NULL`), so calling it on
|
||||
* a freshly uploaded file answers 404 and the file stays on the trip. The soft
|
||||
* delete puts it there first. It also drops the receipt link the upload created,
|
||||
* so a failed edit does not leave the file attached to the expense.
|
||||
*
|
||||
* Returns the ids it could not remove. The caller is expected to say so rather
|
||||
* than swallow it: a file left behind is a file the user has to find in the
|
||||
* Files tab and delete by hand.
|
||||
*/
|
||||
export async function discardReceipts(
|
||||
tripId: number | string,
|
||||
ids: readonly number[],
|
||||
): Promise<number[]> {
|
||||
const stuck: number[] = []
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await filesApi.delete(tripId, id)
|
||||
await filesApi.permanentDelete(tripId, id)
|
||||
} catch {
|
||||
stuck.push(id)
|
||||
}
|
||||
}
|
||||
return stuck
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the pending receipts, run the save, and take the uploads back out
|
||||
* again if it throws.
|
||||
*
|
||||
* The rollback runs for a failure in the save AND for one partway through the
|
||||
* upload loop, which is why the ids are collected outside the try.
|
||||
*/
|
||||
export async function saveWithReceipts<T>(
|
||||
tripId: number | string,
|
||||
files: readonly File[],
|
||||
budgetItemId: number | null,
|
||||
save: (uploadedIds: number[]) => Promise<T>,
|
||||
): Promise<{ result: T; stuckIds: number[] }> {
|
||||
const uploaded: number[] = []
|
||||
try {
|
||||
for (const file of files) {
|
||||
const [id] = await uploadReceipts(tripId, [file], budgetItemId)
|
||||
if (id != null) uploaded.push(id)
|
||||
}
|
||||
const result = await save(uploaded)
|
||||
return { result, stuckIds: [] }
|
||||
} catch (err) {
|
||||
const stuck = await discardReceipts(tripId, uploaded)
|
||||
;(err as { stuckReceiptIds?: number[] }).stuckReceiptIds = stuck
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ArrowUp, Reply, Smile, X } from 'lucide-react'
|
||||
import { ArrowUp, ImagePlus, Reply, Smile, X } from 'lucide-react'
|
||||
import type { User } from '../../types'
|
||||
import { useCollabChat } from './useCollabChat'
|
||||
import { ChatMessages } from './CollabChatMessages'
|
||||
@@ -14,7 +14,7 @@ interface CollabChatProps {
|
||||
|
||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const S = useCollabChat(tripId, currentUser)
|
||||
const { t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly } = S
|
||||
const { t, canEdit, loading, text, replyTo, setReplyTo, sending, showEmoji, setShowEmoji, reactMenu, setReactMenu, containerRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles, imagePreviews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, handleTextChange, handleSend, handleKeyDown, handleReact, handleEmojiSelect } = S
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
@@ -27,7 +27,7 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
|
||||
<ChatMessages {...S} />
|
||||
{/* Composer */}
|
||||
<div style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
|
||||
<div onDragOver={e => e.preventDefault()} onDrop={handleDrop} style={{ flexShrink: 0, paddingTop: 8, paddingLeft: 12, paddingRight: 12, borderTop: '1px solid var(--border-faint)' }} className="pb-3 bg-surface-card">
|
||||
{/* Reply preview */}
|
||||
{replyTo && (
|
||||
<div style={{
|
||||
@@ -48,6 +48,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imagePreviews.length > 0 && <div style={{ display: 'flex', gap: 8, marginBottom: 8, overflowX: 'auto' }}>{imagePreviews.map((url, i) => <div key={url} style={{ position: 'relative' }}><img src={url} alt="" style={{ width: 56, height: 56, objectFit: 'cover', borderRadius: 8 }} /><button type="button" onClick={() => removeImage(i)} aria-label="Remove image" style={{ position: 'absolute', top: -6, right: -6, border: 0, borderRadius: '50%', background: 'var(--text-primary)', color: 'var(--bg-primary)', width: 18, height: 18, cursor: 'pointer' }}>×</button></div>)}</div>}
|
||||
{uploadProgress > 0 && uploadProgress < 100 && <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>Uploading {uploadProgress}%</div>}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||
{/* Emoji button */}
|
||||
{canEdit && (
|
||||
@@ -61,6 +63,8 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canEdit && <><input ref={imageInputRef} type="file" accept="image/jpeg,image/png,image/gif,image/webp" multiple hidden onChange={e => { if (e.target.files) addImageFiles(e.target.files); e.currentTarget.value = '' }} /><button type="button" onClick={() => imageInputRef.current?.click()} aria-label="Attach images" style={{ width: 34, height: 34, borderRadius: '50%', border: 'none', background: 'transparent', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', padding: 0 }}><ImagePlus size={19} /></button></>}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
@@ -76,15 +80,16 @@ export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
value={text}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
|
||||
{/* Send */}
|
||||
{canEdit && (
|
||||
<button type="button" onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
<button type="button" onClick={handleSend} disabled={(!text.trim() && !imageFiles.length) || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
background: text.trim() || imageFiles.length ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||
cursor: text.trim() || imageFiles.length ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<ArrowUp size={18} strokeWidth={2.5} />
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
export function CollabChatAttachment({ attachment }: { attachment: { url: string; original_name?: string; mime_type?: string } }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return <>
|
||||
<img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={() => setOpen(true)} style={{ display: 'block', width: 180, maxWidth: '100%', maxHeight: 220, objectFit: 'cover', borderRadius: 10, cursor: 'zoom-in', marginTop: 4 }} />
|
||||
{open && <div role="dialog" onClick={() => setOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.82)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}><button aria-label="Close image" onClick={() => setOpen(false)} style={{ position: 'fixed', top: 18, right: 18, background: 'rgba(255,255,255,.15)', color: '#fff', border: 0, borderRadius: '50%', width: 36, height: 36 }}><X size={20} /></button><img src={attachment.url} alt={attachment.original_name || 'Attached image'} onClick={e => e.stopPropagation()} style={{ maxWidth: '95vw', maxHeight: '90vh', objectFit: 'contain' }} /></div>}
|
||||
</>
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Trash2, Reply, ChevronUp } from 'lucide-react'
|
||||
import { URL_REGEX } from './CollabChat.constants'
|
||||
import { formatTime, formatDateSeparator, shouldShowDateSeparator } from './CollabChat.helpers'
|
||||
import { MessageText } from './CollabChatMessageText'
|
||||
import { CollabChatAttachment } from './CollabChatAttachment'
|
||||
import { LinkPreview } from './CollabChatLinkPreview'
|
||||
import { ReactionBadge } from './CollabChatReactionBadge'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
@@ -166,6 +167,7 @@ export function ChatMessages(props: any) {
|
||||
{hasReply ? (
|
||||
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
||||
) : <MessageText text={msg.text} />}
|
||||
{msg.attachments?.map((attachment: any) => <CollabChatAttachment key={attachment.id} attachment={attachment} />)}
|
||||
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
||||
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { FormEvent, useEffect, useState } from 'react'
|
||||
import { ExternalLink, Link2, Pin, Plus, Trash2, X } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
interface CollabLink {
|
||||
id: number
|
||||
title: string
|
||||
url: string
|
||||
pinned: boolean | number
|
||||
}
|
||||
|
||||
function LinkIcon({ url, title }: { url: string; title: string }) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
let favicon = ''
|
||||
try { favicon = new URL('/favicon.ico', url).href } catch { favicon = '' }
|
||||
if (!favicon || failed) return <span aria-hidden="true" style={{ display: 'grid', placeItems: 'center', width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, background: 'var(--bg-secondary)', color: 'var(--text-faint)' }}><Link2 size={15} /></span>
|
||||
return <img src={favicon} alt="" aria-hidden="true" title={title} onError={() => setFailed(true)} style={{ width: 28, height: 28, flex: '0 0 28px', borderRadius: 7, objectFit: 'contain', background: 'var(--bg-secondary)' }} />
|
||||
}
|
||||
|
||||
export default function CollabLinks({ tripId }: { tripId: number }) {
|
||||
const { t } = useTranslation()
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const canEdit = useCanDo()('collab_edit', trip)
|
||||
const toast = useToast()
|
||||
const [links, setLinks] = useState<CollabLink[]>([])
|
||||
const [title, setTitle] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
collabApi.getLinks(tripId)
|
||||
.then(d => { if (!cancelled) setLinks(d.links || []) })
|
||||
.catch(err => {
|
||||
if (cancelled) return
|
||||
console.error('Failed to load collab links:', err)
|
||||
toast.error(t('common.error'))
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [tripId, toast, t])
|
||||
|
||||
// Live sync, the same shape the notes panel uses. Without it the three
|
||||
// collab:link events the server broadcasts would arrive nowhere and a link
|
||||
// somebody else added would only appear on the next reload.
|
||||
useEffect(() => {
|
||||
if (!tripId) return
|
||||
const handler = (msg: { type?: string; tripId?: number | string; link?: CollabLink; linkId?: number }) => {
|
||||
// The panel is not remounted on a trip change, so an event still in
|
||||
// flight from the trip we just left must not land in this list.
|
||||
if (String(msg?.tripId) !== String(tripId)) return
|
||||
if (msg.type === 'collab:link:created' && msg.link) {
|
||||
setLinks(prev => prev.some(l => l.id === msg.link!.id) ? prev : [msg.link!, ...prev])
|
||||
}
|
||||
if (msg.type === 'collab:link:updated' && msg.link) {
|
||||
setLinks(prev => prev
|
||||
.map(l => l.id === msg.link!.id ? { ...l, ...msg.link! } : l)
|
||||
.sort((a, b) => Number(b.pinned) - Number(a.pinned)))
|
||||
}
|
||||
if (msg.type === 'collab:link:deleted' && msg.linkId) {
|
||||
setLinks(prev => prev.filter(l => l.id !== msg.linkId))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [tripId])
|
||||
|
||||
// Every write says something when it fails. Silence left the pin looking
|
||||
// unchanged and the delete looking ignored, with an unhandled rejection in
|
||||
// the console as the only trace.
|
||||
const add = async (event?: FormEvent) => {
|
||||
event?.preventDefault()
|
||||
if (!title.trim() || !url.trim() || busy) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const d = await collabApi.createLink(tripId, { title: title.trim(), url: url.trim() })
|
||||
setLinks(v => [d.link, ...v])
|
||||
setTitle(''); setUrl(''); setExpanded(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to add collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async (link: CollabLink) => {
|
||||
try {
|
||||
const d = await collabApi.updateLink(tripId, link.id, { pinned: !link.pinned })
|
||||
setLinks(v => v.map(x => x.id === link.id ? d.link : x).sort((a, b) => Number(b.pinned) - Number(a.pinned)))
|
||||
} catch (err) {
|
||||
console.error('Failed to pin collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await collabApi.deleteLink(tripId, id)
|
||||
setLinks(v => v.filter(x => x.id !== id))
|
||||
} catch (err) {
|
||||
console.error('Failed to delete collab link:', err)
|
||||
toast.error(t('common.error'))
|
||||
}
|
||||
}
|
||||
return <div style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 14px', borderBottom: '1px solid var(--border-faint)' }}><Link2 size={18} aria-hidden="true" /><strong>{t('collab.tabs.links')}</strong><span style={{ marginLeft: 'auto', color: 'var(--text-faint)', fontSize: 12 }}>{links.length}</span></div>
|
||||
{canEdit && <div style={{ padding: 10, borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<button type="button" onClick={() => setExpanded(v => !v)} aria-expanded={expanded} aria-controls={`collab-link-form-${tripId}`} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, border: 0, borderRadius: 8, padding: '7px 10px', background: 'var(--bg-secondary)', color: 'var(--text-primary)', cursor: 'pointer', fontWeight: 600 }}>{expanded ? <X size={16} aria-hidden="true" /> : <Plus size={16} aria-hidden="true" />}{expanded ? t('collab.links.cancel') : t('collab.links.add')}</button>
|
||||
{expanded && <form id={`collab-link-form-${tripId}`} onSubmit={add} style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
|
||||
<label htmlFor={`collab-link-title-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.titlePlaceholder')}</label>
|
||||
<input id={`collab-link-title-${tripId}`} value={title} onChange={e => setTitle(e.target.value)} placeholder={t('collab.links.titlePlaceholder')} autoFocus style={{ padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} />
|
||||
<div style={{ display: 'flex', gap: 6 }}><label htmlFor={`collab-link-url-${tripId}`} style={{ position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clip: 'rect(0,0,0,0)', whiteSpace: 'nowrap', border: 0 }}>{t('collab.links.urlPlaceholder')}</label><input id={`collab-link-url-${tripId}`} type="url" required value={url} onChange={e => setUrl(e.target.value)} placeholder={t('collab.links.urlPlaceholder')} style={{ flex: 1, minWidth: 0, padding: 8, borderRadius: 8, border: '1px solid var(--border-primary)', background: 'var(--bg-input)', color: 'var(--text-primary)' }} /><button type="submit" disabled={busy || !title.trim() || !url.trim()} aria-label={t('collab.links.save')} style={{ width: 36, border: 0, borderRadius: 8, background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer' }}><Plus size={18} aria-hidden="true" /></button></div>
|
||||
</form>}
|
||||
</div>}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: 10 }}>{links.length === 0 ? <div style={{ padding: 24, textAlign: 'center', color: 'var(--text-faint)' }}>{t('collab.links.empty')}</div> : links.map(link => <div key={link.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 6px', borderBottom: '1px solid var(--border-faint)' }}><LinkIcon url={link.url} title={link.title} /><a href={link.url} target="_blank" rel="noreferrer" style={{ flex: 1, minWidth: 0, color: 'var(--text-primary)', textDecoration: 'none' }}><div style={{ fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.title}</div><div style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{link.url}</div></a><ExternalLink size={15} color="var(--text-faint)" aria-hidden="true" />{canEdit && <><button type="button" onClick={() => toggle(link)} aria-label={link.pinned ? t('collab.links.unpin') : t('collab.links.pin')} style={{ border: 0, background: 'transparent', color: link.pinned ? 'var(--accent)' : 'var(--text-faint)', cursor: 'pointer' }}><Pin size={15} fill={link.pinned ? 'currentColor' : 'none'} aria-hidden="true" /></button><button type="button" onClick={() => remove(link.id)} aria-label={t('collab.links.delete')} style={{ border: 0, background: 'transparent', color: 'var(--text-faint)', cursor: 'pointer' }}><Trash2 size={15} aria-hidden="true" /></button></>}</div>)}</div>
|
||||
</div>
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuthStore } from '../../store/authStore'
|
||||
|
||||
vi.mock('./CollabChat', () => ({ default: () => <div data-testid="collab-chat">Chat</div> }))
|
||||
vi.mock('./CollabNotes', () => ({ default: () => <div data-testid="collab-notes">Notes</div> }))
|
||||
vi.mock('./CollabLinks', () => ({ default: () => <div data-testid="collab-links">Links</div> }))
|
||||
vi.mock('./CollabPolls', () => ({ default: () => <div data-testid="collab-polls">Polls</div> }))
|
||||
vi.mock('./WhatsNextWidget', () => ({ default: () => <div data-testid="whats-next">WhatsNext</div> }))
|
||||
vi.mock('../../api/websocket', () => ({
|
||||
@@ -39,11 +40,12 @@ describe('CollabPanel', () => {
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-001
|
||||
it('desktop layout renders all four panels', () => {
|
||||
it('desktop layout renders all five panels', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
})
|
||||
@@ -55,6 +57,7 @@ describe('CollabPanel', () => {
|
||||
// Tab buttons exist
|
||||
expect(screen.getByRole('button', { name: /chat/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /notes/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /^links$/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /polls/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /what.?s next/i })).toBeInTheDocument()
|
||||
// Only chat visible by default
|
||||
@@ -73,6 +76,13 @@ describe('CollabPanel', () => {
|
||||
expect(screen.queryByTestId('collab-chat')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('mobile: Links tab shows the translated label, not the i18n key', () => {
|
||||
setViewport(375)
|
||||
render(<CollabPanel tripId={1} />)
|
||||
expect(screen.getByRole('button', { name: 'Links' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'collab.tabs.links' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// FE-COMP-COLLABPANEL-004
|
||||
it('mobile: clicking Polls tab switches to CollabPolls', () => {
|
||||
setViewport(375)
|
||||
@@ -147,7 +157,7 @@ describe('CollabPanel', () => {
|
||||
// FE-W5CPN-001 to FE-W5CPN-013
|
||||
// The desktop branch picks a different layout for every combination of enabled
|
||||
// collab features, so each combination gets its own case.
|
||||
const allOff = { chat: false, notes: false, polls: false, whatsnext: false }
|
||||
const allOff = { chat: false, notes: false, links: false, polls: false, whatsnext: false }
|
||||
|
||||
describe('CollabPanel feature combinations', () => {
|
||||
beforeEach(() => {
|
||||
@@ -172,6 +182,7 @@ describe('CollabPanel feature combinations', () => {
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, chat: true }} />)
|
||||
expect(screen.getByTestId('collab-chat')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-polls')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('whats-next')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -253,6 +264,42 @@ describe('CollabPanel feature combinations', () => {
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The three-or-more branch used to render notes and links as a pair and drop
|
||||
// whichever of the two was on its own, so these walk every shape of it.
|
||||
it('FE-W5CPN-014: three right panels including notes but not links keeps notes', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: false, polls: true, whatsnext: true }} />)
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-links')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-015: three right panels including links but not notes keeps links', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: false, links: true, polls: true, whatsnext: true }} />)
|
||||
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('whats-next')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('collab-notes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-016: all four right panels render, notes and links above the other two', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ chat: true, notes: true, links: true, polls: true, whatsnext: true }} />)
|
||||
for (const id of ['collab-chat', 'collab-notes', 'collab-links', 'collab-polls', 'whats-next']) {
|
||||
expect(screen.getByTestId(id)).toBeInTheDocument()
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-W5CPN-017: notes and links alone with a third panel and no chat all render', () => {
|
||||
setViewport(1280)
|
||||
render(<CollabPanel tripId={1} collabFeatures={{ ...allOff, notes: true, links: true, polls: true }} />)
|
||||
expect(screen.getByTestId('collab-notes')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-links')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('collab-polls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5CPN-013: mobile falls back to the first tab when the active one gets disabled', () => {
|
||||
setViewport(375)
|
||||
const { rerender } = render(<CollabPanel tripId={1} />)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
||||
import { MessageCircle, StickyNote, Link2, BarChart3, Sparkles } from 'lucide-react'
|
||||
import CollabChat from './CollabChat'
|
||||
import CollabNotes from './CollabNotes'
|
||||
import CollabPolls from './CollabPolls'
|
||||
import WhatsNextWidget from './WhatsNextWidget'
|
||||
import CollabLinks from './CollabLinks'
|
||||
|
||||
function useIsDesktop(breakpoint = 1024) {
|
||||
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
|
||||
@@ -28,6 +29,7 @@ interface TripMember {
|
||||
interface CollabFeatures {
|
||||
chat: boolean
|
||||
notes: boolean
|
||||
links?: boolean
|
||||
polls: boolean
|
||||
whatsnext: boolean
|
||||
}
|
||||
@@ -39,10 +41,11 @@ interface CollabPanelProps {
|
||||
}
|
||||
|
||||
const ALL_TABS = [
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', icon: StickyNote },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', icon: Sparkles },
|
||||
{ id: 'chat', featureKey: 'chat' as const, labelKey: 'collab.tabs.chat', fallback: 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', featureKey: 'notes' as const, labelKey: 'collab.tabs.notes', fallback: 'Notes', icon: StickyNote },
|
||||
{ id: 'links', featureKey: 'links' as const, labelKey: 'collab.tabs.links', fallback: 'Links', icon: Link2 },
|
||||
{ id: 'polls', featureKey: 'polls' as const, labelKey: 'collab.tabs.polls', fallback: 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', featureKey: 'whatsnext' as const, labelKey: 'collab.whatsNext.title', fallback: "What's Next", icon: Sparkles },
|
||||
]
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }: CollabPanelProps) {
|
||||
@@ -50,12 +53,14 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
const { t } = useTranslation()
|
||||
const isDesktop = useIsDesktop()
|
||||
|
||||
const features = collabFeatures || { chat: true, notes: true, polls: true, whatsnext: true }
|
||||
// Older server/admin configs predate the Links feature; merge defaults so a
|
||||
// missing `links` key does not silently hide the new panel.
|
||||
const features = { chat: true, notes: true, links: true, polls: true, whatsnext: true, ...(collabFeatures || {}) }
|
||||
|
||||
const tabs = useMemo(() =>
|
||||
ALL_TABS.filter(tab => features[tab.featureKey]).map(tab => ({
|
||||
...tab,
|
||||
label: t(tab.labelKey),
|
||||
label: t(tab.labelKey) || tab.fallback,
|
||||
})),
|
||||
[features, t])
|
||||
|
||||
@@ -70,11 +75,28 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
|
||||
const chatOn = features.chat
|
||||
const rightPanels = [
|
||||
features.notes && 'notes',
|
||||
features.notes && 'notes', features.links && 'links',
|
||||
features.polls && 'polls',
|
||||
features.whatsnext && 'whatsnext',
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
// One place decides what a panel id renders. It was spelled out at five call
|
||||
// sites, and the layout branch below dropped a panel precisely because one of
|
||||
// those copies did not list it.
|
||||
const renderPanel = (p: string) => (
|
||||
<>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'links' && <CollabLinks tripId={tripId} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</>
|
||||
)
|
||||
const panelRow = (ids: string[]) => (
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, minHeight: 0 }}>
|
||||
{ids.map(p => <div key={p} className={cardClass} style={{ flex: 1, minWidth: 0 }}>{renderPanel(p)}</div>)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (tabs.length === 0) return null
|
||||
|
||||
if (isDesktop) {
|
||||
@@ -99,35 +121,19 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{rightPanels.length === 1 && (
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
{rightPanels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{rightPanels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
)}
|
||||
{rightPanels.length === 2 && rightPanels.map(p => (
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
))}
|
||||
{rightPanels.length === 3 && (
|
||||
<>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
{rightPanels.length <= 2 && panelRow(rightPanels)}
|
||||
{rightPanels.length >= 3 && (() => {
|
||||
// Two rows, split by kind rather than by a pair being present:
|
||||
// the old condition needed notes AND links together, so turning
|
||||
// the new Links feature off dropped Notes out of the layout
|
||||
// entirely. Each row renders only when it has something in it.
|
||||
const written = rightPanels.filter(p => p === 'notes' || p === 'links')
|
||||
const rest = rightPanels.filter(p => p !== 'notes' && p !== 'links')
|
||||
return <>
|
||||
{written.length > 0 && panelRow(written)}
|
||||
{rest.length > 0 && panelRow(rest)}
|
||||
</>
|
||||
)}
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -135,26 +141,10 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
|
||||
// Chat off — remaining panels share full width
|
||||
const panels = rightPanels
|
||||
if (panels.length === 1) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div className={cardClass} style={{ flex: 1 }}>
|
||||
{panels[0] === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{panels[0] === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{panels.map(p => (
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>
|
||||
{p === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{p === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{p === 'whatsnext' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
<div key={p} className={cardClass} style={{ flex: 1 }}>{renderPanel(p)}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
@@ -170,7 +160,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
{tabs.map(tab => {
|
||||
const active = mobileTab === tab.id
|
||||
return (
|
||||
<button type="button" key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
@@ -187,6 +177,7 @@ export default function CollabPanel({ tripId, tripMembers = [], collabFeatures }
|
||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
{mobileTab === 'chat' && features.chat && <CollabChat tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'notes' && features.notes && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'links' && features.links && <CollabLinks tripId={tripId} />}
|
||||
{mobileTab === 'polls' && features.polls && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'next' && features.whatsnext && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* The images pinned to a chat message before it is sent.
|
||||
*
|
||||
* One hook for both shells. The desktop hook and the phone screen each carried
|
||||
* their own copy of the same picking, previewing and clearing, which is two
|
||||
* places for the same leak and a duplication bill on every line.
|
||||
*
|
||||
* Object URLs are held in a ref and revoked when the file they belong to goes,
|
||||
* when the list is cleared and on unmount. Rebuilding every preview on each
|
||||
* change, which is what both copies did, leaked one URL per image per keystroke
|
||||
* of picking, and did it from inside a setState updater, so React ran it twice
|
||||
* in development and leaked twice as fast.
|
||||
*/
|
||||
export const CHAT_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
|
||||
export const MAX_CHAT_IMAGES = 4
|
||||
export const MAX_CHAT_IMAGE_BYTES = 10 * 1024 * 1024
|
||||
|
||||
export interface ChatImages {
|
||||
files: File[]
|
||||
previews: string[]
|
||||
/** Returns false when something was dropped, so the caller can say why. */
|
||||
add: (incoming: FileList | File[]) => boolean
|
||||
remove: (index: number) => void
|
||||
clear: () => void
|
||||
}
|
||||
|
||||
export function useChatImages(): ChatImages {
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [previews, setPreviews] = useState<string[]>([])
|
||||
const urls = useRef<string[]>([])
|
||||
|
||||
const revokeAll = useCallback(() => {
|
||||
urls.current.forEach(URL.revokeObjectURL)
|
||||
urls.current = []
|
||||
}, [])
|
||||
|
||||
useEffect(() => revokeAll, [revokeAll])
|
||||
|
||||
const add = useCallback((incoming: FileList | File[]) => {
|
||||
const all = Array.from(incoming)
|
||||
const valid = all.filter(f => CHAT_IMAGE_TYPES.includes(f.type) && f.size <= MAX_CHAT_IMAGE_BYTES)
|
||||
if (valid.length) {
|
||||
setFiles(prev => {
|
||||
const room = MAX_CHAT_IMAGES - prev.length
|
||||
const added = valid.slice(0, Math.max(0, room))
|
||||
// Only the new files get a URL; the ones already in the list keep theirs.
|
||||
const fresh = added.map(URL.createObjectURL)
|
||||
urls.current = [...urls.current, ...fresh]
|
||||
setPreviews(p => [...p, ...fresh])
|
||||
return [...prev, ...added]
|
||||
})
|
||||
}
|
||||
return valid.length === all.length
|
||||
}, [])
|
||||
|
||||
const remove = useCallback((index: number) => {
|
||||
setFiles(prev => prev.filter((_, i) => i !== index))
|
||||
setPreviews(prev => {
|
||||
const gone = prev[index]
|
||||
if (gone) {
|
||||
URL.revokeObjectURL(gone)
|
||||
urls.current = urls.current.filter(u => u !== gone)
|
||||
}
|
||||
return prev.filter((_, i) => i !== index)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(() => {
|
||||
revokeAll()
|
||||
setFiles([])
|
||||
setPreviews([])
|
||||
}, [revokeAll])
|
||||
|
||||
return { files, previews, add, remove, clear }
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback, type ClipboardEvent, type DragEvent } from 'react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useChatImages } from './useChatImages'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
export function useCollabChat(tripId: any, currentUser: any) {
|
||||
@@ -23,6 +24,8 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
const [replyTo, setReplyTo] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const images = useChatImages()
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [showEmoji, setShowEmoji] = useState(false)
|
||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||
@@ -38,6 +41,7 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
const scrollRef = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
const emojiBtnRef = useRef(null)
|
||||
const imageInputRef = useRef<HTMLInputElement>(null)
|
||||
const isAtBottom = useRef(true)
|
||||
|
||||
const scrollToBottom = useCallback((behavior = 'auto') => {
|
||||
@@ -117,23 +121,35 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
}, [])
|
||||
|
||||
/* ── send ── */
|
||||
const addImageFiles = useCallback((incoming: File[] | FileList) => {
|
||||
if (!images.add(incoming)) toast.error(t('collab.chat.imageRejected'))
|
||||
}, [images, toast, t])
|
||||
const removeImage = images.remove
|
||||
const handlePaste = useCallback((e: ClipboardEvent) => { if (e.clipboardData.files.length) addImageFiles(e.clipboardData.files) }, [addImageFiles])
|
||||
const handleDrop = useCallback((e: DragEvent) => { e.preventDefault(); addImageFiles(e.dataTransfer.files) }, [addImageFiles])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const body = text.trim()
|
||||
if (!body || sending) return
|
||||
if ((!body && !images.files.length) || sending) return
|
||||
setSending(true)
|
||||
try {
|
||||
const payload: { text: string; reply_to?: number } = { text: body }
|
||||
if (replyTo) payload.reply_to = replyTo.id
|
||||
const data = await collabApi.sendMessage(tripId, payload)
|
||||
let data
|
||||
if (images.files.length) {
|
||||
const form = new FormData(); if (body) form.append('text', body); if (replyTo) form.append('reply_to', String(replyTo.id)); images.files.forEach(file => form.append('images', file))
|
||||
data = await collabApi.sendMessage(tripId, form, { onUploadProgress: e => setUploadProgress(e.total ? Math.round((e.loaded / e.total) * 100) : 0) })
|
||||
} else {
|
||||
const payload: { text: string; reply_to?: number } = { text: body }; if (replyTo) payload.reply_to = replyTo.id
|
||||
data = await collabApi.sendMessage(tripId, payload)
|
||||
}
|
||||
if (data?.message) {
|
||||
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
|
||||
}
|
||||
setText(''); setReplyTo(null); setShowEmoji(false)
|
||||
setText(''); setReplyTo(null); setShowEmoji(false); images.clear()
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
isAtBottom.current = true
|
||||
setTimeout(() => scrollToBottom('smooth'), 50)
|
||||
} catch { toast.error(t('common.error')) } finally { setSending(false) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t])
|
||||
} catch { toast.error(t('common.error')) } finally { setSending(false); setUploadProgress(0) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom, toast, t, images])
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||||
@@ -175,5 +191,5 @@ export function useCollabChat(tripId: any, currentUser: any) {
|
||||
return emojiRegex.test(text.trim())
|
||||
}
|
||||
|
||||
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
|
||||
return { currentUser, tripId, t, is12h, can, trip, canEdit, messages, setMessages, loading, setLoading, hasMore, setHasMore, loadingMore, setLoadingMore, text, setText, replyTo, setReplyTo, hoveredId, setHoveredId, sending, setSending, showEmoji, setShowEmoji, reactMenu, setReactMenu, deletingIds, setDeletingIds, deleteTimersRef, containerRef, messagesRef, scrollRef, textareaRef, emojiBtnRef, imageInputRef, imageFiles: images.files, imagePreviews: images.previews, uploadProgress, addImageFiles, removeImage, handlePaste, handleDrop, isAtBottom, scrollToBottom, checkAtBottom, handleLoadMore, handleTextChange, handleSend, handleKeyDown, handleDelete, handleReact, handleEmojiSelect, isOwn, isEmojiOnly }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { collectionsApi } from '../../api/collections'
|
||||
import { STATUS_META, STATUS_ORDER } from '../../pages/collections/collectionsModel'
|
||||
import type { CollectionPlace, CollectionStatus } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../types'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
|
||||
interface LocationBias {
|
||||
low: { lat: number; lng: number }
|
||||
@@ -53,7 +54,16 @@ async function loadSavedPlaces(ids: number[]): Promise<CollectionPlace[]> {
|
||||
return merged
|
||||
}
|
||||
|
||||
/** Compact click-away dropdown (Tailwind — this panel lives outside .trek-dash). */
|
||||
/**
|
||||
* Compact click-away dropdown (Tailwind, this panel lives outside .trek-dash).
|
||||
*
|
||||
* The panel is positioned against the FILTER ROW rather than against this
|
||||
* button, so it opens across the full width of the row instead of the half its
|
||||
* own trigger occupies. The two filters sit side by side and only one can be
|
||||
* open at a time, so the wider panel costs nothing and stops list names being
|
||||
* truncated at roughly ten characters. The row carries the `relative` this
|
||||
* needs; there is no other call site.
|
||||
*/
|
||||
function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
current: string | number
|
||||
options: Opt[]
|
||||
@@ -72,7 +82,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
}, [open])
|
||||
const cur = options.find(o => o.key === current) ?? options[0]
|
||||
return (
|
||||
<div className="relative min-w-0 flex-1" ref={ref}>
|
||||
<div className="min-w-0 flex-1" ref={ref}>
|
||||
<button type="button" onClick={() => setOpen(o => !o)} aria-haspopup="listbox" aria-expanded={open}
|
||||
className={`w-full flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border bg-surface-input text-[12px] font-medium text-content-secondary transition-colors ${open ? 'border-accent' : 'border-edge hover:bg-surface-hover'}`}>
|
||||
<span className="shrink-0 text-content-faint">{cur.icon ?? lead}</span>
|
||||
@@ -80,7 +90,7 @@ function FilterDropdown({ current, options, onSelect, lead }: {
|
||||
<ChevronDown size={13} className="shrink-0 text-content-faint" />
|
||||
</button>
|
||||
{open && (
|
||||
<div role="listbox" className="absolute z-30 left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
|
||||
<div role="listbox" className="absolute z-30 top-full left-0 right-0 mt-1 max-h-[240px] overflow-y-auto p-1 rounded-xl border border-edge bg-surface-card shadow-lg flex flex-col gap-0.5">
|
||||
{options.map(o => (
|
||||
<button key={o.key} type="button" role="option" aria-selected={o.key === current} onClick={() => { onSelect(o.key); setOpen(false) }}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded-lg text-[12.5px] text-left transition-colors hover:bg-surface-hover ${o.key === current ? 'text-content font-semibold' : 'text-content-secondary'}`}>
|
||||
@@ -173,7 +183,10 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
]
|
||||
|
||||
return (
|
||||
<aside className="w-full sm:w-64 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
|
||||
// Same 320px as the details column on the other side of the form: two panels
|
||||
// of different widths flanking one form read as a mistake rather than a
|
||||
// hierarchy, and neither of them is the more important one.
|
||||
<aside className="w-full sm:w-80 shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch">
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
|
||||
<Bookmark size={15} className="text-accent" />
|
||||
<span className="text-[13px] font-semibold text-content">{t('collections.picker.title')}</span>
|
||||
@@ -189,7 +202,7 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
/>
|
||||
</div>
|
||||
{lists.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex gap-2">
|
||||
<FilterDropdown current={listFilter} options={listOpts} onSelect={k => setListFilter(k as number | 'all')} lead={<Layers size={13} />} />
|
||||
<FilterDropdown current={statusFilter} options={statusOpts} onSelect={k => setStatusFilter(k as CollectionStatus | 'all')} lead={<Bookmark size={13} />} />
|
||||
</div>
|
||||
@@ -201,7 +214,13 @@ export default function CollectionPicker({ bias, onSelect, t }: CollectionPicker
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<p className="text-center text-[12px] text-content-faint py-10 px-3">{t('collections.picker.empty')}</p>
|
||||
<EmptyState
|
||||
scene="search"
|
||||
title={t('collections.picker.empty')}
|
||||
size={84}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{page.map(place => (
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// FE-COMP-JOURNEYDATEPICKER-001 to FE-COMP-JOURNEYDATEPICKER-006
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
|
||||
function open(value = '2026-09-11') {
|
||||
const onChange = vi.fn()
|
||||
render(<DatePicker value={value} onChange={onChange} />)
|
||||
return { onChange }
|
||||
}
|
||||
|
||||
const header = () => screen.getByRole('button', { name: /select month|select year|\d{4} – \d{4}/i })
|
||||
const prev = (name: RegExp) => screen.getByRole('button', { name })
|
||||
|
||||
describe('DatePicker year navigation (#2318)', () => {
|
||||
it('FE-COMP-JOURNEYDATEPICKER-001: opens on the month of the value with weekday headers', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('September 2026')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-002: the header climbs to the months, then to the years', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Jan' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2026')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
expect(screen.getByRole('button', { name: '2016' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '2027' })).toBeInTheDocument()
|
||||
expect(header()).toHaveTextContent('2016 – 2027')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-003: picking a year three back lands on that year in one move', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(screen.getByRole('button', { name: '2023' }))
|
||||
|
||||
// Back on the months, of the chosen year.
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2023')
|
||||
await user.click(screen.getByRole('button', { name: 'Mar' }))
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('March 2023')
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-004: the arrows step the level they are on', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
|
||||
await user.click(prev(/previous month/i))
|
||||
expect(screen.getByRole('button', { name: 'Select month' })).toHaveTextContent('August 2026')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(prev(/previous year/i))
|
||||
expect(screen.getByRole('button', { name: 'Select year' })).toHaveTextContent('2025')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(prev(/previous years/i))
|
||||
expect(header()).toHaveTextContent('2004 – 2015')
|
||||
await user.click(screen.getByRole('button', { name: /next years/i }))
|
||||
expect(header()).toHaveTextContent('2016 – 2027')
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-005: a day picked after the jump is handed back as ISO', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onChange } = open()
|
||||
await user.click(screen.getByText(/Sep 11, 2026/))
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(screen.getByRole('button', { name: '2023' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Mar' }))
|
||||
await user.click(screen.getByRole('button', { name: '15' }))
|
||||
expect(onChange).toHaveBeenCalledWith('2023-03-15')
|
||||
expect(screen.queryByText('Mo')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-COMP-JOURNEYDATEPICKER-006: reopening starts on the days again', async () => {
|
||||
const user = userEvent.setup()
|
||||
open()
|
||||
const trigger = screen.getByText(/Sep 11, 2026/)
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByRole('button', { name: 'Select month' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Select year' }))
|
||||
await user.click(trigger)
|
||||
expect(screen.queryByRole('button', { name: '2023' })).not.toBeInTheDocument()
|
||||
await user.click(trigger)
|
||||
expect(screen.getByText('Mo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,16 @@ import { localIsoDate } from '../../utils/localDate'
|
||||
import { ArrowLeft, ChevronRight, Calendar } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
/**
|
||||
* The header steps up a level on each click, days → months → years, and a
|
||||
* pick steps back down. Mirrors CustomDateTimePicker, so a person who has
|
||||
* learnt one calendar in TREK knows the other. Without it the only way to a
|
||||
* different year was one month at a time, which on a photo picker asked to
|
||||
* cover a trip three years back meant thirty-six clicks (#2318).
|
||||
*/
|
||||
type CalendarView = 'days' | 'months' | 'years'
|
||||
const YEAR_PAGE_SIZE = 12
|
||||
|
||||
export function DatePicker({ value, onChange, tripDates }: {
|
||||
value: string
|
||||
onChange: (date: string) => void
|
||||
@@ -10,6 +20,8 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [view, setView] = useState<CalendarView>('days')
|
||||
const [yearPageStart, setYearPageStart] = useState(0)
|
||||
const [viewMonth, setViewMonth] = useState(() => {
|
||||
const d = value ? new Date(value + 'T00:00:00') : new Date()
|
||||
return { year: d.getFullYear(), month: d.getMonth() }
|
||||
@@ -27,19 +39,73 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
setViewMonth(p => p.month === 11 ? { year: p.year + 1, month: 0 } : { ...p, month: p.month + 1 })
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (view === 'days') prevMonth()
|
||||
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year - 1 }))
|
||||
else setYearPageStart(s => s - YEAR_PAGE_SIZE)
|
||||
}
|
||||
const handleNext = () => {
|
||||
if (view === 'days') nextMonth()
|
||||
else if (view === 'months') setViewMonth(p => ({ ...p, year: p.year + 1 }))
|
||||
else setYearPageStart(s => s + YEAR_PAGE_SIZE)
|
||||
}
|
||||
const handleHeaderClick = () => {
|
||||
if (view === 'days') {
|
||||
setView('months')
|
||||
} else if (view === 'months') {
|
||||
setYearPageStart(Math.floor(viewMonth.year / YEAR_PAGE_SIZE) * YEAR_PAGE_SIZE)
|
||||
setView('years')
|
||||
}
|
||||
}
|
||||
const selectMonth = (month: number) => {
|
||||
setViewMonth(p => ({ ...p, month }))
|
||||
setView('days')
|
||||
}
|
||||
const selectYear = (year: number) => {
|
||||
setViewMonth(p => ({ ...p, year }))
|
||||
setView('months')
|
||||
}
|
||||
const toggleOpen = () => {
|
||||
// Reopening lands on the days again: a picker left on the year grid
|
||||
// would otherwise open there next time, one level away from a date.
|
||||
setView('days')
|
||||
setOpen(!open)
|
||||
}
|
||||
|
||||
const prevLabel = view === 'days' ? t('common.datepicker.prevMonth')
|
||||
: view === 'months' ? t('common.datepicker.prevYear') : t('common.datepicker.prevYears')
|
||||
const nextLabel = view === 'days' ? t('common.datepicker.nextMonth')
|
||||
: view === 'months' ? t('common.datepicker.nextYear') : t('common.datepicker.nextYears')
|
||||
const headerLabel = view === 'days' ? monthName
|
||||
: view === 'months' ? String(viewMonth.year)
|
||||
: `${yearPageStart} – ${yearPageStart + YEAR_PAGE_SIZE - 1}`
|
||||
const headerAria = view === 'days' ? t('common.datepicker.selectMonth')
|
||||
: view === 'months' ? t('common.datepicker.selectYear') : undefined
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
|
||||
const cells: (number | null)[] = []
|
||||
for (let i = 0; i < firstDow; i++) cells.push(null)
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||
|
||||
const monthNames = Array.from({ length: 12 }, (_, i) =>
|
||||
new Date(viewMonth.year, i).toLocaleDateString(undefined, { month: 'short' }))
|
||||
const years = Array.from({ length: YEAR_PAGE_SIZE }, (_, i) => yearPageStart + i)
|
||||
|
||||
const formatted = value ? new Date(value + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : null
|
||||
|
||||
const navButton = 'w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500'
|
||||
const gridCell = (selected: boolean) => `h-9 rounded-lg text-[12px] font-medium flex items-center justify-center transition-colors ${
|
||||
selected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={toggleOpen}
|
||||
className="w-full px-3 py-2 border border-zinc-200 dark:border-zinc-700 rounded-lg text-[13px] bg-white dark:bg-zinc-800 text-zinc-900 dark:text-white text-left flex items-center justify-between"
|
||||
>
|
||||
{formatted ? (
|
||||
@@ -59,54 +125,86 @@ export function DatePicker({ value, onChange, tripDates }: {
|
||||
above closes the popover again from the keyboard. */}
|
||||
<div role="presentation" className="fixed inset-0 z-[10]" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 z-[20] bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg p-3 w-[280px]">
|
||||
{/* Month nav */}
|
||||
{/* Header: arrows step the current view, the label climbs to the next one up */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<button type="button" onClick={prevMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<button type="button" onClick={handlePrev} aria-label={prevLabel} className={navButton}>
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<span className="text-[13px] font-semibold text-zinc-900 dark:text-white">{monthName}</span>
|
||||
<button type="button" onClick={nextMonth} className="w-7 h-7 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-700 flex items-center justify-center text-zinc-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHeaderClick}
|
||||
aria-label={headerAria}
|
||||
disabled={view === 'years'}
|
||||
className="px-2 py-1 rounded-lg text-[13px] font-semibold text-zinc-900 dark:text-white hover:bg-zinc-100 dark:hover:bg-zinc-700 disabled:hover:bg-transparent disabled:cursor-default"
|
||||
>
|
||||
{headerLabel}
|
||||
</button>
|
||||
<button type="button" onClick={handleNext} aria-label={nextLabel} className={navButton}>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e${i}`} />
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === localIsoDate()
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => { onChange(dateStr); setOpen(false) }}
|
||||
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
||||
isSelected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: isToday
|
||||
? 'text-zinc-900 dark:text-white font-bold'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
{view === 'months' && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{monthNames.map((name, i) => (
|
||||
<button key={name} type="button" onClick={() => selectMonth(i)} className={gridCell(i === viewMonth.month)}>
|
||||
{name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'years' && (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{years.map(y => (
|
||||
<button key={y} type="button" onClick={() => selectYear(y)} className={gridCell(y === viewMonth.year)}>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'days' && (
|
||||
<>
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 mb-1">
|
||||
{['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'].map((d, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-medium text-zinc-400 py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{cells.map((day, i) => {
|
||||
if (day === null) return <div key={`e${i}`} />
|
||||
const dateStr = `${viewMonth.year}-${pad(viewMonth.month + 1)}-${pad(day)}`
|
||||
const isSelected = dateStr === value
|
||||
const isTrip = tripDates?.has(dateStr)
|
||||
const isToday = dateStr === localIsoDate()
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => { onChange(dateStr); setOpen(false) }}
|
||||
className={`w-9 h-9 rounded-lg text-[12px] font-medium flex items-center justify-center relative transition-colors ${
|
||||
isSelected
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
: isToday
|
||||
? 'text-zinc-900 dark:text-white font-bold'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
{isTrip && !isSelected && (
|
||||
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-indigo-500" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-JRN-PICKER-001 to FE-JRN-PICKER-020
|
||||
// FE-JRN-PICKER-001 to FE-JRN-PICKER-021
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { http, HttpResponse, delay } from 'msw'
|
||||
@@ -324,6 +324,39 @@ describe('ProviderPicker', () => {
|
||||
expect(range.to.endsWith('-09')).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-021: a location picked after the photos loaded reorders the grid', async () => {
|
||||
// contextLocation is live editor state: the place search and "use my location"
|
||||
// write it while this picker stays mounted, and the key is only provider plus
|
||||
// date. Sorting inside the fetch instead of in a memo leaves the grid frozen
|
||||
// while the caption above it already claims nearest-first.
|
||||
searchReturns([
|
||||
asset('rome', { lat: 41.9, lng: 12.5, takenAt: '2026-03-15T09:00:00.000Z' }),
|
||||
asset('helsinki', { lat: 60.17, lng: 24.94, takenAt: '2026-03-15T11:00:00.000Z' }),
|
||||
])
|
||||
const props = {
|
||||
provider: 'immich',
|
||||
userId: 42,
|
||||
entries,
|
||||
trips,
|
||||
existingAssetIds: new Set<string>(),
|
||||
onClose: vi.fn(),
|
||||
onAdd: vi.fn(async () => {}),
|
||||
} as React.ComponentProps<typeof ProviderPicker>
|
||||
const { container, rerender } = render(<ProviderPicker {...props} />)
|
||||
await screen.findByText('March 15, 2026')
|
||||
|
||||
const order = () => Array.from(container.querySelectorAll('img'))
|
||||
.map(img => (img.getAttribute('src') || '').split('/assets/0/')[1]?.split('/')[0])
|
||||
.filter(Boolean)
|
||||
|
||||
// Nothing to be near yet, so newest first.
|
||||
expect(order()).toEqual(['helsinki', 'rome'])
|
||||
|
||||
rerender(<ProviderPicker {...props} contextLocation={{ lat: 41.9, lng: 12.5, name: 'Rome' }} />)
|
||||
|
||||
await waitFor(() => expect(order()).toEqual(['rome', 'helsinki']))
|
||||
})
|
||||
|
||||
it('FE-JRN-PICKER-020: closes through the header button and the backdrop', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container, onClose } = mountPicker()
|
||||
|
||||
@@ -1,189 +1,244 @@
|
||||
import { useEffect, useState, useRef, useMemo } from 'react'
|
||||
import { X, Check, Calendar, ChevronRight, Camera } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { memoriesApi } from '../../api/client'
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore'
|
||||
import { groupPhotosByDate, sortProviderPhotos, type GeoPoint } from '../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger'
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker'
|
||||
import { Calendar, Camera, Check, ChevronRight, Play, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { memoriesApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
groupPhotosByDate,
|
||||
sortProviderPhotos,
|
||||
type GeoPoint,
|
||||
} from '../../pages/journeyDetail/JourneyDetailPage.helpers';
|
||||
import type { JourneyEntry, JourneyTrip } from '../../store/journeyStore';
|
||||
import { DatePicker } from './JourneyDetailPageDatePicker';
|
||||
import { ScrollTrigger } from './JourneyDetailPageScrollTrigger';
|
||||
|
||||
export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] }
|
||||
export type ProviderPhotoGroup = { assetIds: string[]; passphrase?: string; mediaTypes?: string[] };
|
||||
|
||||
export function ProviderPicker({ provider, userId, entries, trips, existingAssetIds, onClose, onAdd, initialDate, contextLocation, initialEntryId, embedded = false }: {
|
||||
provider: string
|
||||
userId: number
|
||||
entries: JourneyEntry[]
|
||||
trips: JourneyTrip[]
|
||||
existingAssetIds: Set<string>
|
||||
onClose: () => void
|
||||
onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>
|
||||
initialDate?: string
|
||||
contextLocation?: (GeoPoint & { name?: string }) | null
|
||||
initialEntryId?: number | null
|
||||
embedded?: boolean
|
||||
export function ProviderPicker({
|
||||
provider,
|
||||
userId,
|
||||
entries,
|
||||
trips,
|
||||
existingAssetIds,
|
||||
onClose,
|
||||
onAdd,
|
||||
initialDate,
|
||||
contextLocation,
|
||||
initialEntryId,
|
||||
embedded = false,
|
||||
}: {
|
||||
provider: string;
|
||||
userId: number;
|
||||
entries: JourneyEntry[];
|
||||
trips: JourneyTrip[];
|
||||
existingAssetIds: Set<string>;
|
||||
onClose: () => void;
|
||||
onAdd: (groups: ProviderPhotoGroup[], entryId: number | null) => Promise<void>;
|
||||
initialDate?: string;
|
||||
contextLocation?: (GeoPoint & { name?: string }) | null;
|
||||
initialEntryId?: number | null;
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip')
|
||||
const [photos, setPhotos] = useState<any[]>([])
|
||||
const [albums, setAlbums] = useState<Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>>([])
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null)
|
||||
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [searchPage, setSearchPage] = useState(1)
|
||||
const [searchFrom, setSearchFrom] = useState('')
|
||||
const [searchTo, setSearchTo] = useState('')
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(new Map())
|
||||
const [customFrom, setCustomFrom] = useState('')
|
||||
const [customTo, setCustomTo] = useState('')
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(initialEntryId ?? null)
|
||||
const [addToOpen, setAddToOpen] = useState(false)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation();
|
||||
const [filter, setFilter] = useState<'day' | 'trip' | 'custom' | 'all' | 'album'>(initialDate ? 'day' : 'trip');
|
||||
const [photos, setPhotos] = useState<any[]>([]);
|
||||
const [albums, setAlbums] = useState<
|
||||
Array<{ id: string; albumName: string; assetCount: number; passphrase?: string }>
|
||||
>([]);
|
||||
const [selectedAlbum, setSelectedAlbum] = useState<string | null>(null);
|
||||
const [selectedAlbumPassphrase, setSelectedAlbumPassphrase] = useState<string | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [searchPage, setSearchPage] = useState(1);
|
||||
const [searchFrom, setSearchFrom] = useState('');
|
||||
const [searchTo, setSearchTo] = useState('');
|
||||
const [selected, setSelected] = useState<Map<string, { albumId?: string; passphrase?: string; mediaType?: string }>>(
|
||||
new Map()
|
||||
);
|
||||
const [customFrom, setCustomFrom] = useState('');
|
||||
const [customTo, setCustomTo] = useState('');
|
||||
const [targetEntryId, setTargetEntryId] = useState<number | null>(initialEntryId ?? null);
|
||||
const [addToOpen, setAddToOpen] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// compute trip range
|
||||
const tripRange = useMemo(() => {
|
||||
let from = '', to = ''
|
||||
let from = '',
|
||||
to = '';
|
||||
for (const t of trips) {
|
||||
if (t.start_date && (!from || t.start_date < from)) from = t.start_date
|
||||
if (t.end_date && (!to || t.end_date > to)) to = t.end_date
|
||||
if (t.start_date && (!from || t.start_date < from)) from = t.start_date;
|
||||
if (t.end_date && (!to || t.end_date > to)) to = t.end_date;
|
||||
}
|
||||
return { from, to }
|
||||
}, [trips])
|
||||
return { from, to };
|
||||
}, [trips]);
|
||||
|
||||
const cancelPending = () => {
|
||||
if (abortRef.current) { abortRef.current.abort() }
|
||||
abortRef.current = new AbortController()
|
||||
return abortRef.current.signal
|
||||
}
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
abortRef.current = new AbortController();
|
||||
return abortRef.current.signal;
|
||||
};
|
||||
|
||||
const searchPhotos = async (from: string, to: string, page: number = 1, append: boolean = false) => {
|
||||
const signal = cancelPending()
|
||||
if (page === 1) { setLoading(true); setPhotos([]) } else { setLoadingMore(true) }
|
||||
setSearchFrom(from)
|
||||
setSearchTo(to)
|
||||
setSearchPage(page)
|
||||
const signal = cancelPending();
|
||||
if (page === 1) {
|
||||
setLoading(true);
|
||||
setPhotos([]);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
setSearchFrom(from);
|
||||
setSearchTo(to);
|
||||
setSearchPage(page);
|
||||
try {
|
||||
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal)
|
||||
const assets = data.assets || []
|
||||
setPhotos(prev => append ? [...prev, ...assets] : assets)
|
||||
setHasMore(!!data.hasMore)
|
||||
const data = await memoriesApi.search(provider, { from, to, page, size: 50 }, signal);
|
||||
const assets = data.assets || [];
|
||||
setPhotos((prev) => (append ? [...prev, ...assets] : assets));
|
||||
setHasMore(!!data.hasMore);
|
||||
} catch {
|
||||
// A cancelled request is about to be replaced by the next one, so leave
|
||||
// its paging state alone.
|
||||
if (!signal.aborted) setHasMore(false)
|
||||
if (!signal.aborted) setHasMore(false);
|
||||
}
|
||||
if (!signal.aborted) { setLoading(false); setLoadingMore(false) }
|
||||
}
|
||||
if (!signal.aborted) {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMorePhotos = () => {
|
||||
if (loadingMore || !hasMore) return
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true)
|
||||
}
|
||||
if (loadingMore || !hasMore) return;
|
||||
searchPhotos(searchFrom, searchTo, searchPage + 1, true);
|
||||
};
|
||||
|
||||
const loadAlbumPhotos = async (album: { id: string; passphrase?: string }) => {
|
||||
const signal = cancelPending()
|
||||
setLoading(true)
|
||||
setPhotos([])
|
||||
setHasMore(false)
|
||||
const signal = cancelPending();
|
||||
setLoading(true);
|
||||
setPhotos([]);
|
||||
setHasMore(false);
|
||||
try {
|
||||
setPhotos((await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [])
|
||||
} catch { /* ignore */ }
|
||||
if (!signal.aborted) setLoading(false)
|
||||
}
|
||||
const assets = (await memoriesApi.albumPhotos(provider, album.id, album.passphrase, signal)).assets || [];
|
||||
setPhotos(assets);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!signal.aborted) setLoading(false);
|
||||
};
|
||||
|
||||
const loadAlbums = async () => {
|
||||
try {
|
||||
setAlbums((await memoriesApi.albums(provider)).albums || [])
|
||||
setAlbums((await memoriesApi.albums(provider)).albums || []);
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
// load on mount / filter change
|
||||
useEffect(() => {
|
||||
if (filter === 'day' && initialDate) {
|
||||
searchPhotos(initialDate, initialDate)
|
||||
searchPhotos(initialDate, initialDate);
|
||||
} else if (filter === 'trip' && tripRange.from && tripRange.to) {
|
||||
searchPhotos(tripRange.from, tripRange.to)
|
||||
searchPhotos(tripRange.from, tripRange.to);
|
||||
} else if (filter === 'all') {
|
||||
searchPhotos('', '')
|
||||
searchPhotos('', '');
|
||||
} else if (filter === 'album' && albums.length === 0) {
|
||||
loadAlbums()
|
||||
loadAlbums();
|
||||
}
|
||||
}, [filter])
|
||||
}, [filter]);
|
||||
|
||||
const handleCustomSearch = () => {
|
||||
if (customFrom && customTo) searchPhotos(customFrom, customTo)
|
||||
}
|
||||
if (customFrom && customTo) searchPhotos(customFrom, customTo);
|
||||
};
|
||||
|
||||
// Sorted here rather than in the two fetches above. contextLocation is live
|
||||
// editor state: the place search and "use my location" write it while this
|
||||
// picker stays mounted, and its key is only provider plus date, so a fetch-time
|
||||
// sort would leave the grid frozen while the caption already says "near Rome".
|
||||
// It also keeps the ranking global instead of per page, which a sort inside
|
||||
// the appending search cannot do.
|
||||
const sortedPhotos = useMemo(
|
||||
() => sortProviderPhotos(photos, contextLocation),
|
||||
[photos, contextLocation?.lat, contextLocation?.lng],
|
||||
)
|
||||
);
|
||||
|
||||
const toggleAsset = (id: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Map(prev)
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
next.delete(id);
|
||||
} else {
|
||||
const mediaType = (photos as any[]).find(p => p.id === id)?.mediaType
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType })
|
||||
const mediaType = (photos as any[]).find((p) => p.id === id)?.mediaType;
|
||||
next.set(id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType });
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const targetLabel = targetEntryId
|
||||
? entries.find(e => e.id === targetEntryId)?.title || entries.find(e => e.id === targetEntryId)?.entry_date || t('journey.stats.entries')
|
||||
: t('journey.picker.newGallery')
|
||||
? entries.find((e) => e.id === targetEntryId)?.title ||
|
||||
entries.find((e) => e.id === targetEntryId)?.entry_date ||
|
||||
t('journey.stats.entries')
|
||||
: t('journey.picker.newGallery');
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={embedded ? 'journey-provider-picker-embedded' : undefined}
|
||||
className={embedded
|
||||
? 'w-full h-full min-h-0 flex flex-col overflow-hidden'
|
||||
: 'fixed inset-0 z-[9999] flex items-end md:items-center justify-center md:p-5 overscroll-none bg-[rgba(9,9,11,0.75)]'}
|
||||
className={
|
||||
embedded
|
||||
? 'flex h-full min-h-0 w-full flex-col overflow-hidden'
|
||||
: 'fixed inset-0 z-[9999] flex items-end justify-center overscroll-none bg-[rgba(9,9,11,0.75)] md:items-center md:p-5'
|
||||
}
|
||||
role="presentation"
|
||||
onClick={embedded ? undefined : onClose}
|
||||
onTouchMove={e => { if (!embedded && e.target === e.currentTarget) e.preventDefault() }}
|
||||
onTouchMove={(e) => {
|
||||
if (!embedded && e.target === e.currentTarget) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={embedded
|
||||
? 'bg-white dark:bg-zinc-900 w-full h-full flex flex-col overflow-hidden'
|
||||
: 'bg-white dark:bg-zinc-900 rounded-t-2xl md:rounded-2xl shadow-[0_20px_40px_rgba(0,0,0,0.2)] max-w-[720px] md:max-w-[960px] w-full max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] md:max-h-[85vh] flex flex-col overflow-hidden'}
|
||||
className={
|
||||
embedded
|
||||
? 'flex h-full w-full flex-col overflow-hidden bg-white dark:bg-zinc-900'
|
||||
: 'flex max-h-[calc(100dvh-var(--bottom-nav-h)-20px)] w-full max-w-[720px] flex-col overflow-hidden rounded-t-2xl bg-white shadow-[0_20px_40px_rgba(0,0,0,0.2)] md:max-h-[85vh] md:max-w-[960px] md:rounded-2xl dark:bg-zinc-900'
|
||||
}
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
|
||||
role="presentation"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
{/* Header */}
|
||||
{!embedded && <div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
|
||||
</h2>
|
||||
<button type="button" onClick={onClose} className="w-8 h-8 rounded-lg flex items-center justify-center text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>}
|
||||
{!embedded && (
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-b border-zinc-200 px-6 py-4 dark:border-zinc-700">
|
||||
<h2 className="text-[16px] font-bold text-zinc-900 dark:text-white">
|
||||
{provider === 'immich' ? 'Immich' : provider === 'synologyphotos' ? 'Synology Photos' : provider}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="px-6 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 px-6 py-3 dark:border-zinc-700">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1.5 mb-3">
|
||||
<div className="mb-3 flex gap-1.5">
|
||||
{[
|
||||
...(initialDate ? [{ id: 'day' as const, label: t('journey.picker.day') || 'This day' }] : []),
|
||||
{ id: 'trip' as const, label: t('journey.picker.tripPeriod') },
|
||||
{ id: 'custom' as const, label: t('journey.picker.dateRange') },
|
||||
{ id: 'all' as const, label: t('journey.picker.allPhotos'), short: t('common.all') },
|
||||
{ id: 'album' as const, label: t('journey.picker.albums') },
|
||||
].map(f => (
|
||||
<button type="button"
|
||||
].map((f) => (
|
||||
<button
|
||||
type="button"
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-[12px] font-medium transition-colors ${
|
||||
className={`rounded-lg px-3 py-1.5 text-[12px] font-medium transition-colors ${
|
||||
filter === f.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900'
|
||||
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900'
|
||||
: 'text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
@@ -192,180 +247,252 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
<span className="hidden sm:inline">{f.label}</span>
|
||||
<span className="sm:hidden">{f.short}</span>
|
||||
</>
|
||||
) : f.label}
|
||||
) : (
|
||||
f.label
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filter content — always visible row */}
|
||||
{(!embedded || filter !== 'day') && <div className="min-h-[36px] flex items-center">
|
||||
{filter === 'day' && initialDate && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</span>
|
||||
{contextLocation?.name && <span className="text-zinc-400">· near {contextLocation.name}</span>}
|
||||
</div>
|
||||
)}
|
||||
{filter === 'trip' && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
{tripRange.from && tripRange.to ? (
|
||||
<>
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-zinc-400">—</span>
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
<span className="ml-1 text-zinc-400">
|
||||
({Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) + 1} days)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(!embedded || filter !== 'day') && (
|
||||
<div className="flex min-h-[36px] items-center">
|
||||
{filter === 'day' && initialDate && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(initialDate + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
{contextLocation?.name && <span className="text-zinc-400">· near {contextLocation.name}</span>}
|
||||
</div>
|
||||
)}
|
||||
{filter === 'trip' && (
|
||||
<div className="flex items-center gap-2 text-[12px] text-zinc-500">
|
||||
{tripRange.from && tripRange.to ? (
|
||||
<>
|
||||
<Calendar size={13} className="text-zinc-400" />
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.from + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="text-zinc-400">—</span>
|
||||
<span className="font-medium text-zinc-900 dark:text-white">
|
||||
{new Date(tripRange.to + 'T00:00:00').toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
<span className="ml-1 text-zinc-400">
|
||||
(
|
||||
{Math.ceil((new Date(tripRange.to).getTime() - new Date(tripRange.from).getTime()) / 86400000) +
|
||||
1}{' '}
|
||||
days)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-zinc-400">{t('journey.trips.noTripsLinkedSettings')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'custom' && (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1"><DatePicker value={customFrom} onChange={setCustomFrom} /></div>
|
||||
<span className="text-zinc-400 text-[12px]">—</span>
|
||||
<div className="flex-1"><DatePicker value={customTo} onChange={setCustomTo} /></div>
|
||||
<button type="button" onClick={handleCustomSearch}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[12px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 flex-shrink-0">
|
||||
{t('journey.picker.search')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'album' && (
|
||||
<div className="flex gap-2 overflow-x-auto flex-1">
|
||||
{albums.map((a: any) => (
|
||||
<button type="button"
|
||||
key={a.id}
|
||||
onClick={() => { setSelectedAlbum(a.id); setSelectedAlbumPassphrase(a.passphrase); loadAlbumPhotos(a) }}
|
||||
className={`px-2.5 py-1 rounded-lg text-[11px] font-medium whitespace-nowrap flex-shrink-0 border ${
|
||||
selectedAlbum === a.id
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-200 dark:border-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
{filter === 'custom' && (
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<DatePicker value={customFrom} onChange={setCustomFrom} />
|
||||
</div>
|
||||
<span className="text-[12px] text-zinc-400">—</span>
|
||||
<div className="flex-1">
|
||||
<DatePicker value={customTo} onChange={setCustomTo} />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCustomSearch}
|
||||
className="flex-shrink-0 rounded-lg bg-zinc-900 px-3 py-1.5 text-[12px] font-medium text-white hover:bg-zinc-800 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
|
||||
>
|
||||
{a.albumName || a.name || 'Album'}{a.assetCount != null ? ` (${a.assetCount})` : ''}
|
||||
{t('journey.picker.search')}
|
||||
</button>
|
||||
))}
|
||||
{albums.length === 0 && !loading && <span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filter === 'album' && (
|
||||
<div className="flex flex-1 gap-2 overflow-x-auto">
|
||||
{albums.map((a: any) => (
|
||||
<button
|
||||
type="button"
|
||||
key={a.id}
|
||||
onClick={() => {
|
||||
setSelectedAlbum(a.id);
|
||||
setSelectedAlbumPassphrase(a.passphrase);
|
||||
loadAlbumPhotos(a);
|
||||
}}
|
||||
className={`flex-shrink-0 rounded-lg border px-2.5 py-1 text-[11px] font-medium whitespace-nowrap ${
|
||||
selectedAlbum === a.id
|
||||
? 'border-zinc-900 bg-zinc-900 text-white dark:border-white dark:bg-white dark:text-zinc-900'
|
||||
: 'border-zinc-200 text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
{a.albumName || a.name || 'Album'}
|
||||
{a.assetCount != null ? ` (${a.assetCount})` : ''}
|
||||
</button>
|
||||
))}
|
||||
{albums.length === 0 && !loading && (
|
||||
<span className="text-[12px] text-zinc-400">{t('journey.picker.noAlbums')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add-to entry selector */}
|
||||
{!embedded && <div className="px-6 py-2.5 border-b border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] uppercase text-zinc-500">{t('journey.picker.addTo')}</span>
|
||||
<button type="button"
|
||||
onClick={() => setAddToOpen(!addToOpen)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-zinc-200 dark:border-zinc-700 text-[12px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<span className={targetEntryId ? '' : 'font-semibold'}>{targetLabel}</span>
|
||||
<ChevronRight size={12} className="rotate-90 text-zinc-400" />
|
||||
</button>
|
||||
{addToOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setAddToOpen(false)} />
|
||||
<div className="absolute left-12 top-full mt-1 z-10 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-lg py-1.5 min-w-[200px] max-h-[240px] overflow-y-auto">
|
||||
<button type="button"
|
||||
onClick={() => { setTargetEntryId(null); setAddToOpen(false) }}
|
||||
className={`w-full text-left px-3 py-2 text-[12px] flex items-center gap-2 ${
|
||||
!targetEntryId
|
||||
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<Camera size={12} />
|
||||
{t('journey.picker.newGallery')}
|
||||
</button>
|
||||
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').length > 0 && (
|
||||
<div className="h-px bg-zinc-200 dark:bg-zinc-700 my-1" />
|
||||
)}
|
||||
{entries.filter(e => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]').map(e => (
|
||||
<button type="button"
|
||||
key={e.id}
|
||||
onClick={() => { setTargetEntryId(e.id); setAddToOpen(false) }}
|
||||
className={`w-full text-left px-3 py-2 text-[12px] truncate ${
|
||||
targetEntryId === e.id
|
||||
? 'bg-zinc-100 dark:bg-zinc-700 font-semibold text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-700 dark:text-zinc-300 hover:bg-zinc-50 dark:hover:bg-zinc-700'
|
||||
{!embedded && (
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 bg-zinc-50 px-6 py-2.5 dark:border-zinc-700 dark:bg-zinc-800/50">
|
||||
<div className="relative flex items-center gap-2">
|
||||
<span className="text-[10px] font-semibold tracking-[0.12em] text-zinc-500 uppercase">
|
||||
{t('journey.picker.addTo')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddToOpen(!addToOpen)}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-200 px-3 py-1.5 text-[12px] font-medium text-zinc-700 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<span className={targetEntryId ? '' : 'font-semibold'}>{targetLabel}</span>
|
||||
<ChevronRight size={12} className="rotate-90 text-zinc-400" />
|
||||
</button>
|
||||
{addToOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9]" role="presentation" onClick={() => setAddToOpen(false)} />
|
||||
<div className="absolute top-full left-12 z-10 mt-1 max-h-[240px] min-w-[200px] overflow-y-auto rounded-xl border border-zinc-200 bg-white py-1.5 shadow-lg dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTargetEntryId(null);
|
||||
setAddToOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] ${
|
||||
!targetEntryId
|
||||
? 'bg-zinc-100 font-semibold text-zinc-900 dark:bg-zinc-700 dark:text-white'
|
||||
: 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{e.title || e.location_name || new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' })}
|
||||
<Camera size={12} />
|
||||
{t('journey.picker.newGallery')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{entries.filter(
|
||||
(e) => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]'
|
||||
).length > 0 && <div className="my-1 h-px bg-zinc-200 dark:bg-zinc-700" />}
|
||||
{entries
|
||||
.filter((e) => e.type !== 'skeleton' && e.title !== 'Gallery' && e.title !== '[Trip Photos]')
|
||||
.map((e) => (
|
||||
<button
|
||||
type="button"
|
||||
key={e.id}
|
||||
onClick={() => {
|
||||
setTargetEntryId(e.id);
|
||||
setAddToOpen(false);
|
||||
}}
|
||||
className={`w-full truncate px-3 py-2 text-left text-[12px] ${
|
||||
targetEntryId === e.id
|
||||
? 'bg-zinc-100 font-semibold text-zinc-900 dark:bg-zinc-700 dark:text-white'
|
||||
: 'text-zinc-700 hover:bg-zinc-50 dark:text-zinc-300 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{e.title ||
|
||||
e.location_name ||
|
||||
new Date(e.entry_date + 'T12:00:00').toLocaleDateString(undefined, {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
)}
|
||||
|
||||
{/* Select all bar — sticky above grid */}
|
||||
{!loading && sortedPhotos.length > 0 && (() => {
|
||||
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id))
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id))
|
||||
if (selectable.length === 0) return null
|
||||
return (
|
||||
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 flex-shrink-0">
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Map())
|
||||
} else {
|
||||
setSelected(new Map(selectable.map((a: any) => [a.id, { albumId: selectedAlbum ?? undefined, passphrase: selectedAlbumPassphrase, mediaType: a.mediaType }])))
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[11px] font-medium border border-zinc-200 dark:border-zinc-700 text-zinc-500 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div className={`w-3.5 h-3.5 rounded border flex items-center justify-center ${
|
||||
allSelected
|
||||
? 'bg-zinc-900 dark:bg-white border-zinc-900 dark:border-white'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
}`}>
|
||||
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
|
||||
</div>
|
||||
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{!loading &&
|
||||
sortedPhotos.length > 0 &&
|
||||
(() => {
|
||||
const selectable = sortedPhotos.filter((a: any) => !existingAssetIds.has(a.id));
|
||||
const allSelected = selectable.length > 0 && selectable.every((a: any) => selected.has(a.id));
|
||||
if (selectable.length === 0) return null;
|
||||
return (
|
||||
<div className="flex-shrink-0 border-b border-zinc-200 bg-white px-4 py-2 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
setSelected(new Map());
|
||||
} else {
|
||||
setSelected(
|
||||
new Map(
|
||||
selectable.map((a: any) => [
|
||||
a.id,
|
||||
{
|
||||
albumId: selectedAlbum ?? undefined,
|
||||
passphrase: selectedAlbumPassphrase,
|
||||
mediaType: a.mediaType,
|
||||
},
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 px-2.5 py-1 text-[11px] font-medium text-zinc-500 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div
|
||||
className={`flex h-3.5 w-3.5 items-center justify-center rounded border ${
|
||||
allSelected
|
||||
? 'border-zinc-900 bg-zinc-900 dark:border-white dark:bg-white'
|
||||
: 'border-zinc-300 dark:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
{allSelected && <Check size={9} className="text-white dark:text-zinc-900" strokeWidth={3} />}
|
||||
</div>
|
||||
{allSelected ? t('journey.picker.deselectAll') : t('journey.picker.selectAll')} ({selectable.length})
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Photo grid */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-4 min-h-0">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="w-6 h-6 border-2 border-zinc-300 border-t-zinc-900 rounded-full animate-spin" />
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-zinc-300 border-t-zinc-900" />
|
||||
</div>
|
||||
) : sortedPhotos.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-[13px] text-zinc-500">
|
||||
{filter === 'trip' && !tripRange.from ? t('journey.trips.noTripsLinkedSettings') : t('journey.detail.noPhotos')}
|
||||
{filter === 'trip' && !tripRange.from
|
||||
? t('journey.trips.noTripsLinkedSettings')
|
||||
: t('journey.detail.noPhotos')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{groupPhotosByDate(sortedPhotos).map(group => (
|
||||
{groupPhotosByDate(sortedPhotos).map((group) => (
|
||||
<div key={group.date}>
|
||||
{(!embedded || filter !== 'day') && (
|
||||
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400 mb-2 mt-4 first:mt-0">
|
||||
<p className="mt-4 mb-2 text-[11px] font-medium text-zinc-500 first:mt-0 dark:text-zinc-400">
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-5 gap-1.5 mb-1">
|
||||
<div className="mb-1 grid grid-cols-3 gap-1.5 sm:grid-cols-4 md:grid-cols-5">
|
||||
{group.assets.map((asset: any) => {
|
||||
const isSelected = selected.has(asset.id)
|
||||
const alreadyAdded = existingAssetIds.has(asset.id)
|
||||
const isSelected = selected.has(asset.id);
|
||||
const alreadyAdded = existingAssetIds.has(asset.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -374,42 +501,47 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
aria-pressed={isSelected}
|
||||
aria-label={asset.city || group.label}
|
||||
onClick={() => !alreadyAdded && toggleAsset(asset.id)}
|
||||
className={`relative block w-full aspect-square rounded-lg overflow-hidden ${
|
||||
className={`relative block aspect-square w-full overflow-hidden rounded-lg ${
|
||||
alreadyAdded
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: isSelected
|
||||
? 'ring-2 ring-zinc-900 dark:ring-white ring-offset-2 dark:ring-offset-zinc-900 cursor-pointer'
|
||||
? 'cursor-pointer ring-2 ring-zinc-900 ring-offset-2 dark:ring-white dark:ring-offset-zinc-900'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/thumbnail${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onError={e => {
|
||||
const img = e.currentTarget
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`
|
||||
if (!img.src.includes('/original')) img.src = original
|
||||
onError={(e) => {
|
||||
const img = e.currentTarget;
|
||||
const original = `/api/integrations/memories/${provider}/assets/0/${asset.id}/${userId}/original${selectedAlbumPassphrase ? `?passphrase=${encodeURIComponent(selectedAlbumPassphrase)}` : ''}`;
|
||||
if (!img.src.includes('/original')) img.src = original;
|
||||
}}
|
||||
/>
|
||||
{alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-500 text-white flex items-center justify-center">
|
||||
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-500 text-white">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !alreadyAdded && (
|
||||
<div className="absolute top-1.5 right-1.5 w-5 h-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 flex items-center justify-center">
|
||||
<div className="absolute top-1.5 right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-zinc-900 text-white dark:bg-white dark:text-zinc-900">
|
||||
<Check size={12} />
|
||||
</div>
|
||||
)}
|
||||
{asset.mediaType === 'video' && (
|
||||
<div className="pointer-events-none absolute bottom-1.5 left-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-black/60">
|
||||
<Play size={8} className="ml-px text-white" fill="currentColor" />
|
||||
</div>
|
||||
)}
|
||||
{asset.city && (
|
||||
<div className="absolute bottom-0 left-0 right-0 p-1 bg-gradient-to-t from-black/50 to-transparent">
|
||||
<p className="text-[8px] text-white truncate">{asset.city}</p>
|
||||
<div className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/50 to-transparent p-1">
|
||||
<p className="truncate text-[8px] text-white">{asset.city}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -421,35 +553,58 @@ export function ProviderPicker({ provider, userId, entries, trips, existingAsset
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800/50 flex-shrink-0">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-zinc-200/60 dark:bg-zinc-700/60 text-[11px] leading-none text-zinc-500 dark:text-zinc-400">
|
||||
<span className="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[10px] leading-none font-bold">{selected.size}</span>
|
||||
<div className="flex flex-shrink-0 items-center justify-between border-t border-zinc-200 bg-zinc-50 px-6 py-4 dark:border-zinc-700 dark:bg-zinc-800/50">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-zinc-200/60 px-2.5 py-1 text-[11px] leading-none text-zinc-500 dark:bg-zinc-700/60 dark:text-zinc-400">
|
||||
<span className="inline-flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-zinc-900 px-1 text-[10px] leading-none font-bold text-white dark:bg-white dark:text-zinc-900">
|
||||
{selected.size}
|
||||
</span>
|
||||
<span className="leading-[18px]">{t('journey.picker.selected')}</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={onClose} className="px-3.5 py-2 rounded-lg border border-zinc-200 dark:border-zinc-600 text-[13px] font-medium text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-zinc-200 px-3.5 py-2 text-[13px] font-medium text-zinc-700 hover:bg-zinc-100 dark:border-zinc-600 dark:text-zinc-300 dark:hover:bg-zinc-700"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>()
|
||||
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
|
||||
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] }
|
||||
g.assetIds.push(assetId)
|
||||
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image')
|
||||
groupMap.set(passphrase, g)
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (adding) return;
|
||||
setAdding(true);
|
||||
try {
|
||||
const groupMap = new Map<string | undefined, { assetIds: string[]; mediaTypes: string[] }>();
|
||||
for (const [assetId, { passphrase, mediaType }] of selected.entries()) {
|
||||
const g = groupMap.get(passphrase) || { assetIds: [], mediaTypes: [] };
|
||||
g.assetIds.push(assetId);
|
||||
g.mediaTypes.push(mediaType === 'video' ? 'video' : 'image');
|
||||
groupMap.set(passphrase, g);
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({
|
||||
assetIds: g.assetIds,
|
||||
mediaTypes: g.mediaTypes,
|
||||
passphrase,
|
||||
}));
|
||||
await onAdd(groups, targetEntryId);
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
const groups = [...groupMap.entries()].map(([passphrase, g]) => ({ assetIds: g.assetIds, mediaTypes: g.mediaTypes, passphrase }))
|
||||
onAdd(groups, targetEntryId)
|
||||
}}
|
||||
disabled={selected.size === 0}
|
||||
className="px-3.5 py-2 rounded-lg bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 text-[13px] font-medium hover:bg-zinc-800 dark:hover:bg-zinc-100 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
disabled={selected.size === 0 || adding}
|
||||
className="rounded-lg bg-zinc-900 px-3.5 py-2 text-[13px] font-medium text-white hover:bg-zinc-800 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-100"
|
||||
>
|
||||
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
{adding ? (
|
||||
<span className="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-zinc-400 border-t-white dark:border-t-zinc-900" />
|
||||
) : (
|
||||
<>
|
||||
{t('common.add')} {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-JSCROLLTRIG-001 to FE-COMP-JSCROLLTRIG-006
|
||||
// FE-COMP-JSCROLLTRIG-001 to FE-COMP-JSCROLLTRIG-009
|
||||
|
||||
import { render } from '../../../tests/helpers/render'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
@@ -87,4 +87,44 @@ describe('ScrollTrigger', () => {
|
||||
unmount()
|
||||
expect(observers[0].disconnect).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JSCROLLTRIG-007: a fresh callback identity alone does not rebuild the observer', () => {
|
||||
// Both callers hand in a new arrow every render; rebuilding for that churn
|
||||
// replays the initial callback and pulls pages nobody asked for.
|
||||
const { rerender } = render(<ScrollTrigger onVisible={vi.fn()} loading={false} />)
|
||||
expect(observers).toHaveLength(1)
|
||||
|
||||
rerender(<ScrollTrigger onVisible={vi.fn()} loading={false} />)
|
||||
rerender(<ScrollTrigger onVisible={vi.fn()} loading={false} />)
|
||||
|
||||
expect(observers).toHaveLength(1)
|
||||
expect(observers[0].disconnect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-JSCROLLTRIG-008: the surviving observer calls the newest callback, not the one it was built with', () => {
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
const { rerender } = render(<ScrollTrigger onVisible={first} loading={false} />)
|
||||
rerender(<ScrollTrigger onVisible={second} loading={false} />)
|
||||
|
||||
intersect(observers[0], true)
|
||||
|
||||
expect(first).not.toHaveBeenCalled()
|
||||
expect(second).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-JSCROLLTRIG-009: a settled page really re-observes the sentinel', () => {
|
||||
// A browser observer only fires on a threshold crossing plus once per
|
||||
// observe(). A sentinel that never leaves the 200px margin therefore only
|
||||
// gets another callback because the loading flip rebuilds the observer, so
|
||||
// loading must stay a dependency rather than moving into a ref as well.
|
||||
const onVisible = vi.fn()
|
||||
const { rerender } = render(<ScrollTrigger onVisible={onVisible} loading />)
|
||||
expect(observers).toHaveLength(1)
|
||||
|
||||
rerender(<ScrollTrigger onVisible={onVisible} loading={false} />)
|
||||
|
||||
expect(observers).toHaveLength(2)
|
||||
expect(observers[0].disconnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,13 +2,24 @@ import { useEffect, useRef } from 'react'
|
||||
|
||||
export function ScrollTrigger({ onVisible, loading }: { onVisible: () => void; loading: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
// Both callers hand in a fresh arrow on every render — the picker its
|
||||
// loadMorePhotos, the gallery its setVisibleCount step. With onVisible in the
|
||||
// effect deps, every unrelated re-render tore the observer down and observed
|
||||
// again, and each observe() replays the initial callback. The ref keeps the
|
||||
// observer out of that churn.
|
||||
//
|
||||
// loading stays a dependency on purpose: re-observing after a page has landed
|
||||
// is the only thing that still produces a callback when the sentinel never
|
||||
// left the 200px margin.
|
||||
const onVisibleRef = useRef(onVisible)
|
||||
onVisibleRef.current = onVisible
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisible() }, { rootMargin: '200px' })
|
||||
const obs = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !loading) onVisibleRef.current() }, { rootMargin: '200px' })
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [onVisible, loading])
|
||||
}, [loading])
|
||||
return (
|
||||
<div ref={ref} className="flex justify-center py-4 mt-2">
|
||||
<div className="w-5 h-5 border-2 border-zinc-300 border-t-zinc-900 dark:border-zinc-600 dark:border-t-white rounded-full animate-spin" />
|
||||
|
||||
@@ -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';
|
||||
@@ -81,6 +86,9 @@ vi.mock('@maplibre/maplibre-gl-leaflet', () => ({
|
||||
}),
|
||||
}));
|
||||
vi.mock('../Map/engines/maplibre', () => ({ default: {} }));
|
||||
// jsdom refuses a WebGL context, and the basemap now believes it (#2288). These
|
||||
// cases are about the GL layer, so the probe says yes here.
|
||||
vi.mock('../../utils/webgl', () => ({ hasWebGL: () => true, resetWebGLProbe: () => {} }));
|
||||
|
||||
const entriesWithCoords = [
|
||||
{ id: 'e1', lat: 48.8566, lng: 2.3522, title: 'Paris', mood: null, entry_date: '2025-06-01' },
|
||||
@@ -693,4 +701,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 }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useCartoApiKey } from '../../hooks/useTileUrl'
|
||||
import { isVectorStyle, resolveTileUrl } from '../../utils/tileUrl'
|
||||
import { OFM_DARK, OFM_POSITRON, attributionForTile } from '../../constants/mapDefaults'
|
||||
import { attachVectorBasemap, type GlLeafletLayer } from '../Map/VectorBasemap'
|
||||
import { attachVectorBasemap, detachBasemapLayer, restyleBasemap, type BasemapLayer } from '../Map/VectorBasemap'
|
||||
import { escapeHtml, type JourneyTrack } from '@trek/shared'
|
||||
|
||||
export interface MapMarkerItem {
|
||||
@@ -162,7 +162,8 @@ function JourneyMap(
|
||||
const tileUrlRef = useRef(tileUrl)
|
||||
tileUrlRef.current = tileUrl
|
||||
const tileLayerRef = useRef<L.TileLayer | null>(null)
|
||||
const glLayerRef = useRef<GlLeafletLayer | null>(null)
|
||||
// GL layer or the raster stand-in a browser without WebGL gets instead (#2288).
|
||||
const glLayerRef = useRef<BasemapLayer | null>(null)
|
||||
// The vector basemap loads async; a map torn down before it lands must not get one.
|
||||
const cancelledRef = useRef(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
@@ -379,7 +380,7 @@ function JourneyMap(
|
||||
map.remove()
|
||||
mapRef.current = null
|
||||
tileLayerRef.current = null
|
||||
glLayerRef.current?.remove()
|
||||
detachBasemapLayer(glLayerRef.current)
|
||||
glLayerRef.current = null
|
||||
markersRef.current.clear()
|
||||
}
|
||||
@@ -389,7 +390,7 @@ function JourneyMap(
|
||||
// marker and track it just drew. A vector basemap restyles instead, which also
|
||||
// avoids spending a WebGL context on every theme toggle.
|
||||
useEffect(() => {
|
||||
if (isVectorStyle(tileUrl)) glLayerRef.current?.getMaplibreMap()?.setStyle(tileUrl)
|
||||
if (isVectorStyle(tileUrl)) restyleBasemap(glLayerRef.current, tileUrl)
|
||||
else tileLayerRef.current?.setUrl(tileUrl)
|
||||
}, [tileUrl])
|
||||
|
||||
@@ -405,6 +406,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], {
|
||||
|
||||
@@ -25,14 +25,33 @@ describe('buildNavItems', () => {
|
||||
})
|
||||
|
||||
describe('splitMobileNav', () => {
|
||||
it('falls back to Dashboard + first two in the bar when there is no config', () => {
|
||||
it('docks the top two enabled defaults when there is no config', () => {
|
||||
for (const cfg of [undefined, { bar: [], more: [] }]) {
|
||||
const s = splitMobileNav(items, cfg)
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
|
||||
expect(ids(s.more)).toEqual(['journey', 'collections'])
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'journey'])
|
||||
// Atlas is still reachable, one tap further under More.
|
||||
expect(ids(s.more)).toEqual(['atlas', 'collections'])
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the Vacay/Atlas dock on an instance where Journey is switched off', () => {
|
||||
const s = splitMobileNav([item('dashboard'), item('vacay'), item('atlas')], undefined)
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
|
||||
expect(ids(s.more)).toEqual([])
|
||||
})
|
||||
|
||||
it('fills the free slot from the next default rather than leaving it empty', () => {
|
||||
const s = splitMobileNav([item('dashboard'), item('atlas'), item('journey')], undefined)
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'journey', 'atlas'])
|
||||
expect(ids(s.more)).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves an account that picked its own dock alone', () => {
|
||||
const s = splitMobileNav(items, { bar: ['vacay', 'atlas'], more: ['journey', 'collections'] })
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'vacay', 'atlas'])
|
||||
expect(ids(s.more)).toEqual(['journey', 'collections'])
|
||||
})
|
||||
|
||||
it('honours a custom split and order, Dashboard still first', () => {
|
||||
const s = splitMobileNav(items, { bar: ['journey'], more: ['collections', 'vacay', 'atlas'] })
|
||||
expect(ids(s.bar)).toEqual(['dashboard', 'journey'])
|
||||
|
||||
@@ -35,8 +35,18 @@ const ADDON_ICONS: Record<string, LucideIcon> = {
|
||||
/** The bar holds Dashboard + at most this many custom items; the rest go to More.
|
||||
* Two keeps the dock at 3 destinations (Dashboard + 2) so the More slot still fits. */
|
||||
export const MOBILE_NAV_MAX_BAR = 2
|
||||
/** The built-in dock next to Dashboard for an un-customised account; everything else starts under More. */
|
||||
export const DEFAULT_DOCK_IDS = ['vacay', 'atlas']
|
||||
/**
|
||||
* The built-in dock next to Dashboard for an un-customised account, in priority
|
||||
* order: the first MOBILE_NAV_MAX_BAR of these that are actually enabled take the
|
||||
* slots, everything else starts under More.
|
||||
*
|
||||
* Journey outranks Atlas because a phone in the field wants the journal it writes
|
||||
* to every day, not the map of countries it already visited. Journey ships
|
||||
* disabled though, so an instance that never turned it on keeps the Vacay/Atlas
|
||||
* dock it has today. Listing all four known global addons rather than just the
|
||||
* top two also keeps the dock filled when one of them is switched off.
|
||||
*/
|
||||
export const DEFAULT_DOCK_IDS = ['vacay', 'journey', 'atlas', 'collections']
|
||||
|
||||
export function buildNavItems(
|
||||
globalAddons: { id: string; name: string; icon: string }[],
|
||||
@@ -90,10 +100,10 @@ export interface MobileNavSplit {
|
||||
/**
|
||||
* Resolve the persisted `{ bar, more }` id split against the live nav items.
|
||||
* Dashboard is always pinned first in the bar. An empty/absent config falls back
|
||||
* to the built-in dock (Dashboard + Vacay/Atlas where enabled, everything else
|
||||
* under More) so an un-customised user sees exactly today's layout. Stored ids
|
||||
* that no longer resolve are dropped; newly available items are appended under
|
||||
* More.
|
||||
* to the built-in dock (Dashboard + the top DEFAULT_DOCK_IDS that are enabled,
|
||||
* everything else under More). Stored ids that no longer resolve are dropped;
|
||||
* newly available items are appended under More, so an account that once picked
|
||||
* its own dock keeps it untouched.
|
||||
*/
|
||||
export function splitMobileNav(
|
||||
items: NavItemDef[],
|
||||
@@ -102,16 +112,20 @@ export function splitMobileNav(
|
||||
const dashboard = items.find((i) => i.id === 'dashboard')
|
||||
const head = dashboard ? [dashboard] : []
|
||||
const rest = items.filter((i) => i.id !== 'dashboard')
|
||||
const byId = new Map(rest.map((i) => [i.id, i]))
|
||||
const pick = (ids: string[]) => ids.map((id) => byId.get(id)).filter((x): x is NavItemDef => !!x)
|
||||
|
||||
if (!cfg || (cfg.bar.length === 0 && cfg.more.length === 0)) {
|
||||
const dock = pick(DEFAULT_DOCK_IDS).slice(0, MOBILE_NAV_MAX_BAR)
|
||||
const docked = new Set(dock.map((i) => i.id))
|
||||
return {
|
||||
bar: [...head, ...rest.filter((i) => DEFAULT_DOCK_IDS.includes(i.id))],
|
||||
more: rest.filter((i) => !DEFAULT_DOCK_IDS.includes(i.id)),
|
||||
bar: [...head, ...dock],
|
||||
// Store order, not DEFAULT_DOCK_IDS order, so More keeps reading like the
|
||||
// addon list itself.
|
||||
more: rest.filter((i) => !docked.has(i.id)),
|
||||
}
|
||||
}
|
||||
|
||||
const byId = new Map(rest.map((i) => [i.id, i]))
|
||||
const pick = (ids: string[]) => ids.map((id) => byId.get(id)).filter((x): x is NavItemDef => !!x)
|
||||
const known = new Set([...cfg.bar, ...cfg.more])
|
||||
|
||||
const barPicked = pick(cfg.bar)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
import { Navigation, LocateFixed, Locate } from 'lucide-react'
|
||||
import type { TrackingMode, GeoWatchErrorCode } from '../../hooks/useGeolocation'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -77,7 +78,7 @@ export default function LocationButton({ mode, error, errorCode, onClick, bottom
|
||||
cursor: 'pointer',
|
||||
background: isActive ? '#3b82f6' : 'var(--bg-card, white)',
|
||||
color: isActive ? 'white' : (error ? '#ef4444' : 'var(--text-muted, #6b7280)'),
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.25)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Map as MapIcon, Satellite } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
|
||||
export type BaseLayer = 'default' | 'satellite'
|
||||
|
||||
@@ -18,7 +19,7 @@ export function MapLayerSwitcher({ active, onToggle }: { active: BaseLayer; onTo
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
}}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -18,7 +18,13 @@ const mapMock = vi.hoisted(() => ({
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
panBy: vi.fn(),
|
||||
latLngToContainerPoint: vi.fn(() => ({ x: 0, y: 0, distanceTo: () => 1000 })),
|
||||
// A flat projection, 1000 px per degree: far enough apart that every
|
||||
// booking line clears its declutter floor, which is measured along the
|
||||
// projected line (#2275) rather than read off a canned distanceTo.
|
||||
latLngToContainerPoint: vi.fn(([lat, lng]: [number, number]) => ({
|
||||
x: lng * 1000, y: lat * 1000,
|
||||
distanceTo(other: { x: number; y: number }) { return Math.hypot(lng * 1000 - other.x, lat * 1000 - other.y) },
|
||||
})),
|
||||
// Panes: jsdom has none, so keep them in a map the pane tests can read back.
|
||||
panes: new Map<string, HTMLElement>(),
|
||||
getPane: vi.fn(function (this: void, name: string) { return mapMock.panes.get(name) }),
|
||||
@@ -221,6 +227,24 @@ describe('MapView', () => {
|
||||
expect(screen.getAllByTestId('polyline').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-006b: a caller-coloured route takes its own core and casing', () => {
|
||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }]} />)
|
||||
|
||||
const [casing, core] = screen.getAllByTestId('polyline')
|
||||
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
|
||||
expect(casing.color).toBe('#c2740a')
|
||||
expect(core.color).toBe('#ff9f0a')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-006c: a line with no colour of its own keeps the route blue', () => {
|
||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} routeColors={[undefined]} />)
|
||||
|
||||
const [casing, core] = screen.getAllByTestId('polyline')
|
||||
.map(el => JSON.parse(el.getAttribute('data-path-options') as string))
|
||||
expect(casing.color).toBe('#0a5cc2')
|
||||
expect(core.color).toBe('#0a84ff')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-007: does not render polyline when route is null', () => {
|
||||
render(<MapView route={null} />)
|
||||
expect(screen.queryByTestId('polyline')).toBeNull()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -509,6 +606,9 @@ export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
// One colour pair per entry of `route`, or absent for the blue the route has always
|
||||
// been. Only the road trip passes these, and only while colouring by day is on.
|
||||
routeColors = null,
|
||||
routeSegments = [],
|
||||
selectedPlaceId = null,
|
||||
hoverDisabled = false,
|
||||
@@ -538,6 +638,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 +659,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])
|
||||
@@ -820,7 +940,12 @@ export const MapView = memo(function MapView({
|
||||
// Store flips state synchronously (instant, works offline); a failed save is logged there.
|
||||
updateSetting('map_base_layer', isSatellite ? 'default' : 'satellite').catch(() => {})
|
||||
}, [isSatellite, updateSetting])
|
||||
const switcherBottom = hasDayDetail
|
||||
// The day panel is a centred card between the two sidebars, so on the desktop
|
||||
// map it never reaches this pill sitting hard against the left sidebar. Lifting
|
||||
// it by the panel height only made it hop on every collapse and expand, since
|
||||
// --day-panel-h changes while hasDayDetail stays true. On a phone the panel is
|
||||
// full width and does cover the pill, so the lift stays there.
|
||||
const switcherBottom = isMobile && hasDayDetail
|
||||
? 'calc(var(--bottom-nav-h, 0px) + 20px + var(--day-panel-h, 0px) + 12px)'
|
||||
: 'calc(var(--bottom-nav-h, 0px) + 12px)'
|
||||
|
||||
@@ -872,43 +997,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' }}
|
||||
pathOptions={{ color: routeColors?.[i]?.casing ?? '#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' }}
|
||||
pathOptions={{ color: routeColors?.[i]?.line ?? '#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}
|
||||
|
||||
@@ -1540,6 +1540,21 @@ describe('MapViewGL', () => {
|
||||
expect(features[0].geometry.coordinates).toEqual([[2, 48], [3, 49]])
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-048b: a caller-coloured route rides its colours on the feature', async () => {
|
||||
const routeSource = geoSource()
|
||||
glMap.getSource.mockImplementation((id: string) => (id === 'trip-route' ? routeSource : null))
|
||||
|
||||
render(<MapViewGL places={[]} fitKey={1} route={[[[48, 2], [49, 3]], [[50, 4], [51, 5]]]}
|
||||
routeColors={[{ line: '#ff9f0a', casing: '#c2740a' }, undefined]} />)
|
||||
await act(async () => {})
|
||||
|
||||
const { features } = lastData(routeSource)
|
||||
expect(features[0].properties.color).toBe('#ff9f0a')
|
||||
expect(features[0].properties.casing).toBe('#c2740a')
|
||||
// No colour means the day route's own blue, which the layer paint falls back to.
|
||||
expect(features[1].properties.color).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-049: unusable GPX geometry is skipped instead of breaking the layer', async () => {
|
||||
const gpxSource = geoSource()
|
||||
glMap.getSource.mockImplementation((id: string) => (id === 'trip-gpx' ? gpxSource : null))
|
||||
@@ -1855,4 +1870,168 @@ 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)
|
||||
})
|
||||
// ── Satellite ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Leaflet has had the imagery for a while and swaps its whole tile layer for it.
|
||||
// A GL map cannot do that, because its basemap is a style with dozens of layers,
|
||||
// so the imagery goes on as a raster layer under everything TREK draws.
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-074: satellite adds the imagery under the first TREK layer', async () => {
|
||||
loadOnAttach()
|
||||
useSettingsStore.setState({
|
||||
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
|
||||
} as never)
|
||||
glMap.getStyle.mockReturnValue({
|
||||
layers: [{ id: 'background' }, { id: 'road' }, { id: 'trip-route' }, { id: 'trip-gpx-hit' }],
|
||||
})
|
||||
|
||||
render(<MapViewGL places={[]} fitKey={1} />)
|
||||
await flushFrames()
|
||||
|
||||
expect(glMap.addSource).toHaveBeenCalledWith('trip-satellite', expect.objectContaining({
|
||||
type: 'raster',
|
||||
tiles: [expect.stringContaining('arcgisonline.com')],
|
||||
}))
|
||||
// Anchored before the route, or the imagery would be painted over it.
|
||||
expect(glMap.addLayer).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'trip-satellite-raster', type: 'raster' }),
|
||||
'trip-route',
|
||||
)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-075: with the default basemap no imagery is fetched at all', async () => {
|
||||
loadOnAttach()
|
||||
useSettingsStore.setState({
|
||||
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' },
|
||||
} as never)
|
||||
|
||||
render(<MapViewGL places={[]} fitKey={1} />)
|
||||
await flushFrames()
|
||||
|
||||
expect(glMap.addSource).not.toHaveBeenCalledWith('trip-satellite', expect.anything())
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-076: switching back hides the layer instead of tearing it down', async () => {
|
||||
loadOnAttach()
|
||||
useSettingsStore.setState({
|
||||
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'satellite' },
|
||||
} as never)
|
||||
glMap.getStyle.mockReturnValue({ layers: [{ id: 'background' }, { id: 'trip-route' }] })
|
||||
// Present from the first pass on, the way the real map reports it afterwards.
|
||||
glMap.getSource.mockImplementation((id: string) => (id === 'trip-satellite' ? {} : null))
|
||||
glMap.getLayer.mockImplementation((id: string) => (id === 'trip-satellite-raster' ? {} : null))
|
||||
|
||||
const { rerender } = render(<MapViewGL places={[]} fitKey={1} />)
|
||||
await flushFrames()
|
||||
|
||||
act(() => {
|
||||
useSettingsStore.setState({
|
||||
settings: { ...useSettingsStore.getState().settings, map_base_layer: 'default' },
|
||||
} as never)
|
||||
})
|
||||
rerender(<MapViewGL places={[]} fitKey={1} />)
|
||||
await flushFrames()
|
||||
|
||||
expect(glMap.setLayoutProperty).toHaveBeenCalledWith('trip-satellite-raster', 'visibility', 'none')
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-024
|
||||
// FE-COMP-RESOVERLAY-001 to FE-COMP-RESOVERLAY-045
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
@@ -389,6 +389,43 @@ describe('ReservationOverlay declutter floors and road routes', () => {
|
||||
expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual([[48, 16], [51.5, 16]])
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-043: a routed drive whose ends project close together still draws (#2275)', () => {
|
||||
// Endpoints 50 px apart, under the 80 px car floor, so the straight hop would
|
||||
// hide. The road between them loops out 0.3° (300 px) and back, which is what
|
||||
// is actually drawn — and what the person was missing on their map.
|
||||
const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]]
|
||||
const { unmount } = renderOverlay({ reservations: [hop('car', 7, 0.05)] })
|
||||
expect(lines()).toHaveLength(0)
|
||||
unmount()
|
||||
|
||||
renderOverlay({ reservations: [hop('car', 7, 0.05)], roadRoutes: new Map([[7, road]]) })
|
||||
expect(lines()).toHaveLength(1)
|
||||
expect(JSON.parse(lines()[0].getAttribute('data-points') || '[]')).toEqual(road)
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-044: a drive with a stop on the way is measured along its legs, not its ends', () => {
|
||||
// From and to sit 20 px apart; the stop is 400 px away, so the two legs
|
||||
// together draw 800 px of line.
|
||||
renderOverlay({
|
||||
reservations: [booking({
|
||||
id: 8, type: 'car',
|
||||
endpoints: [
|
||||
endpoint({ role: 'from', sequence: 0, name: 'Home', lat: 48, lng: 16 }),
|
||||
endpoint({ role: 'stop', sequence: 1, name: 'Lake', lat: 48.4, lng: 16 }),
|
||||
endpoint({ role: 'to', sequence: 2, name: 'Back', lat: 48.02, lng: 16 }),
|
||||
],
|
||||
})],
|
||||
})
|
||||
expect(lines()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-045: a routed drive that really is tiny on screen stays decluttered', () => {
|
||||
// Every point of the road projects within 30 px: nothing worth drawing yet.
|
||||
const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]]
|
||||
renderOverlay({ reservations: [hop('car', 7, 0.03)], roadRoutes: new Map([[7, road]]) })
|
||||
expect(lines()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-COMP-RESOVERLAY-036: a booking without an endpoints array is skipped', () => {
|
||||
renderOverlay({ reservations: [booking({ endpoints: undefined })] })
|
||||
expect(lines()).toHaveLength(0)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getTransitMapSegments, type TransitMapSegment } from './transitGeometry
|
||||
import { geodesicArcs } from './flightGeodesy'
|
||||
import { cleanEndpointName } from './reservationName'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
const ENDPOINT_PANE = 'reservation-endpoints'
|
||||
@@ -122,6 +123,12 @@ function computeDuration(from: ReservationEndpoint, to: ReservationEndpoint, fal
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
|
||||
function linesFor(item: TransportItem, roadRoutes: Map<number, [number, number][]> | undefined): [number, number][][] {
|
||||
const road = roadRoutes?.get(item.res.id)
|
||||
return road && road.length >= 2 ? [road] : item.arcs
|
||||
}
|
||||
|
||||
interface TransportItem {
|
||||
res: Reservation
|
||||
from: ReservationEndpoint
|
||||
@@ -200,26 +207,25 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
|
||||
}, [reservations])
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const project = (p: readonly [number, number]) => map.latLngToContainerPoint([p[0], p[1]])
|
||||
return items.filter(item => {
|
||||
// A transit journey draws its real rail/bus alignment, not a straight from->to
|
||||
// line, so the endpoint-proximity declutter (which exists to hide tiny no-op
|
||||
// straight connectors) must not suppress it. Otherwise a zoomed-out day — e.g. one
|
||||
// with no other places to tighten the map onto — hides the whole route (#1570).
|
||||
if (item.transitSegs.length > 0) return true
|
||||
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
||||
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
|
||||
return fromPx.distanceTo(toPx) >= minPx
|
||||
// Measured along what is drawn, not between the ends: a routed drive can
|
||||
// cover half the screen while its endpoints sit close together (#2275).
|
||||
return hopIsVisible(item.type, linesFor(item, roadRoutes), project)
|
||||
})
|
||||
}, [items, zoom, map])
|
||||
}, [items, zoom, map, roadRoutes])
|
||||
|
||||
const labelVisibleIds = useMemo(() => {
|
||||
const set = new Set<number>()
|
||||
for (const item of visibleItems) {
|
||||
const fromPx = map.latLngToContainerPoint([item.from.lat, item.from.lng])
|
||||
const toPx = map.latLngToContainerPoint([item.to.lat, item.to.lng])
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
|
||||
if (fromPx.distanceTo(toPx) >= minPx) set.add(item.res.id)
|
||||
if (fromPx.distanceTo(toPx) >= labelFloorPx(item.type)) set.add(item.res.id)
|
||||
}
|
||||
return set
|
||||
}, [visibleItems, zoom, map])
|
||||
@@ -248,9 +254,7 @@ export default function ReservationOverlay({ reservations, showConnections, onEn
|
||||
))
|
||||
}
|
||||
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
|
||||
const road = roadRoutes?.get(item.res.id)
|
||||
const lines = road && road.length >= 2 ? [road] : item.arcs
|
||||
return lines.map((seg, segIdx) => (
|
||||
return linesFor(item, roadRoutes).map((seg, segIdx) => (
|
||||
<Polyline
|
||||
key={`line-${item.res.id}-${segIdx}`}
|
||||
positions={seg}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Marker, Tooltip, useMap } 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`. */
|
||||
/** Below this the handles are not drawn. Shared with the GL renderer's own copy. */
|
||||
const VIA_MIN_ZOOM = 9
|
||||
|
||||
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
|
||||
/**
|
||||
* Zoomed out, the handles go away.
|
||||
*
|
||||
* A via is a handle for a few hundred metres of road, and at a continental zoom a whole
|
||||
* day's worth collapses into a cluster of dots over one town — not something anybody can
|
||||
* aim at, and a drag there moves the route by kilometres per pixel. Below this the drive
|
||||
* is read, not shaped.
|
||||
*/
|
||||
const map = useMap()
|
||||
const [zoomedIn, setZoomedIn] = useState(() => map.getZoom() >= VIA_MIN_ZOOM)
|
||||
useEffect(() => {
|
||||
const update = () => setZoomedIn(map.getZoom() >= VIA_MIN_ZOOM)
|
||||
update()
|
||||
map.on('zoomend', update)
|
||||
return () => { map.off('zoomend', update) }
|
||||
}, [map])
|
||||
|
||||
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 || !zoomedIn) 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,364 @@ 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)
|
||||
})
|
||||
|
||||
// The second engine, which is what made this whole branch produce anything on a
|
||||
// default install. Every case here overrides the 503 the shared handlers answer with,
|
||||
// so the OSRM path above stays the one the cases before this measure.
|
||||
const VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||
const valhallaAnswer = (summary: Record<string, unknown>) => ({
|
||||
trip: {
|
||||
// The detour twoRoutes describes, re-encoded as the polyline6 Valhalla sends.
|
||||
legs: [{ shape: '_szadB_gjaR~po]_yqwC~po]~tpzA' }],
|
||||
summary: { length: 130, time: 4500, has_toll: false, has_highway: false, has_ferry: false, ...summary },
|
||||
},
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-050: one answer from OSRM is offered a second way by Valhalla', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })),
|
||||
http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: false })))
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[1].avoids).toBe('motorway')
|
||||
expect(routes[1].distance).toBeCloseTo(130000, 0)
|
||||
expect(routes[1].duration).toBe(4500)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-051: a road that still has the class is not offered as avoiding it', async () => {
|
||||
// use_highways: 0 is a weighting, so a leg with no way round the motorway comes back
|
||||
// on the motorway and says so. Offering that as "No motorway" would put a lie on the
|
||||
// map, so it is dropped and the leg keeps the single way OSRM found.
|
||||
//
|
||||
// Asserted on the result rather than on which excludes OSRM was asked: by this point
|
||||
// `excludeUnsupported` has already recorded the public host from the case above, and
|
||||
// that Set is module state for the lifetime of the file.
|
||||
server.use(
|
||||
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]] })
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
}),
|
||||
http.post(VALHALLA, () => HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true, has_ferry: true })))
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52.5, lng: 11.5 })
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
expect(routes[0].avoids).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-052: a ferry-free way is offered, which OSRM alone never managed', async () => {
|
||||
server.use(
|
||||
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]] })
|
||||
return new HttpResponse(null, { status: 400 })
|
||||
}),
|
||||
http.post(VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { costing_options: { auto: Record<string, number> } }
|
||||
// Only the ferry question gets a usable answer, so the first two fall through.
|
||||
if (!('use_ferry' in body.costing_options.auto)) {
|
||||
return HttpResponse.json(valhallaAnswer({ has_highway: true, has_toll: true }))
|
||||
}
|
||||
return HttpResponse.json(valhallaAnswer({ has_ferry: false }))
|
||||
})
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 54, lng: 10 }, { lat: 55, lng: 12 })
|
||||
|
||||
expect(routes).toHaveLength(2)
|
||||
expect(routes[1].avoids).toBe('ferry')
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-054: the same road from the other engine is not offered as a second one', async () => {
|
||||
// The reason the geometry decides rather than the numbers. Valhalla prices roads
|
||||
// differently, so on a long leg the SAME road comes back kilometres apart in both
|
||||
// length and time — here 13 km and 5 minutes, which is the measured spread between
|
||||
// the two engines and comfortably past the 200 m the numbers used to allow. Judged
|
||||
// on those it reads as a discovery and puts a second blue line on top of the first.
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })),
|
||||
http.post(VALHALLA, () => HttpResponse.json({
|
||||
trip: {
|
||||
// twoRoutes.routes[0]'s own line, re-encoded: the identical road.
|
||||
legs: [{ shape: '_szadB_gjaR~po]_qo]~po]_qo]' }],
|
||||
summary: { length: 113, time: 3900, has_toll: false, has_highway: false, has_ferry: false },
|
||||
},
|
||||
}))
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 11 })
|
||||
|
||||
expect(routes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-053: an instance with its own OSRM is never sent to the public Valhalla', async () => {
|
||||
let valhallaCalls = 0
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: 'https://osrm.example.org' } }))
|
||||
server.use(
|
||||
http.get('https://osrm.example.org/route/v1/driving/:coords', ({ request }) => {
|
||||
const exclude = new URL(request.url).searchParams.get('exclude')
|
||||
if (!exclude) return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[0]] })
|
||||
return HttpResponse.json({ code: 'Ok', routes: [twoRoutes.routes[1]] })
|
||||
}),
|
||||
http.post(VALHALLA, () => { valhallaCalls++; return HttpResponse.json(valhallaAnswer({})) })
|
||||
)
|
||||
|
||||
const routes = await calculateAlternatives({ lat: 53, lng: 10 }, { lat: 52, lng: 12 })
|
||||
|
||||
expect(valhallaCalls).toBe(0)
|
||||
expect(routes[1].avoids).toBe('motorway')
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, routing_base_url: '' } }))
|
||||
})
|
||||
|
||||
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,9 +1,11 @@
|
||||
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'
|
||||
import { countRoute } from './routeUsageCounter'
|
||||
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, legAvoids, type AvoidClass } from './valhallaRoute'
|
||||
import type { RouteUsageSurface } from '@trek/shared'
|
||||
|
||||
// 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
|
||||
@@ -14,6 +16,112 @@ 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]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* `fetch`, with the request counted.
|
||||
*
|
||||
* Every route TREK draws goes out from here, and nowhere else, which makes this the one
|
||||
* place that can answer how much routing an instance really does — the number behind
|
||||
* "could we host an engine ourselves". Counted: how many requests, of what kind, how
|
||||
* many waypoints, roughly how far, and whether the host refused. Not counted, because it
|
||||
* is never sent: where any of it was.
|
||||
*
|
||||
* Distance is the straight line along the waypoint chain rather than the routed length,
|
||||
* which is only in the answer and differs per response shape. It is a floor on the real
|
||||
* figure, and a floor is enough to tell a 1500 km per-request limit from a 150 km one.
|
||||
*
|
||||
* An aborted request is not a failure: the map cancels constantly while someone drags.
|
||||
*/
|
||||
async function routedFetch(
|
||||
url: string,
|
||||
signal: AbortSignal | undefined,
|
||||
kind: RouteUsageSurface,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
waypoints: readonly Waypoint[],
|
||||
): Promise<Response> {
|
||||
const selfHosted = !!useSettingsStore.getState().settings.routing_base_url?.trim()
|
||||
let km = 0
|
||||
for (let i = 1; i < waypoints.length; i++) km += haversineKm(waypoints[i - 1], waypoints[i])
|
||||
const sample = { profile, surface: kind, selfHosted, waypoints: waypoints.length, km }
|
||||
try {
|
||||
const response = await fetch(url, { signal })
|
||||
countRoute({ ...sample, failed: !response.ok })
|
||||
return response
|
||||
} catch (err) {
|
||||
if (!(err instanceof DOMException && err.name === 'AbortError')) {
|
||||
countRoute({ ...sample, failed: true })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +153,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 })
|
||||
const response = await routedFetch(url, signal, 'route', profile, waypoints)
|
||||
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 +360,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')
|
||||
const response = await routedFetch(url, signal, 'segments', 'driving', waypoints)
|
||||
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')
|
||||
@@ -285,23 +393,55 @@ export async function calculateSegments(
|
||||
*/
|
||||
export async function calculateRouteWithLegs(
|
||||
waypoints: Waypoint[],
|
||||
{ signal, profile = 'driving', tripId, dayId }: { signal?: AbortSignal; profile?: RouteProfileKey; tripId?: number | string | null; dayId?: number | null } = {}
|
||||
{ signal, profile = 'driving', tripId, dayId, avoid = [] }: {
|
||||
signal?: AbortSignal
|
||||
profile?: RouteProfileKey
|
||||
tripId?: number | string | null
|
||||
dayId?: number | null
|
||||
/** Road classes to weight away. Only the driving profile has any, and only Valhalla can. */
|
||||
avoid?: readonly AvoidClass[]
|
||||
} = {}
|
||||
): Promise<RouteWithLegs> {
|
||||
if (!waypoints || waypoints.length < 2) {
|
||||
return { coordinates: [], distance: 0, duration: 0, legs: [] }
|
||||
}
|
||||
|
||||
const avoiding = profile === 'driving' && avoid.length > 0 && valhallaAvailable()
|
||||
? [...avoid].sort()
|
||||
: []
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
// The cached result carries formatted leg distances, so the active distance unit is
|
||||
// part of the key — otherwise switching km↔mi would return stale text (#1300).
|
||||
// A plugin route is trip-/day-specific (it may return different charging stops for
|
||||
// the same coordinates on a different day), so its key includes tripId/dayId;
|
||||
// the built-in OSRM profiles are context-free and leave those out.
|
||||
//
|
||||
// The avoidance belongs in the key for two reasons pointing opposite ways. Without it
|
||||
// turning the switch on returns the tolled route out of cache in under a millisecond,
|
||||
// so nothing on screen changes and it reads as a broken toggle. And the leak the other
|
||||
// way is worse: this Map is shared with the ordinary day planner, the studio and the
|
||||
// booking geometry, none of which ask for avoidance, so an avoid-routed answer stored
|
||||
// under the plain key would hand them a road nobody drives and a second engine's
|
||||
// times. `avoiding` is empty whenever the request goes to OSRM after all, so the key
|
||||
// also separates the two engines.
|
||||
const pluginScope = profile.startsWith('plugin:') ? `:${tripId ?? ''}:${dayId ?? ''}` : ''
|
||||
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}`
|
||||
const avoidScope = avoiding.length ? `:avoid=${avoiding.join(',')}` : ''
|
||||
const cacheKey = `${profile}:${getDistanceUnit()}:${coords}${pluginScope}${avoidScope}`
|
||||
const cached = routeCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
if (avoiding.length) {
|
||||
const routed = await routeAvoidingWithLegs(waypoints, avoiding, signal)
|
||||
// Null is a Valhalla that could not answer at all. Falling through to OSRM keeps a
|
||||
// day drawn rather than blank, and `avoidance` stays absent on that result so the
|
||||
// rail can say the avoidance did not happen instead of implying it did.
|
||||
if (routed) {
|
||||
cacheRoute(cacheKey, routed)
|
||||
return routed
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin profile (`plugin:<id>/<profile>`): the server invokes that routeProvider
|
||||
// and normalizes its answer; null means the provider failed or refused, and the
|
||||
// throw makes callers fall back to straight lines exactly like an OSRM outage.
|
||||
@@ -344,10 +484,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`
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new Error('Route could not be calculated')
|
||||
// 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 routedFetch(url, signal, 'legs', osrmProfile, waypoints)
|
||||
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,13 +517,106 @@ export async function calculateRouteWithLegs(
|
||||
}
|
||||
)
|
||||
|
||||
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs }
|
||||
routeCache.set(cacheKey, result)
|
||||
const snapped = readSnapped(data, waypoints)
|
||||
const result: RouteWithLegs = { coordinates, distance: route.distance, duration: route.duration, legs, ...(snapped ? { snapped } : {}) }
|
||||
cacheRoute(cacheKey, result)
|
||||
return result
|
||||
}
|
||||
|
||||
/** Store a route and drop the oldest once the cache is over its cap. */
|
||||
function cacheRoute(key: string, route: RouteWithLegs): void {
|
||||
routeCache.set(key, route)
|
||||
if (routeCache.size > ROUTE_CACHE_MAX) {
|
||||
const oldest = routeCache.keys().next().value
|
||||
if (oldest !== undefined) routeCache.delete(oldest)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole chain from the second engine, weighted away from some road classes.
|
||||
*
|
||||
* Shaped exactly like the OSRM answer above so the callers never branch on which engine
|
||||
* spoke — same leg list, same formatted text, same snap positions. What it adds is
|
||||
* `avoidance`: what was asked for against what the road actually turned out to be, which
|
||||
* is the only honest way to label a weighting that can fail.
|
||||
*
|
||||
* Null rather than a throw when it cannot answer, so the caller falls through to OSRM
|
||||
* and the day stays drawn.
|
||||
*/
|
||||
async function routeAvoidingWithLegs(
|
||||
waypoints: Waypoint[],
|
||||
avoid: AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteWithLegs | null> {
|
||||
const sample = {
|
||||
profile: 'driving' as const,
|
||||
surface: 'legs' as const,
|
||||
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
|
||||
waypoints: waypoints.length,
|
||||
km: waypoints.slice(1).reduce((sum, point, i) => sum + haversineKm(waypoints[i], point), 0),
|
||||
}
|
||||
const run = await valhallaRun(waypoints, 'driving', avoid, signal)
|
||||
// Counted by hand because the adapter speaks to a different engine with a different
|
||||
// verb, and the counter exists to answer "how much routing does this instance do" —
|
||||
// an engine it cannot see is exactly the traffic that would go missing from it.
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !run })
|
||||
if (!run) return null
|
||||
|
||||
const legs: RouteSegment[] = run.legs.map((leg, i): RouteSegment => {
|
||||
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||
return {
|
||||
mid: [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2],
|
||||
from,
|
||||
to,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
walkingText: formatDuration(leg.distance / (5000 / 3600)),
|
||||
drivingText: formatDuration(leg.duration),
|
||||
distanceText: formatRouteDistance(leg.distance),
|
||||
durationText: formatDuration(leg.duration),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
coordinates: run.total.coordinates,
|
||||
distance: run.total.distance,
|
||||
duration: run.total.duration,
|
||||
legs,
|
||||
snapped: run.snapped,
|
||||
avoidance: { asked: avoid, achieved: avoid.filter(cls => legAvoids(run.total, cls)) },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
@@ -403,3 +639,284 @@ 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?: AvoidClass
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Ferry is on the list for Valhalla's sake and skipped on the OSRM path below. OSRM
|
||||
* does carry a ferry class, but on a leg where a ferry is the only crossing it answers
|
||||
* NoRoute rather than the long way round, which is not an alternative anybody can use.
|
||||
* Valhalla weights it instead and drives around, which is the whole point.
|
||||
*/
|
||||
const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const
|
||||
|
||||
/**
|
||||
* How far two lines may stray from each other and still be the same road, in kilometres.
|
||||
*
|
||||
* Parallel carriageways, a different slip road onto the same motorway and the noise
|
||||
* between two engines' idea of where a road is all sit well under this. A way somebody
|
||||
* would call an alternative leaves it by kilometres: the ferry-free run from Hamburg to
|
||||
* Copenhagen, which is a genuinely different road, peaks 2.3 km off the default.
|
||||
*/
|
||||
const SAME_ROAD_KM = 0.5
|
||||
|
||||
/**
|
||||
* Two lines are the same road.
|
||||
*
|
||||
* Length and duration used to decide this, and stopped being enough when a second engine
|
||||
* started answering. Valhalla prices roads differently — measured over ten European legs
|
||||
* its distances ran 2.8 % above OSRM's and its times 5.4 % — so on a 470 km leg the same
|
||||
* road comes back thirteen kilometres apart and a 200 m tolerance calls it a discovery.
|
||||
* Two identical blue lines on the map is exactly what that looks like.
|
||||
*
|
||||
* The geometry does not care which engine drew it, so it decides whenever there is one.
|
||||
* The numbers stay as the fallback for a line with no shape to compare.
|
||||
*/
|
||||
function sameRoad(a: RouteAlternative, b: RouteAlternative): boolean {
|
||||
const stray = furthestFrom(b.coordinates, a.coordinates)
|
||||
if (stray) return stray.km < SAME_ROAD_KM
|
||||
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(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
// Valhalla first, because on a default install it is the only one of the two that
|
||||
// can answer at all. It weights the class away instead of banning it, so it also
|
||||
// answers on a leg where the class cannot be avoided — and says so, which is why the
|
||||
// result is thrown away below unless the road really came back without it.
|
||||
const weighted = await valhallaExcluding(from, to, profile, exclude, signal)
|
||||
if (weighted) return weighted
|
||||
|
||||
// OSRM has a ferry class too, but on a leg where the ferry IS the crossing it answers
|
||||
// NoRoute rather than driving round, so there is nothing to offer and no point paying
|
||||
// a request to be told so.
|
||||
if (exclude === 'ferry') return null
|
||||
return osrmExcluding(from, to, profile, exclude, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same leg from Valhalla, weighted away from one class, or null.
|
||||
*
|
||||
* The check that makes this honest is `legAvoids`: `use_tolls: 0` is a preference, so
|
||||
* between two points with no untolled connection the answer is still a tolled road.
|
||||
* Offering that as "No tolls" would put a lie on the map, so it is dropped and the
|
||||
* OSRM attempt gets its turn.
|
||||
*/
|
||||
async function valhallaExcluding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
if (!valhallaAvailable()) return null
|
||||
const sample = {
|
||||
profile,
|
||||
surface: 'alternatives' as const,
|
||||
selfHosted: !!useSettingsStore.getState().settings.routing_base_url?.trim(),
|
||||
waypoints: 2,
|
||||
km: haversineKm(from, to),
|
||||
}
|
||||
const leg = await valhallaRouteAvoiding(from, to, profile, exclude, signal)
|
||||
// Counted here rather than in the adapter, so the adapter stays a format translator
|
||||
// and every routing request an instance makes still lands in the one counter.
|
||||
if (!signal?.aborted) countRoute({ ...sample, failed: !leg })
|
||||
if (!leg || !legAvoids(leg, exclude)) return null
|
||||
return {
|
||||
coordinates: leg.coordinates,
|
||||
distance: leg.distance,
|
||||
duration: leg.duration,
|
||||
divergence: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** The same leg from OSRM with the class excluded outright, or null. */
|
||||
async function osrmExcluding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
exclude: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RouteAlternative | null> {
|
||||
const base = routeBaseFor(profile)
|
||||
if (excludeUnsupported.has(base)) return null
|
||||
try {
|
||||
const coords = `${from.lng},${from.lat};${to.lng},${to.lat}`
|
||||
const url = `${base}/${coords}?exclude=${exclude}&overview=full&geometries=geojson`
|
||||
const response = await routedFetch(url, signal, 'alternatives', profile, [from, to])
|
||||
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 routedFetch(url, signal, 'alternatives', profile, [from, to])
|
||||
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, then without the tolls, then without a ferry.
|
||||
// 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.
|
||||
//
|
||||
// This used to produce nothing at all on a default install: OSRM's `exclude` is HTTP
|
||||
// 400 on both public hosts, whose car profile is built without excludable classes. It
|
||||
// now asks Valhalla first, where avoidance is a runtime weighting, and only falls back
|
||||
// to OSRM's `exclude` for an instance running its own MLD-built router.
|
||||
if (routes.length < 2 && profile === 'driving') {
|
||||
for (const exclude of EXCLUDABLE_CLASSES) {
|
||||
if (signal?.aborted) break
|
||||
const detour = await routeExcluding(from, to, 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 {
|
||||
const furthest = furthestFrom(line, reference)
|
||||
return furthest ? { lat: furthest.lat, lng: furthest.lng } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The same point, plus how far off it actually is.
|
||||
*
|
||||
* The search itself stays in squared degrees with a cosine on the longitude: it only has
|
||||
* to ORDER the candidates, and a haversine per pair of a 200-by-200 comparison buys
|
||||
* nothing for it. The winner is converted once at the end, because `sameRoad` needs a
|
||||
* real distance rather than a rank, and half a degree is a different number of metres in
|
||||
* Andalusia than in Lapland.
|
||||
*/
|
||||
function furthestFrom(
|
||||
line: [number, number][],
|
||||
reference: [number, number][],
|
||||
): { lat: number; lng: number; km: 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 bestNeighbour: [number, 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
|
||||
let nearestPoint: [number, number] | null = null
|
||||
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; nearestPoint = reference[j] }
|
||||
}
|
||||
if (nearest > bestDist) { bestDist = nearest; best = { lat, lng }; bestNeighbour = nearestPoint }
|
||||
}
|
||||
if (!best) return null
|
||||
const km = bestNeighbour ? haversineKm(best, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
|
||||
return { ...best, km }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { TripRouteOverviewPill, TripRouteOverviewPanel } from './TripRouteOverview'
|
||||
import type { TripRouteOverview } from './useTripRouteOverview'
|
||||
|
||||
const overview = (over: Partial<TripRouteOverview> = {}): TripRouteOverview => ({
|
||||
days: [
|
||||
{ dayId: 1, dayNumber: 1, date: '2025-06-01', title: null, color: { line: '#0a84ff', casing: '#0a5cc2' }, lines: [], segments: [], distance: 82000, duration: 3600, modes: ['driving'] },
|
||||
{ dayId: 2, dayNumber: 2, date: '2025-06-02', title: 'Coast road', color: { line: '#ff9f0a', casing: '#c2740a' }, lines: [], segments: [], distance: 140500, duration: 7200, modes: ['driving', 'walking'] },
|
||||
],
|
||||
lines: [], lineColors: [], segments: [], focusPoints: [],
|
||||
totalDistance: 222500, totalDuration: 10800, loading: false,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('TripRouteOverviewPill', () => {
|
||||
it('FE-MAP-TROU-001: says which way it will take you, and reports its state', () => {
|
||||
const onToggle = vi.fn()
|
||||
const { rerender } = render(<TripRouteOverviewPill active={false} onToggle={onToggle} />)
|
||||
|
||||
const button = screen.getByTestId('trip-overview-pill')
|
||||
expect(button).toHaveAttribute('aria-pressed', 'false')
|
||||
expect(button).toHaveAttribute('aria-label', 'map.overview.show')
|
||||
|
||||
fireEvent.click(button)
|
||||
expect(onToggle).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<TripRouteOverviewPill active onToggle={onToggle} />)
|
||||
expect(screen.getByTestId('trip-overview-pill')).toHaveAttribute('aria-label', 'map.overview.hide')
|
||||
})
|
||||
})
|
||||
|
||||
describe('TripRouteOverviewPanel', () => {
|
||||
it('FE-MAP-TROU-002: leads with the trip total in the reader\'s own unit', () => {
|
||||
const { rerender } = render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
|
||||
expect(screen.getByText('222.5 km')).toBeInTheDocument()
|
||||
|
||||
rerender(<TripRouteOverviewPanel overview={overview()} unit="imperial" />)
|
||||
expect(screen.getByText('138.3 mi')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-003: names every day, its distance and how it is travelled', () => {
|
||||
render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
|
||||
|
||||
expect(screen.getByText('dayplan.dayN')).toBeInTheDocument()
|
||||
// A day with a title is named by it rather than by its number.
|
||||
expect(screen.getByText('Coast road')).toBeInTheDocument()
|
||||
expect(screen.getByText('82 km')).toBeInTheDocument()
|
||||
expect(screen.getByText('140.5 km')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-004: marks the total as partial while legs are still coming in', () => {
|
||||
const { container, rerender } = render(<TripRouteOverviewPanel overview={overview({ loading: true })} unit="metric" />)
|
||||
expect(container.textContent).toContain('…')
|
||||
|
||||
rerender(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
|
||||
expect(container.textContent).not.toContain('…')
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-005: a day row picks that day when the shell offers it', () => {
|
||||
const onSelectDay = vi.fn()
|
||||
render(<TripRouteOverviewPanel overview={overview()} unit="metric" selectedDayId={2} onSelectDay={onSelectDay} />)
|
||||
|
||||
fireEvent.click(screen.getByText('Coast road'))
|
||||
expect(onSelectDay).toHaveBeenCalledWith(2)
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-006: read-only shells get rows, not buttons', () => {
|
||||
render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-008: hugs its rows, and takes the cap the shell gives it', () => {
|
||||
const { container, rerender } = render(<TripRouteOverviewPanel overview={overview()} unit="metric" />)
|
||||
const card = () => screen.getByTestId('trip-overview-panel')
|
||||
|
||||
expect(card().style.width).toBe('fit-content')
|
||||
expect(card().style.maxWidth).toBe('320px')
|
||||
|
||||
// The phone shell asks for a narrower card so the map keeps the space.
|
||||
rerender(<TripRouteOverviewPanel overview={overview()} unit="metric" maxWidth={240} />)
|
||||
expect(card().style.maxWidth).toBe('240px')
|
||||
expect(container).not.toBeEmptyDOMElement()
|
||||
})
|
||||
|
||||
it('FE-MAP-TROU-007: draws nothing at all for a trip with no routed day', () => {
|
||||
const { container } = render(<TripRouteOverviewPanel overview={overview({ days: [], totalDistance: 0 })} unit="metric" />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Route } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { profileIcon } from '../Planner/DayPlanSidebarRouteConnector'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { MAP_CONTROL_SHADOW } from './mapControlShadow'
|
||||
import type { TripRouteOverview as Overview } from './useTripRouteOverview'
|
||||
import type { DistanceUnit } from '../../types'
|
||||
|
||||
/**
|
||||
* Toggle for the whole-trip route overview (#1736). Same frosted shell as the other
|
||||
* map controls, so it lines up with the compass and the layer switcher.
|
||||
*/
|
||||
export function TripRouteOverviewPill({ active, onToggle }: { active: boolean; onToggle: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const label = active ? t('map.overview.hide') : t('map.overview.show')
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: MAP_CONTROL_SHADOW,
|
||||
}}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
aria-pressed={active}
|
||||
data-testid="trip-overview-pill"
|
||||
className={active ? 'text-accent' : 'text-content-muted'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', padding: 0,
|
||||
transition: 'background 0.14s, color 0.14s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<Route size={17} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip's stages and its total distance, read off the overview the map is drawing.
|
||||
*
|
||||
* One component for both shells: the phone and the desktop place it differently but the
|
||||
* rows are the same rows, and a second copy of them is a second thing to keep in step.
|
||||
*/
|
||||
export function TripRouteOverviewPanel({ overview, unit, selectedDayId, onSelectDay, maxWidth = 320 }: {
|
||||
overview: Overview
|
||||
unit: DistanceUnit
|
||||
selectedDayId?: number | null
|
||||
onSelectDay?: (dayId: number) => void
|
||||
/** How wide the card may grow before day names start to ellipsize. */
|
||||
maxWidth?: number
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
if (!overview.days.length) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="trip-overview-panel"
|
||||
style={{
|
||||
pointerEvents: 'auto', borderRadius: 14, overflow: 'hidden',
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
|
||||
// Sized to its rows rather than to the space available: a trip of short day
|
||||
// names left a band of empty card over the map, which on a phone is most of
|
||||
// what there is to look at.
|
||||
width: 'fit-content', maxWidth,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, padding: '10px 14px 8px' }}>
|
||||
<span className="text-content-muted" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||
{t('map.overview.total')}
|
||||
</span>
|
||||
{/* Same tier as the label beside it: the two read as one line rather than as a
|
||||
heading with a number stuck under it. */}
|
||||
<span className="text-content" style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 600 }}>
|
||||
{formatDistance(overview.totalDistance / 1000, unit)}
|
||||
{/* Still routing: the number is a partial sum, and saying so beats a total
|
||||
that silently grows while you read it. */}
|
||||
{overview.loading && <span className="text-content-muted" style={{ fontWeight: 400 }}>{' '}…</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ maxHeight: 220, overflowY: 'auto', borderTop: '1px solid var(--border-primary)' }}>
|
||||
{overview.days.map(day => {
|
||||
const label = day.title || t('dayplan.dayN', { n: day.dayNumber })
|
||||
const row = (
|
||||
<>
|
||||
<span style={{ width: 8, height: 8, borderRadius: 999, background: day.color.line, flexShrink: 0 }} aria-hidden />
|
||||
<span className="text-content" style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0 }} className="text-content-muted">
|
||||
{day.modes.map(mode => {
|
||||
const Icon = profileIcon(mode)
|
||||
return <Icon key={mode} size={12} strokeWidth={2} aria-hidden />
|
||||
})}
|
||||
</span>
|
||||
<span className="text-content-muted" style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{formatDistance(day.distance / 1000, unit)}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
const style = {
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '7px 14px', textAlign: 'left' as const,
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
background: day.dayId === selectedDayId ? 'var(--bg-hover)' : 'transparent',
|
||||
border: 'none',
|
||||
}
|
||||
return onSelectDay ? (
|
||||
<button key={day.dayId} type="button" onClick={() => onSelectDay(day.dayId)} style={{ ...style, cursor: 'pointer' }}>
|
||||
{row}
|
||||
</button>
|
||||
) : (
|
||||
<div key={day.dayId} style={style}>{row}</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,8 +10,17 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, waitFor } from '@testing-library/react'
|
||||
import type * as L from 'leaflet'
|
||||
import { maplibreGL as bridge } from '@maplibre/maplibre-gl-leaflet'
|
||||
import { VectorBasemap, attachVectorBasemap, hideLabelLayers, type GlLeafletLayer } from './VectorBasemap'
|
||||
import { OFM_POSITRON, OFM_DARK, OFM_ATTRIBUTION } from '../../constants/mapDefaults'
|
||||
import { hasWebGL } from '../../utils/webgl'
|
||||
import {
|
||||
VectorBasemap,
|
||||
attachVectorBasemap,
|
||||
detachBasemapLayer,
|
||||
hideLabelLayers,
|
||||
restyleBasemap,
|
||||
type BasemapLayer,
|
||||
type GlLeafletLayer,
|
||||
} from './VectorBasemap'
|
||||
import { OFM_POSITRON, OFM_DARK, OFM_ATTRIBUTION, RASTER_FALLBACK_TILE_URL } from '../../constants/mapDefaults'
|
||||
|
||||
const addAttribution = vi.fn()
|
||||
/** Only the two things the component touches; casting keeps the mock honest about that. */
|
||||
@@ -20,7 +29,16 @@ const leafletMap = asMap({ attributionControl: { addAttribution } })
|
||||
/** A map built without an attribution control, like the atlas. */
|
||||
const bareMap = asMap({})
|
||||
|
||||
vi.mock('react-leaflet', () => ({ useMap: () => leafletMap }))
|
||||
// TileLayer as well as useMap: the component renders one when the browser has no
|
||||
// WebGL, and jsdom is exactly such a browser.
|
||||
vi.mock('react-leaflet', () => ({
|
||||
useMap: () => leafletMap,
|
||||
TileLayer: ({ url }: { url: string }) => <div data-testid="raster-basemap" data-url={url} />,
|
||||
}))
|
||||
|
||||
// jsdom has no WebGL, so without this every case below would take the raster
|
||||
// branch. The suite is about the GL wiring; the raster branch has its own cases.
|
||||
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() }))
|
||||
vi.mock('./engines/maplibre', () => ({ default: { __engine: 'maplibre' } }))
|
||||
vi.mock('@maplibre/maplibre-gl-leaflet', () => {
|
||||
const maplibreGL = vi.fn(() => {
|
||||
@@ -201,3 +219,91 @@ describe('hideLabelLayers', () => {
|
||||
expect(() => hideLabelLayers(layer)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A browser that will not give MapLibre a WebGL context (#2288).
|
||||
*
|
||||
* Hardware acceleration off, a blocklisted driver, a VM or a remote desktop all
|
||||
* land here. Before the probe, the throw came out of an async effect nobody could
|
||||
* catch, and the layer it left behind took the page down on the way out.
|
||||
*/
|
||||
describe('VectorBasemap without WebGL', () => {
|
||||
const noWebGL = vi.mocked(hasWebGL)
|
||||
|
||||
/** A map real Leaflet will accept, which the raster stand-in needs. */
|
||||
const rasterMap = () => asMap({ addLayer: vi.fn(), attributionControl: { addAttribution } })
|
||||
|
||||
beforeEach(() => {
|
||||
noWebGL.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-013: draws raster tiles and never asks for the maplibre chunk', async () => {
|
||||
// Not loading it is half the point: about a megabyte for a browser that could
|
||||
// never have drawn it.
|
||||
const { findByTestId } = render(<VectorBasemap style={OFM_POSITRON} />)
|
||||
|
||||
const tiles = await findByTestId('raster-basemap')
|
||||
expect(tiles.getAttribute('data-url')).toBe(RASTER_FALLBACK_TILE_URL)
|
||||
expect(maplibreGL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-014: unmounting is quiet, with nothing to take off the map', () => {
|
||||
const { unmount } = render(<VectorBasemap style={OFM_POSITRON} />)
|
||||
// The crash in #2288 came from teardown, not from the failed attach.
|
||||
expect(() => unmount()).not.toThrow()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-015: the imperative maps get the raster stand-in in their ref', async () => {
|
||||
const ref: { current: BasemapLayer | null } = { current: null }
|
||||
const map = rasterMap()
|
||||
|
||||
await attachVectorBasemap(map, OFM_POSITRON, ref, () => false)
|
||||
|
||||
expect(maplibreGL).not.toHaveBeenCalled()
|
||||
expect(ref.current).not.toBeNull()
|
||||
// OpenStreetMap rather than OpenFreeMap: different tiles, different credit.
|
||||
expect(addAttribution).toHaveBeenCalledWith(expect.stringContaining('openstreetmap.org/copyright'))
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-016: a map torn down mid-probe still gets nothing', async () => {
|
||||
const ref: { current: BasemapLayer | null } = { current: null }
|
||||
await attachVectorBasemap(rasterMap(), OFM_POSITRON, ref, () => true)
|
||||
expect(ref.current).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-017: the raster stand-in ignores a restyle and a label sweep', async () => {
|
||||
const ref: { current: BasemapLayer | null } = { current: null }
|
||||
await attachVectorBasemap(rasterMap(), OFM_POSITRON, ref, () => false)
|
||||
|
||||
// Both are GL-only operations. Called on raster tiles they must do nothing
|
||||
// rather than reach for a maplibre map that is not there.
|
||||
expect(() => restyleBasemap(ref.current, OFM_DARK)).not.toThrow()
|
||||
expect(() => hideLabelLayers(ref.current!)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('detachBasemapLayer', () => {
|
||||
it('FE-COMP-VECBM-018: a GL layer whose context never came off cleanly anyway', () => {
|
||||
// This is the crash from #2288 in one line: maplibre-gl-leaflet's onRemove
|
||||
// calls this._glMap.remove() without asking whether there is one, and after a
|
||||
// refused context there is not. It ran inside a React cleanup, so the throw
|
||||
// reached a boundary and replaced the page.
|
||||
const remove = vi.fn(function (this: { _glMap?: { remove: () => void } }) {
|
||||
this._glMap!.remove()
|
||||
})
|
||||
const halfBuilt = { getMaplibreMap: () => undefined, remove } as unknown as GlLeafletLayer
|
||||
|
||||
expect(() => detachBasemapLayer(halfBuilt)).not.toThrow()
|
||||
expect(remove).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-019: a layer that throws for any other reason is swallowed too', () => {
|
||||
// Unmounting a map is never allowed to fail because its basemap did.
|
||||
const layer = { remove: vi.fn(() => { throw new Error('boom') }) } as unknown as BasemapLayer
|
||||
expect(() => detachBasemapLayer(layer)).not.toThrow()
|
||||
})
|
||||
|
||||
it('FE-COMP-VECBM-020: nothing to detach is not an error', () => {
|
||||
expect(() => detachBasemapLayer(null)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useMap } from 'react-leaflet'
|
||||
import type * as L from 'leaflet'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { TileLayer, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import type { MaplibreGL } from '@maplibre/maplibre-gl-leaflet'
|
||||
import { attributionForTile } from '../../constants/mapDefaults'
|
||||
import {
|
||||
RASTER_FALLBACK_MAX_ZOOM,
|
||||
RASTER_FALLBACK_TILE_URL,
|
||||
attributionForTile,
|
||||
} from '../../constants/mapDefaults'
|
||||
import { hasWebGL } from '../../utils/webgl'
|
||||
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
|
||||
|
||||
/** The Leaflet layer maplibre-gl-leaflet hands back. */
|
||||
export type GlLeafletLayer = InstanceType<typeof MaplibreGL>
|
||||
|
||||
/**
|
||||
* What a map ended up drawing with. Every caller keeps one ref for both kinds, so
|
||||
* there is no code path that knows only about the GL layer and falls over the
|
||||
* raster stand-in the moment a browser has no WebGL (#2288).
|
||||
*/
|
||||
export type BasemapLayer = GlLeafletLayer | L.TileLayer
|
||||
|
||||
/** A GL layer rather than the raster stand-in. Prototype method, so `in` finds it. */
|
||||
function isGlLayer(layer: BasemapLayer): layer is GlLeafletLayer {
|
||||
return 'getMaplibreMap' in layer
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the layer factory.
|
||||
*
|
||||
@@ -28,11 +46,105 @@ async function loadLayerFactory() {
|
||||
return bridge.maplibreGL
|
||||
}
|
||||
|
||||
/** The factory, or null when its chunk never arrived. A dead chunk is not the basemap's problem. */
|
||||
async function loadLayerFactoryOrNull() {
|
||||
try {
|
||||
return await loadLayerFactory()
|
||||
} catch (err) {
|
||||
// A chunk gone after a deploy is the global handler's case and heals itself
|
||||
// with one reload; here it only means there is no GL layer to attach.
|
||||
if (isChunkLoadError(err)) reloadOnceForChunk()
|
||||
else console.warn('[basemap] the maplibre chunk did not load', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Credit the basemap on a map that has an attribution control; a no-op on one that does not. */
|
||||
function creditBasemap(map: L.Map, style: string): void {
|
||||
map.attributionControl?.addAttribution(attributionForTile(style))
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a half-built GL layer safe to take off a map.
|
||||
*
|
||||
* maplibre-gl-leaflet's onRemove calls `this._glMap.remove()` without asking
|
||||
* whether there is one, and after a refused WebGL context there is not. That
|
||||
* second throw is what turned a missing basemap into a crash: it lands in a React
|
||||
* cleanup, where a boundary picks it up and replaces the page (#2288). A no-op
|
||||
* stand-in lets the bridge run the rest of its own teardown, the pane child and
|
||||
* the zoom-animation listener, and skip only the line that cannot work. A healthy
|
||||
* layer already has a GL map and is left alone.
|
||||
*/
|
||||
function disarmGlLayer(layer: GlLeafletLayer): void {
|
||||
const internals = layer as unknown as { _glMap?: { remove: () => void } | null }
|
||||
if (!internals._glMap) internals._glMap = { remove: () => {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* Hang a GL layer into a Leaflet map and say whether it took.
|
||||
*
|
||||
* maplibre-gl builds its WebGL context inside the Map constructor, which Leaflet
|
||||
* reaches through onAdd, so `addTo` is where a browser without WebGL throws. The
|
||||
* probe catches nearly every case; what is left is a context budget that ran out
|
||||
* between probe and attach, which is real on a page holding several maps. Either
|
||||
* way the layer has to come back off: Leaflet registers it before onAdd runs, so
|
||||
* a broken one left behind is handed to the retile effect and to every later
|
||||
* map.remove().
|
||||
*/
|
||||
function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolean {
|
||||
try {
|
||||
layer.addTo(map)
|
||||
} catch (err) {
|
||||
console.warn('[basemap] no WebGL context for the vector basemap, drawing raster tiles', err)
|
||||
detachBasemapLayer(layer)
|
||||
return false
|
||||
}
|
||||
creditBasemap(map, style)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The raster stand-in, attached imperatively for the maps that build Leaflet
|
||||
* themselves. The react-leaflet callers get the same tiles through the <TileLayer>
|
||||
* below instead, the way they already draw a user's own raster template.
|
||||
*/
|
||||
function attachRasterFallback(map: L.Map): L.TileLayer {
|
||||
const layer = L.tileLayer(RASTER_FALLBACK_TILE_URL, {
|
||||
maxZoom: RASTER_FALLBACK_MAX_ZOOM,
|
||||
referrerPolicy: 'strict-origin-when-cross-origin',
|
||||
} as L.TileLayerOptions)
|
||||
layer.addTo(map)
|
||||
creditBasemap(map, RASTER_FALLBACK_TILE_URL)
|
||||
return layer
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a basemap off its map. Never throws: unmounting a map is not allowed to
|
||||
* fail because its basemap did (#2288).
|
||||
*/
|
||||
export function detachBasemapLayer(layer: BasemapLayer | null | undefined): void {
|
||||
if (!layer) return
|
||||
if (isGlLayer(layer)) disarmGlLayer(layer)
|
||||
try {
|
||||
layer.remove()
|
||||
} catch (err) {
|
||||
console.warn('[basemap] teardown failed, leaving the layer where it is', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retile in place. Swapping the layer would drop a WebGL context per theme
|
||||
* toggle, and browsers cap those in the low teens.
|
||||
*
|
||||
* The raster stand-in has nothing to swap: it draws one template, and there is no
|
||||
* keyless dark OSM raster to switch to, so a theme change leaves it alone rather
|
||||
* than refetching the same tiles.
|
||||
*/
|
||||
export function restyleBasemap(layer: BasemapLayer | null | undefined, style: string): void {
|
||||
if (!layer || !isGlLayer(layer)) return
|
||||
layer.getMaplibreMap()?.setStyle(style)
|
||||
}
|
||||
|
||||
/**
|
||||
* A MapLibre vector style as the basemap of a Leaflet map.
|
||||
*
|
||||
@@ -40,21 +152,37 @@ function creditBasemap(map: L.Map, style: string): void {
|
||||
* watermark unless the operator has requested a key by mail, so the basemap of
|
||||
* every Leaflet map here is a style document rather than a tile template.
|
||||
* maplibre-gl-leaflet hangs a GL canvas into Leaflet's own tile pane, which
|
||||
* leaves everything above it alone: markers, GeoJSON, clusters, plugin layers
|
||||
* and the panes they live in are untouched, and Leaflet's CSS puts
|
||||
* leaves everything above it alone: markers, GeoJSON, clusters, plugin layers and
|
||||
* the panes they live in are untouched, and Leaflet's CSS puts
|
||||
* `pointer-events: none` on the layer's canvas, so clicks fall through to them.
|
||||
*
|
||||
* WebGL is not a given, though. Hardware acceleration switched off, a blocklisted
|
||||
* driver, a VM or a remote desktop all end with maplibre-gl throwing out of its
|
||||
* constructor, so the browser is asked first and gets raster tiles when the answer
|
||||
* is no (#2288). Asking is also what keeps the maplibre chunk off the wire for a
|
||||
* browser that could never have used it.
|
||||
*/
|
||||
export function VectorBasemap({ style }: { style: string }): null {
|
||||
export function VectorBasemap({ style }: { style: string }) {
|
||||
const map = useMap()
|
||||
const layerRef = useRef<GlLeafletLayer | null>(null)
|
||||
const styleRef = useRef(style)
|
||||
styleRef.current = style
|
||||
// State rather than a plain constant: the probe can say yes and the attach can
|
||||
// still fail once the page has spent its context budget, and that has to reach
|
||||
// the render or the raster tiles never appear.
|
||||
const [glUsable, setGlUsable] = useState(hasWebGL)
|
||||
|
||||
useEffect(() => {
|
||||
if (!glUsable) return
|
||||
let cancelled = false
|
||||
|
||||
void (async () => {
|
||||
const maplibreGL = await loadLayerFactory()
|
||||
const maplibreGL = await loadLayerFactoryOrNull()
|
||||
if (cancelled) return
|
||||
if (!maplibreGL) {
|
||||
setGlUsable(false)
|
||||
return
|
||||
}
|
||||
|
||||
// The style is read through a ref, never from the dependency list: a style
|
||||
// change must retile in place rather than tear the layer down, the same
|
||||
@@ -64,68 +192,106 @@ export function VectorBasemap({ style }: { style: string }): null {
|
||||
interactive: false,
|
||||
attributionControl: false,
|
||||
})
|
||||
if (!attachGlLayer(map, layer, styleRef.current)) {
|
||||
setGlUsable(false)
|
||||
return
|
||||
}
|
||||
layerRef.current = layer
|
||||
layer.addTo(map)
|
||||
creditBasemap(map, styleRef.current)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
layerRef.current?.remove()
|
||||
detachBasemapLayer(layerRef.current)
|
||||
layerRef.current = null
|
||||
}
|
||||
}, [map])
|
||||
}, [map, glUsable])
|
||||
|
||||
// Restyle in place. Swapping the layer would drop a WebGL context per theme
|
||||
// toggle, and browsers cap those in the low teens.
|
||||
useEffect(() => {
|
||||
layerRef.current?.getMaplibreMap()?.setStyle(style)
|
||||
restyleBasemap(layerRef.current, style)
|
||||
}, [style])
|
||||
|
||||
// No WebGL, no vector tiles. Raster keeps the map readable instead of leaving
|
||||
// markers, routes and clusters floating over grey; see mapDefaults for why this
|
||||
// particular source. The satellite toggle in MapView was always raster and is
|
||||
// unaffected either way.
|
||||
if (!glUsable) {
|
||||
return (
|
||||
<TileLayer
|
||||
key="webgl-fallback"
|
||||
url={RASTER_FALLBACK_TILE_URL}
|
||||
attribution={attributionForTile(RASTER_FALLBACK_TILE_URL)}
|
||||
maxZoom={RASTER_FALLBACK_MAX_ZOOM}
|
||||
keepBuffer={8}
|
||||
updateWhenZooming={false}
|
||||
updateWhenIdle={true}
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export default VectorBasemap
|
||||
|
||||
/**
|
||||
* The same layer for the maps that build Leaflet imperatively rather than
|
||||
* The same basemap for the maps that build Leaflet imperatively rather than
|
||||
* through react-leaflet: the journey map and the atlas.
|
||||
*
|
||||
* Loading is async because maplibre-gl only ever arrives through a dynamic
|
||||
* import, so the caller passes a `cancelled` probe: a map torn down mid-load
|
||||
* must not get a layer attached to it afterwards.
|
||||
* Loading is async because maplibre-gl only ever arrives through a dynamic import,
|
||||
* so the caller passes a `cancelled` probe: a map torn down mid-load must not get
|
||||
* a layer attached to it afterwards. What lands in `ref` is either the GL layer or
|
||||
* the raster stand-in, which is why callers go through restyleBasemap() and
|
||||
* detachBasemapLayer() rather than reaching for getMaplibreMap() themselves.
|
||||
*/
|
||||
export async function attachVectorBasemap(
|
||||
map: L.Map,
|
||||
style: string,
|
||||
ref: { current: GlLeafletLayer | null },
|
||||
ref: { current: BasemapLayer | null },
|
||||
cancelled: () => boolean,
|
||||
opts: { hideLabels?: boolean } = {},
|
||||
): Promise<void> {
|
||||
const maplibreGL = await loadLayerFactory()
|
||||
// Same gate as the component: a browser without WebGL never pays for the
|
||||
// maplibre chunk and gets raster tiles rather than an empty map (#2288).
|
||||
if (!hasWebGL()) {
|
||||
if (!cancelled()) ref.current = attachRasterFallback(map)
|
||||
return
|
||||
}
|
||||
|
||||
const maplibreGL = await loadLayerFactoryOrNull()
|
||||
if (cancelled()) return
|
||||
if (!maplibreGL) {
|
||||
ref.current = attachRasterFallback(map)
|
||||
return
|
||||
}
|
||||
|
||||
const layer = maplibreGL({ style, interactive: false, attributionControl: false })
|
||||
ref.current = layer
|
||||
layer.addTo(map)
|
||||
// The raster layer this replaces carried the credit, and OpenFreeMap asks for
|
||||
// one of its own.
|
||||
creditBasemap(map, style)
|
||||
// one of its own; attachGlLayer adds it once the layer is actually on the map.
|
||||
if (!attachGlLayer(map, layer, style)) {
|
||||
ref.current = attachRasterFallback(map)
|
||||
return
|
||||
}
|
||||
ref.current = layer
|
||||
if (opts.hideLabels) hideLabelLayers(layer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every label from a vector style.
|
||||
*
|
||||
* The atlas draws country names into its own fills, so a basemap that repeats
|
||||
* them underneath is noise. OpenFreeMap has no label-free style, but its labels
|
||||
* are all `symbol` layers, while borders and coastlines are `line` and `fill`,
|
||||
* so hiding that one type leaves the geography intact.
|
||||
* The atlas draws country names into its own fills, so a basemap that repeats them
|
||||
* underneath is noise. OpenFreeMap has no label-free style, but its labels are all
|
||||
* `symbol` layers, while borders and coastlines are `line` and `fill`, so hiding
|
||||
* that one type leaves the geography intact.
|
||||
*
|
||||
* Bound to `style.load` rather than run once: the event fires again after every
|
||||
* setStyle, so a theme switch keeps the labels off without a second call site.
|
||||
*/
|
||||
export function hideLabelLayers(layer: GlLeafletLayer): void {
|
||||
export function hideLabelLayers(layer: BasemapLayer): void {
|
||||
// Nothing to hide on the raster stand-in: OSM bakes its labels into the tile.
|
||||
// A WebGL-less atlas therefore shows country names twice, which is cosmetic and
|
||||
// the price of having a basemap at all, because the only keyless alternative is
|
||||
// none.
|
||||
if (!isGlLayer(layer)) return
|
||||
const gl = layer.getMaplibreMap()
|
||||
if (!gl) return
|
||||
const apply = () => {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { buildDayRouteRuns } from './dayRoutePlan'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
|
||||
|
||||
const at = (lat: number, lng: number, order: number, extra: Record<string, unknown> = {}) =>
|
||||
buildAssignment({ day_id: 1, order_index: order, place: buildPlace({ lat, lng }), ...extra })
|
||||
|
||||
const inputs = (over: Partial<{
|
||||
days: Day[]; assignments: AssignmentsMap; reservations: Reservation[]
|
||||
accommodations: Accommodation[]; optimizeFromAccommodation: boolean | undefined
|
||||
}> = {}) => ({
|
||||
days: [buildDay({ id: 1, day_number: 1 })],
|
||||
assignments: {},
|
||||
reservations: [],
|
||||
accommodations: [],
|
||||
optimizeFromAccommodation: false,
|
||||
...over,
|
||||
})
|
||||
|
||||
describe('buildDayRouteRuns', () => {
|
||||
it('FE-MAP-DRP-001: orders a day\'s located stops into one run', () => {
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: { '1': [at(48.86, 2.35, 1), at(45.76, 4.83, 0)] },
|
||||
}))
|
||||
|
||||
expect(runs).toHaveLength(1)
|
||||
// order_index, not the order they happen to sit in the array.
|
||||
expect(runs[0].map(p => p.lat)).toEqual([45.76, 48.86])
|
||||
expect(runs[0].every(p => p.isPlace)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-002: drops a stop with no coordinates', () => {
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: {
|
||||
'1': [
|
||||
at(48.86, 2.35, 0),
|
||||
buildAssignment({ day_id: 1, order_index: 1, place: buildPlace({ lat: null, lng: null }) }),
|
||||
at(45.76, 4.83, 2),
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
expect(runs[0]).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-003: a lone stop is no drive at all', () => {
|
||||
expect(buildDayRouteRuns(1, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-004: carries the per-leg travel modes the router resolves against', () => {
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: {
|
||||
'1': [
|
||||
at(48.86, 2.35, 0, { leg_transport_mode: 'walking' }),
|
||||
at(48.88, 2.36, 1, { incoming_leg_transport_mode: 'cycling' }),
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
expect(runs[0][0].leg_transport_mode).toBe('walking')
|
||||
expect(runs[0][1].incoming_leg_transport_mode).toBe('cycling')
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-005: two real places stay one run however far apart they are', () => {
|
||||
// Paris → Tokyo is far past MAX_DRIVE_KM, but both are real places someone
|
||||
// planned, so the run stands: the reachability guard only ever splits a leg
|
||||
// that touches a booking endpoint (#2133).
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: { '1': [at(48.86, 2.35, 0), at(35.68, 139.69, 1)] },
|
||||
}))
|
||||
|
||||
expect(runs).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-006: bookends the day with its accommodation when the setting is on', () => {
|
||||
const accommodation = {
|
||||
id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30,
|
||||
start_day_id: 1, end_day_id: 1,
|
||||
} as unknown as Accommodation
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: { '1': [at(48.86, 2.35, 0), at(48.88, 2.36, 1)] },
|
||||
accommodations: [accommodation],
|
||||
optimizeFromAccommodation: true,
|
||||
}))
|
||||
|
||||
const flat = runs.flat()
|
||||
expect(flat.some(p => p.lat === 48.80 && !p.isPlace)).toBe(true)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-007: leaves the hotel out when the setting is off', () => {
|
||||
const accommodation = {
|
||||
id: 1, trip_id: 1, place_lat: 48.80, place_lng: 2.30,
|
||||
start_day_id: 1, end_day_id: 1,
|
||||
} as unknown as Accommodation
|
||||
const runs = buildDayRouteRuns(1, inputs({
|
||||
assignments: { '1': [at(48.86, 2.35, 0), at(48.88, 2.36, 1)] },
|
||||
accommodations: [accommodation],
|
||||
optimizeFromAccommodation: false,
|
||||
}))
|
||||
|
||||
expect(runs.flat().some(p => p.lat === 48.80)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-MAP-DRP-008: a day that is not in the trip has no route', () => {
|
||||
expect(buildDayRouteRuns(99, inputs({ assignments: { '1': [at(48.86, 2.35, 0)] } }))).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import { withHotelBookends } from './RouteCalculator'
|
||||
import { getTransportRouteEndpoints, getTransportForDay, getMergedItems, isCarrierTransport, hasCarrierEndpointOnDay } from '../../utils/dayMerge'
|
||||
import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg, type CarrierEdge } from '../../utils/dayOrder'
|
||||
import { withinDriveRange } from '../../utils/geo'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation } from '../../types'
|
||||
|
||||
export const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']
|
||||
|
||||
/** A waypoint on a day's drive, carrying the per-leg modes `resolveLegMode` reads off it. */
|
||||
export interface DayRoutePoint {
|
||||
lat: number
|
||||
lng: number
|
||||
isPlace: boolean
|
||||
leg_transport_mode?: string | null
|
||||
incoming_leg_transport_mode?: string | null
|
||||
}
|
||||
|
||||
/** Everything the plan depends on, passed in rather than read from the store, so the
|
||||
* builder stays pure and one caller can run it over every day of a trip at once. */
|
||||
export interface DayRouteInputs {
|
||||
days: Day[]
|
||||
assignments: AssignmentsMap
|
||||
reservations: Reservation[]
|
||||
accommodations: Accommodation[]
|
||||
optimizeFromAccommodation: boolean | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The runs of waypoints a day's route is drawn from, hotel bookends folded in.
|
||||
*
|
||||
* Lifted out of `useRouteCalculation` unchanged so the trip-wide overview draws the
|
||||
* same line the day view does. Every rule below is load-bearing and issue-numbered —
|
||||
* a second copy of it would drift the moment either surface got the next fix.
|
||||
*/
|
||||
export function buildDayRouteRuns(dayId: number, input: DayRouteInputs): DayRoutePoint[][] {
|
||||
const { days: allDays, assignments, reservations: allReservations, accommodations, optimizeFromAccommodation } = input
|
||||
const da = (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const dayOrder = (id: number | null | undefined): number | null => {
|
||||
if (id == null) return null
|
||||
const d = allDays.find(x => x.id === id)
|
||||
return d ? (d.day_number ?? allDays.indexOf(d)) : null
|
||||
}
|
||||
const thisOrder = dayOrder(dayId)
|
||||
|
||||
// The order the day plan shows is the order the map has to draw, so take it from
|
||||
// the same place the plan does rather than rebuilding it here. getTransportForDay
|
||||
// brings the span filter, the hotel/assignment exclusions and the leg expansion with
|
||||
// it; getMergedItems places each leg by its own saved position, falling back to its
|
||||
// time. Notes carry no coordinates, so none are passed.
|
||||
const dayTransports = thisOrder == null ? [] : getTransportForDay({
|
||||
reservations: allReservations.filter(r => TRANSPORT_TYPES.includes(r.type)),
|
||||
dayId,
|
||||
dayAssignmentIds: da.map(a => a.id),
|
||||
days: allDays,
|
||||
})
|
||||
const merged = getMergedItems({ dayAssignments: da, dayNotes: [], dayTransports, dayId })
|
||||
|
||||
type Entry =
|
||||
| { kind: 'place'; lat: number; lng: number; pos: number; time: string | null; mode: string | null; incoming: string | null }
|
||||
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number; carrier: boolean }
|
||||
const entries: Entry[] = merged.flatMap((item): Entry[] => {
|
||||
if (item.type === 'place') {
|
||||
const a = item.data
|
||||
if (!a.place?.lat || !a.place?.lng) return []
|
||||
return [{
|
||||
kind: 'place', lat: a.place.lat, lng: a.place.lng, pos: item.sortKey, time: a.place?.place_time ?? null,
|
||||
// Per-segment travel mode (#1281): mode of the leg leaving this place.
|
||||
mode: (a as { leg_transport_mode?: string | null }).leg_transport_mode ?? null,
|
||||
// Boundary-leg mode (#1281 follow-up): mode of the leg arriving at this place.
|
||||
incoming: (a as { incoming_leg_transport_mode?: string | null }).incoming_leg_transport_mode ?? null,
|
||||
}]
|
||||
}
|
||||
if (item.type !== 'transport') return []
|
||||
const { from, to } = getTransportRouteEndpoints(item.data, dayId)
|
||||
return [{ kind: 'transport', from, to, pos: item.sortKey, carrier: isCarrierTransport(item.data) }]
|
||||
})
|
||||
|
||||
// Group located places into driving runs. A transport WITH a location anchors the
|
||||
// route to its departure point then breaks the run (you don't drive the flight); its
|
||||
// arrival starts the next. A transport WITHOUT a location is ignored entirely. A run
|
||||
// is only a real drive when it contains at least one actual place, or two back-to-back
|
||||
// transports pair into a phantom [airport → airport] road route (#1394). A booking
|
||||
// endpoint also has to be REACHABLE from the stop before it, or a long-haul arrival
|
||||
// sorted in among the day's local stops gets stapled to one of them (#2133).
|
||||
const runs: DayRoutePoint[][] = []
|
||||
let currentRun: DayRoutePoint[] = []
|
||||
let runHasPlace = false
|
||||
const closeRun = () => {
|
||||
if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun)
|
||||
currentRun = []
|
||||
runHasPlace = false
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'place') {
|
||||
const prev = currentRun[currentRun.length - 1]
|
||||
// The open run may be nothing but a far-away arrival endpoint — break rather
|
||||
// than draw the ocean.
|
||||
if (prev && !prev.isPlace && !withinDriveRange(prev, entry)) closeRun()
|
||||
currentRun.push({ lat: entry.lat, lng: entry.lng, isPlace: true, leg_transport_mode: entry.mode, incoming_leg_transport_mode: entry.incoming })
|
||||
runHasPlace = true
|
||||
} else if (entry.from || entry.to) {
|
||||
const prev = currentRun[currentRun.length - 1]
|
||||
if (entry.from && (!prev || withinDriveRange(prev, entry.from))) currentRun.push({ ...entry.from, isPlace: false })
|
||||
closeRun()
|
||||
if (entry.to) currentRun.push({ ...entry.to, isPlace: false })
|
||||
}
|
||||
}
|
||||
closeRun()
|
||||
|
||||
// Bookend the route with the day's accommodation, so the drawn line matches the
|
||||
// sidebar's hotel legs.
|
||||
const day = allDays.find(d => d.id === dayId)
|
||||
const bookends = day && optimizeFromAccommodation !== false
|
||||
? getDayBookendHotels(day, allDays, accommodations)
|
||||
: null
|
||||
const flatPts: DayRoutePoint[] = []
|
||||
for (const e of entries) {
|
||||
if (e.kind === 'place') flatPts.push({ lat: e.lat, lng: e.lng, isPlace: true, leg_transport_mode: e.mode, incoming_leg_transport_mode: e.incoming })
|
||||
else { if (e.from) flatPts.push({ ...e.from, isPlace: false }); if (e.to) flatPts.push({ ...e.to, isPlace: false }) }
|
||||
}
|
||||
// A hotel bookend point is not a place-assignment, so isPlace: false — resolveLegMode
|
||||
// falls through to the day default for hotel-adjacent legs unless the place endpoint
|
||||
// carries its own override.
|
||||
const hotelPt = (a?: Accommodation): DayRoutePoint | null =>
|
||||
a && a.place_lat != null && a.place_lng != null ? { lat: a.place_lat, lng: a.place_lng, isPlace: false } : null
|
||||
// Only draw a hotel bookend when the leg is a real drive: a place before check-in
|
||||
// (#1465), a later "home" stop on the checkout day (#1465), or a transport endpoint on
|
||||
// an arrival/departure day (#1321, #2133) all draw no bookend.
|
||||
const contributes = (e: Entry) => e.kind === 'place' || !!e.from || !!e.to
|
||||
const firstStop = entries.find(contributes)
|
||||
const lastStop = [...entries].reverse().find(contributes)
|
||||
const edgeInfo = (e: Entry | undefined, side: 'first' | 'last') => {
|
||||
if (!e) return undefined
|
||||
if (e.kind === 'place') return { isPlace: true, time: e.time, lat: e.lat, lng: e.lng }
|
||||
const role: CarrierEdge = side === 'first'
|
||||
? (e.from ? 'departure' : 'arrival')
|
||||
: (e.to ? 'arrival' : 'departure')
|
||||
return { isPlace: false, time: null, carrierEdge: e.carrier ? role : null }
|
||||
}
|
||||
// A carrier with a located endpoint today means the day changes geography by
|
||||
// air/rail/sea — the check-in hotel is then the day's destination and the
|
||||
// check-out hotel its origin, which the no-time bookend default respects (#2157).
|
||||
const dayHasCarrier = dayTransports.some(r => hasCarrierEndpointOnDay(r, dayId))
|
||||
const firstWay = flatPts[0]
|
||||
const lastWay = flatPts[flatPts.length - 1]
|
||||
const morningHotel = hotelPt(bookends?.morning)
|
||||
const eveningHotel = hotelPt(bookends?.evening)
|
||||
const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop, 'first'), dayHasCarrier)
|
||||
&& (!morningHotel || !firstWay || firstWay.isPlace || withinDriveRange(morningHotel, firstWay))
|
||||
const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop, 'last'), dayHasCarrier)
|
||||
&& (!eveningHotel || !lastWay || lastWay.isPlace || withinDriveRange(eveningHotel, lastWay))
|
||||
const runsWithHotel: DayRoutePoint[][] = withHotelBookends(
|
||||
runs,
|
||||
firstWay,
|
||||
lastWay,
|
||||
drawMorning ? morningHotel : null,
|
||||
drawEvening ? eveningHotel : null,
|
||||
)
|
||||
|
||||
// Transfer day with no activities: you check out of one accommodation and into
|
||||
// another, so there are no waypoints for withHotelBookends to attach a leg to.
|
||||
if (runsWithHotel.length === 0 && drawMorning && drawEvening) {
|
||||
const m = hotelPt(bookends?.morning)
|
||||
const e = hotelPt(bookends?.evening)
|
||||
if (m && e && (m.lat !== e.lat || m.lng !== e.lng)) runsWithHotel.push([m, e])
|
||||
}
|
||||
|
||||
return runsWithHotel
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* The drop shadow every floating map control carries.
|
||||
*
|
||||
* Tighter and darker than `--sidebar-shadow`, which is built for a panel resting on
|
||||
* the app background: over map tiles its 0.10 alpha spread over 32px all but
|
||||
* disappears, so a round control read as painted onto the map rather than floating
|
||||
* above it. This is the locate button's shadow, which was always the odd one out for
|
||||
* exactly that reason — the others were the ones that needed to match it.
|
||||
*/
|
||||
export const MAP_CONTROL_SHADOW = '0 2px 10px rgba(0,0,0,0.25)'
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -613,6 +613,26 @@ describe('ReservationMapboxOverlay declutter floors', () => {
|
||||
attach(wide, labelled).update([hop(2, 'cruise', 0.4)], labelled)
|
||||
expect(glMarkers.map(m => m.el.textContent)).toEqual(['GOA', 'PMI'])
|
||||
})
|
||||
|
||||
it('FE-COMP-RESGL-043: a routed drive whose ends project close together still draws (#2275)', () => {
|
||||
// 50 px between the ends, under the 80 px car floor; the road loops out 300 px.
|
||||
const road: [number, number][] = [[48, 16], [48.3, 16.1], [48.3, 16.3], [48.05, 16]]
|
||||
const bare = freshMap()
|
||||
attach(bare).update([hop(7, 'car', 0.05)], opts)
|
||||
expect(features(bare)).toHaveLength(0)
|
||||
|
||||
const routed = freshMap()
|
||||
attach(routed).update([hop(7, 'car', 0.05)], opts, new Map([[7, road]]))
|
||||
expect(features(routed)).toHaveLength(1)
|
||||
expect(features(routed)[0].geometry.coordinates).toEqual(road.map(([lat, lng]) => [lng, lat]))
|
||||
})
|
||||
|
||||
it('FE-COMP-RESGL-044: a routed drive that really is tiny on screen stays decluttered', () => {
|
||||
const road: [number, number][] = [[48, 16], [48.01, 16.01], [48.02, 16.02], [48.03, 16]]
|
||||
const map = freshMap()
|
||||
attach(map).update([hop(7, 'car', 0.03)], opts, new Map([[7, road]]))
|
||||
expect(features(map)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReservationMapboxOverlay waypoint edge cases', () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route, Tram
|
||||
import { getTransitMapSegments } from './transitGeometry'
|
||||
import { geodesicArcs } from './flightGeodesy'
|
||||
import { cleanEndpointName } from './reservationName'
|
||||
import { hopIsVisible, labelFloorPx } from '../../utils/reservationRoutes'
|
||||
import { escapeHtml } from '@trek/shared'
|
||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
@@ -259,27 +260,31 @@ export class ReservationMapboxOverlay {
|
||||
})
|
||||
}
|
||||
|
||||
/** What a hop will draw: the road it was routed along when there is one, else its arcs. */
|
||||
private linesFor(item: TransportItem): [number, number][][] {
|
||||
const road = this.roadRoutes.get(item.res.id)
|
||||
return road && road.length >= 2 ? [road] : item.arcs
|
||||
}
|
||||
|
||||
private render() {
|
||||
const map = this.map
|
||||
if (!this.map.getSource(RESERVATION_SOURCE_ID)) return
|
||||
|
||||
const show = this.opts.showConnections
|
||||
|
||||
// Visible filter: require the on-screen pixel distance between
|
||||
// endpoints to exceed a type-specific minimum, same as the Leaflet
|
||||
// overlay, so tiny no-op transport lines don't clutter the map.
|
||||
// Visible filter: a hop draws once what it will draw is long enough on
|
||||
// screen to be worth it, same as the Leaflet overlay, so tiny no-op
|
||||
// transport lines don't clutter the map. Measured along the drawn line,
|
||||
// not between the ends: a routed drive can cover half the screen while
|
||||
// its endpoints sit close together (#2275).
|
||||
const project = (p: readonly [number, number]) => map.project([p[1], p[0]])
|
||||
const visibleItems = show ? this.items.filter(item => {
|
||||
try {
|
||||
// A transit journey draws its real alignment, not a straight from->to line, so
|
||||
// don't let the endpoint-proximity declutter hide it when the map is zoomed out
|
||||
// (#1570). Mirrors the Leaflet overlay.
|
||||
if (item.type === 'transit' && getTransitMapSegments(item.res).length > 0) return true
|
||||
const fromPx = map.project([item.from.lng, item.from.lat])
|
||||
const toPx = map.project([item.to.lng, item.to.lat])
|
||||
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 150 : item.type === 'car' ? 80 : 200
|
||||
return dist >= minPx
|
||||
return hopIsVisible(item.type, this.linesFor(item), project)
|
||||
} catch { return true }
|
||||
}) : []
|
||||
|
||||
@@ -293,8 +298,7 @@ export class ReservationMapboxOverlay {
|
||||
const toPx = map.project([item.to.lng, item.to.lat])
|
||||
const dx = fromPx.x - toPx.x, dy = fromPx.y - toPx.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
const minPx = item.type === 'flight' ? 50 : item.type === 'cruise' ? 300 : item.type === 'car' ? 150 : item.type === 'transit' ? 900 : 400
|
||||
if (dist >= minPx) labelVisibleIds.add(item.res.id)
|
||||
if (dist >= labelFloorPx(item.type)) labelVisibleIds.add(item.res.id)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
@@ -320,9 +324,7 @@ export class ReservationMapboxOverlay {
|
||||
}))
|
||||
}
|
||||
// Prefer the real road route (car/bus/taxi/bicycle) over the straight arc.
|
||||
const road = this.roadRoutes.get(item.res.id)
|
||||
const lines = road && road.length >= 2 ? [road] : item.arcs
|
||||
return lines.map(seg => ({
|
||||
return this.linesFor(item).map(seg => ({
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
resId: item.res.id,
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { countRoute, flushRouteUsage } from './routeUsageCounter'
|
||||
import { routeUsageApi } from '../../api/routeUsage'
|
||||
|
||||
/**
|
||||
* FE-MAP-ROUTECOUNT-001..006 — the tally behind "how much routing does an instance do".
|
||||
*
|
||||
* Two things are worth pinning: that it batches rather than posting per route, because a
|
||||
* counter that costs a request of its own measures the load by adding to it, and that it
|
||||
* never throws — it sits on the hot path of every route the map draws.
|
||||
*/
|
||||
|
||||
const sample = {
|
||||
profile: 'driving' as const,
|
||||
surface: 'legs' as const,
|
||||
selfHosted: false,
|
||||
waypoints: 3,
|
||||
km: 100,
|
||||
failed: false,
|
||||
}
|
||||
|
||||
describe('routeUsageCounter', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(routeUsageApi, 'report').mockResolvedValue(undefined)
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
flushRouteUsage()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-001: a single route posts nothing on its own', () => {
|
||||
countRoute(sample)
|
||||
expect(routeUsageApi.report).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-002: the tally goes out once the timer runs out', () => {
|
||||
countRoute(sample)
|
||||
vi.advanceTimersByTime(60_000)
|
||||
|
||||
expect(routeUsageApi.report).toHaveBeenCalledTimes(1)
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries).toEqual([{ ...sample, requests: 1, failed: 0 }])
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-003: routes of one kind add onto one entry', () => {
|
||||
countRoute(sample)
|
||||
countRoute({ ...sample, waypoints: 5, km: 200 })
|
||||
countRoute({ ...sample, failed: true })
|
||||
flushRouteUsage()
|
||||
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries).toHaveLength(1)
|
||||
expect(entries[0]).toMatchObject({ requests: 3, waypoints: 11, km: 400, failed: 1 })
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-004: a different kind or engine is its own entry', () => {
|
||||
countRoute(sample)
|
||||
countRoute({ ...sample, surface: 'alternatives' })
|
||||
countRoute({ ...sample, selfHosted: true })
|
||||
countRoute({ ...sample, profile: 'walking' })
|
||||
flushRouteUsage()
|
||||
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-005: a busy stretch flushes on its own, without waiting', () => {
|
||||
for (let i = 0; i < 25; i++) countRoute(sample)
|
||||
expect(routeUsageApi.report).toHaveBeenCalledTimes(1)
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[0]
|
||||
expect(entries[0].requests).toBe(25)
|
||||
})
|
||||
|
||||
it('FE-MAP-ROUTECOUNT-006: a refused report is swallowed, and the next tally starts clean', async () => {
|
||||
vi.mocked(routeUsageApi.report).mockRejectedValueOnce(new Error('offline'))
|
||||
countRoute(sample)
|
||||
expect(() => flushRouteUsage()).not.toThrow()
|
||||
await Promise.resolve()
|
||||
|
||||
countRoute(sample)
|
||||
flushRouteUsage()
|
||||
const [entries] = vi.mocked(routeUsageApi.report).mock.calls[1]
|
||||
expect(entries[0].requests).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { RouteUsageEntry, RouteUsageProfile, RouteUsageSurface } from '@trek/shared'
|
||||
import { routeUsageApi } from '../../api/routeUsage'
|
||||
|
||||
/**
|
||||
* Counts the routing this browser asks for, and posts the totals in batches.
|
||||
*
|
||||
* All routing happens client-side against an external engine, so the server never
|
||||
* sees a route request and cannot count one. Without a count nobody knows what a
|
||||
* self-hosted router would have to carry — how many requests a real trip costs, how
|
||||
* big they are, how much already goes to an operator's own engine.
|
||||
*
|
||||
* Counters only. What goes over the wire is four integers per bucket: how many
|
||||
* requests, how many waypoints, how many kilometres, how many failed. No coordinate,
|
||||
* no route, no trip, nothing that says where anybody went.
|
||||
*
|
||||
* Batched on purpose. A counter that cost a request of its own would measure the load
|
||||
* by adding to it, so the tally is held in memory and flushed on a timer, when it
|
||||
* grows past a threshold, and when the page goes away.
|
||||
*/
|
||||
|
||||
/** Flush when the tally has this many requests in it, whichever comes first. */
|
||||
const FLUSH_AFTER_REQUESTS = 25
|
||||
/** …or after this long, so a quiet session still reports what it did. */
|
||||
const FLUSH_AFTER_MS = 60_000
|
||||
/** Guard against a pathological session: drop the tally rather than grow forever. */
|
||||
const MAX_BUCKETS = 40
|
||||
|
||||
type Bucket = Omit<RouteUsageEntry, 'profile' | 'surface' | 'selfHosted'> & {
|
||||
profile: RouteUsageProfile
|
||||
surface: RouteUsageSurface
|
||||
selfHosted: boolean
|
||||
}
|
||||
|
||||
const buckets = new Map<string, Bucket>()
|
||||
let pending = 0
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
let unloadWired = false
|
||||
|
||||
function flush(): void {
|
||||
if (timer !== null) { clearTimeout(timer); timer = null }
|
||||
if (!buckets.size) return
|
||||
const entries = [...buckets.values()]
|
||||
buckets.clear()
|
||||
pending = 0
|
||||
// Fire and forget: a failed report is a lost count, not a problem worth telling
|
||||
// anybody about. The next flush carries on from an empty tally either way.
|
||||
void routeUsageApi.report(entries).catch(() => {})
|
||||
}
|
||||
|
||||
function wireUnload(): void {
|
||||
if (unloadWired || typeof document === 'undefined') return
|
||||
unloadWired = true
|
||||
// `visibilitychange`, not `unload`: the latter no longer fires reliably on mobile,
|
||||
// where a session ends by switching apps rather than by closing a tab.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') flush()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records one routing request. Never throws and never awaits: it sits on the hot path
|
||||
* of every route the map draws.
|
||||
*/
|
||||
export function countRoute(sample: {
|
||||
profile: RouteUsageProfile
|
||||
surface: RouteUsageSurface
|
||||
selfHosted: boolean
|
||||
waypoints: number
|
||||
km: number
|
||||
failed: boolean
|
||||
}): void {
|
||||
try {
|
||||
wireUnload()
|
||||
const key = `${sample.profile}|${sample.surface}|${sample.selfHosted}`
|
||||
const bucket = buckets.get(key)
|
||||
if (bucket) {
|
||||
bucket.requests += 1
|
||||
bucket.waypoints += sample.waypoints
|
||||
bucket.km += sample.km
|
||||
if (sample.failed) bucket.failed += 1
|
||||
} else {
|
||||
if (buckets.size >= MAX_BUCKETS) return
|
||||
buckets.set(key, {
|
||||
profile: sample.profile,
|
||||
surface: sample.surface,
|
||||
selfHosted: sample.selfHosted,
|
||||
requests: 1,
|
||||
waypoints: sample.waypoints,
|
||||
km: sample.km,
|
||||
failed: sample.failed ? 1 : 0,
|
||||
})
|
||||
}
|
||||
pending += 1
|
||||
if (pending >= FLUSH_AFTER_REQUESTS) { flush(); return }
|
||||
if (timer === null) timer = setTimeout(flush, FLUSH_AFTER_MS)
|
||||
} catch { /* counting must never break routing */ }
|
||||
}
|
||||
|
||||
/** Flushes now. Exported for tests and for a deliberate "report before you go". */
|
||||
export function flushRouteUsage(): void {
|
||||
flush()
|
||||
}
|
||||
@@ -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,86 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useMergedMapPois } from './useMergedMapPois'
|
||||
import type { Poi } from './poiCategories'
|
||||
|
||||
/**
|
||||
* FE-MAP-MERGEPOI-001..006 — the two searches that can run at once in road trip mode.
|
||||
*
|
||||
* The corridor answers "what is along the drive", the category pill answers "what is in
|
||||
* view". Both are drawn, the corridor's copy of a shared hit wins, and the reference is
|
||||
* only new when the content really is — the map rebuilds every pin off it.
|
||||
*/
|
||||
|
||||
const poi = (osm_id: string, name = osm_id): Poi => ({
|
||||
osm_id, name, lat: 48, lng: 2, category: 'hotel', source: 'openstreetmap',
|
||||
} as Poi)
|
||||
|
||||
describe('useMergedMapPois', () => {
|
||||
it('FE-MAP-MERGEPOI-001: outside road trip mode only the pill hits are drawn', () => {
|
||||
const explore = [poi('n1')]
|
||||
const { result } = renderHook(() => useMergedMapPois(null, explore))
|
||||
expect(result.current).toBe(explore)
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-002: with nothing found in view the corridor list passes through as-is', () => {
|
||||
const corridor = [poi('n1')]
|
||||
const { result } = renderHook(() => useMergedMapPois(corridor, []))
|
||||
expect(result.current).toBe(corridor)
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-003: before a corridor search the pill hits pass through as-is', () => {
|
||||
const explore = [poi('n2')]
|
||||
const { result } = renderHook(() => useMergedMapPois([], explore))
|
||||
expect(result.current).toBe(explore)
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-004: both searches draw together, corridor first', () => {
|
||||
const { result } = renderHook(() => useMergedMapPois([poi('n1')], [poi('n2')]))
|
||||
expect(result.current.map(p => p.osm_id)).toEqual(['n1', 'n2'])
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-005: a place both searches found is drawn once, as the corridor knows it', () => {
|
||||
// The corridor copy carries how far along the drive it sits, which the pill's does not.
|
||||
const corridor = [poi('n1', 'Hotel am Weg')]
|
||||
const { result } = renderHook(() => useMergedMapPois(corridor, [poi('n1', 'Hotel am Weg (Sicht)')]))
|
||||
expect(result.current).toBe(corridor)
|
||||
expect(result.current).toHaveLength(1)
|
||||
expect(result.current[0].name).toBe('Hotel am Weg')
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-006: an unchanged pair keeps its reference, so no pin is rebuilt', () => {
|
||||
const corridor = [poi('n1')]
|
||||
const explore = [poi('n2')]
|
||||
const { result, rerender } = renderHook(() => useMergedMapPois(corridor, explore))
|
||||
const first = result.current
|
||||
rerender()
|
||||
expect(result.current).toBe(first)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMergedMapPois — the refuel offers', () => {
|
||||
const poi = (id: string): Poi => ({ osm_id: id, name: id, lat: 0, lng: 0 } as Poi)
|
||||
|
||||
it('FE-MAP-MERGEPOI-007: an offered station is drawn even with no search running', () => {
|
||||
// In road trip mode this is the only channel a POI reaches the map through, so
|
||||
// without it somebody is asked to accept a stop they cannot see.
|
||||
const { result } = renderHook(() => useMergedMapPois([], [], [poi('offer')]))
|
||||
expect(result.current.map(p => p.osm_id)).toEqual(['offer'])
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-008: an offer already on the route is one pin, not two', () => {
|
||||
const { result } = renderHook(() => useMergedMapPois([poi('a')], [], [poi('a'), poi('b')]))
|
||||
expect(result.current.map(p => p.osm_id)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('FE-MAP-MERGEPOI-009: nothing offered keeps the same array, so no pin is rebuilt', () => {
|
||||
const corridor = [poi('a')]
|
||||
const { result, rerender } = renderHook(({ o }: { o: Poi[] }) => useMergedMapPois(corridor, [], o), {
|
||||
initialProps: { o: [] as Poi[] },
|
||||
})
|
||||
const first = result.current
|
||||
rerender({ o: [] })
|
||||
expect(result.current).toBe(first)
|
||||
expect(result.current).toBe(corridor)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { Poi } from './poiCategories'
|
||||
|
||||
/**
|
||||
* The findings the map should draw, when two searches can be running at once.
|
||||
*
|
||||
* In road trip mode the corridor search answers "what is along this drive". The category
|
||||
* pill answers a different question — "what is in view right now" — and a hotel at tonight's
|
||||
* stop is exactly the case where someone wants the second one without giving up the first,
|
||||
* so the two lists are drawn together rather than one replacing the other.
|
||||
*
|
||||
* Identity is `osm_id`, the same key the corridor search dedupes its own overlapping tiles
|
||||
* on: a hotel found both along the route and in the viewport is one pin, and the corridor's
|
||||
* copy wins because it carries how far along the drive it sits.
|
||||
*
|
||||
* `offered` is the third case: the handful of filling stations proposed where a tank runs
|
||||
* out. They belong on the map for a plain reason — somebody is being asked to accept a
|
||||
* stop, and a stop you cannot see is not one you can judge — but they are not a search
|
||||
* result and must not survive the panel being closed, which is why they arrive separately
|
||||
* rather than being folded into the corridor's own list.
|
||||
*
|
||||
* Returns the input array itself whenever there is nothing to merge. The map redraws off
|
||||
* this reference, so handing it a fresh array on every render would rebuild every pin.
|
||||
*/
|
||||
export function useMergedMapPois(corridor: Poi[] | null, explore: Poi[], offered: Poi[] = NONE): Poi[] {
|
||||
return useMemo(() => {
|
||||
const base = corridor === null ? explore : merge(corridor, explore)
|
||||
return merge(base, offered)
|
||||
}, [corridor, explore, offered])
|
||||
}
|
||||
|
||||
/** Nothing offered, as one stable array, so the default never changes identity. */
|
||||
const NONE: Poi[] = []
|
||||
|
||||
/** `a` with everything from `b` that it does not already carry, by `osm_id`. */
|
||||
function merge(a: Poi[], b: Poi[]): Poi[] {
|
||||
if (!b.length) return a
|
||||
if (!a.length) return b
|
||||
const seen = new Set(a.map(p => p.osm_id))
|
||||
const extra = b.filter(p => !seen.has(p.osm_id))
|
||||
return extra.length ? [...a, ...extra] : a
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { dayColor } from '../Roadtrip/dayColors'
|
||||
import { useTripRouteOverview } from './useTripRouteOverview'
|
||||
import { buildAssignment, buildDay, buildPlace } from '../../../tests/helpers/factories'
|
||||
import type { AssignmentsMap, RouteSegment } from '../../types'
|
||||
|
||||
vi.mock('./RouteCalculator', async (importActual) => {
|
||||
const actual = await importActual<typeof import('./RouteCalculator')>()
|
||||
return { ...actual, calculateRouteWithLegs: vi.fn() }
|
||||
})
|
||||
|
||||
const { calculateRouteWithLegs } = await import('./RouteCalculator')
|
||||
|
||||
const leg = (distance: number, duration: number): RouteSegment => ({
|
||||
mid: [0, 0], from: [0, 0], to: [0, 0],
|
||||
distance, duration,
|
||||
distanceText: `${Math.round(distance / 1000)} km`,
|
||||
durationText: '1 h', walkingText: '5 h', drivingText: '1 h',
|
||||
})
|
||||
|
||||
const at = (lat: number, lng: number, order: number, dayId: number, extra: Record<string, unknown> = {}) =>
|
||||
buildAssignment({ day_id: dayId, order_index: order, place: buildPlace({ lat, lng }), ...extra })
|
||||
|
||||
const DAYS = [buildDay({ id: 1, day_number: 1 }), buildDay({ id: 2, day_number: 2 })]
|
||||
const ASSIGNMENTS: AssignmentsMap = {
|
||||
'1': [at(48.86, 2.35, 0, 1), at(48.88, 2.36, 1, 1)],
|
||||
'2': [at(45.76, 4.83, 0, 2), at(45.78, 4.85, 1, 2)],
|
||||
}
|
||||
|
||||
const render = (enabled = true, assignments: AssignmentsMap = ASSIGNMENTS, days = DAYS) =>
|
||||
renderHook(
|
||||
({ assignments: a }: { assignments: AssignmentsMap }) =>
|
||||
useTripRouteOverview(7, days, a, [], [], 'driving', enabled),
|
||||
{ initialProps: { assignments } },
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(calculateRouteWithLegs).mockReset()
|
||||
vi.mocked(calculateRouteWithLegs).mockResolvedValue({
|
||||
coordinates: [[48.86, 2.35], [48.87, 2.355], [48.88, 2.36]],
|
||||
distance: 12000, duration: 900,
|
||||
legs: [leg(12000, 900)],
|
||||
})
|
||||
})
|
||||
|
||||
describe('useTripRouteOverview', () => {
|
||||
it('FE-MAP-TRO-001: does nothing at all until it is switched on', () => {
|
||||
const { result } = render(false)
|
||||
|
||||
expect(result.current.days).toEqual([])
|
||||
expect(result.current.totalDistance).toBe(0)
|
||||
expect(result.current.loading).toBe(false)
|
||||
expect(calculateRouteWithLegs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-002: sums every day into the trip total', async () => {
|
||||
const { result } = render()
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days).toHaveLength(2)
|
||||
expect(result.current.days.map(d => d.distance)).toEqual([12000, 12000])
|
||||
expect(result.current.totalDistance).toBe(24000)
|
||||
expect(result.current.totalDuration).toBe(1800)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-003: gives each day the colour the road trip draws it in', async () => {
|
||||
const { result } = render()
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
const colors = result.current.days.map(d => d.color)
|
||||
// The same palette either way, so a day does not change colour when the trip is
|
||||
// read as a road trip instead of an overview.
|
||||
expect(colors[0]).toEqual(dayColor(1))
|
||||
expect(colors[1]).toEqual(dayColor(2))
|
||||
// Every drawn line is labelled with the colour of the day it belongs to.
|
||||
expect(result.current.lineColors).toHaveLength(result.current.lines.length)
|
||||
expect(new Set(result.current.lineColors.map(c => c.line)).size).toBe(2)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-004: reports the modes each day is travelled in', async () => {
|
||||
const { result } = render(true, {
|
||||
'1': [at(48.86, 2.35, 0, 1, { leg_transport_mode: 'walking' }), at(48.88, 2.36, 1, 1)],
|
||||
'2': ASSIGNMENTS['2'],
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days[0].modes).toEqual(['walking'])
|
||||
expect(result.current.days[1].modes).toEqual(['driving'])
|
||||
expect(result.current.days[0].segments[0].mode).toBe('walking')
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-005: draws straight lines first and keeps them when routing refuses', async () => {
|
||||
vi.mocked(calculateRouteWithLegs).mockRejectedValue(new Error('429'))
|
||||
const { result } = render()
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
// The shape of the trip is still on the map...
|
||||
expect(result.current.lines).toHaveLength(2)
|
||||
expect(result.current.lines[0]).toEqual([[48.86, 2.35], [48.88, 2.36]])
|
||||
// ...but a leg that never answered contributes no distance.
|
||||
expect(result.current.totalDistance).toBe(0)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-006: a day with nothing to drive is left out', async () => {
|
||||
const { result } = render(true, { '1': ASSIGNMENTS['1'], '2': [at(45.76, 4.83, 0, 2)] })
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.days.map(d => d.dayId)).toEqual([1])
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-007: frames the whole trip, not one day of it', async () => {
|
||||
const { result } = render()
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(result.current.focusPoints.length).toBe(result.current.lines.flat().length)
|
||||
expect(result.current.focusPoints.length).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
it('FE-MAP-TRO-008: renaming a place does not re-route the trip', async () => {
|
||||
const { result, rerender } = render()
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
const calls = vi.mocked(calculateRouteWithLegs).mock.calls.length
|
||||
|
||||
expect(calls).toBeGreaterThan(0)
|
||||
|
||||
// A new object identity with the same geometry: the plan memo re-runs, the
|
||||
// routing round must not.
|
||||
rerender({
|
||||
assignments: {
|
||||
...ASSIGNMENTS,
|
||||
'1': ASSIGNMENTS['1'].map(a => ({ ...a, place: { ...a.place, name: 'Renamed' } })),
|
||||
},
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false))
|
||||
expect(vi.mocked(calculateRouteWithLegs).mock.calls.length).toBe(calls)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { calculateRouteWithLegs, type RouteProfileKey } from './RouteCalculator'
|
||||
import { buildDayRouteRuns, type DayRoutePoint } from './dayRoutePlan'
|
||||
import { resolveLegMode } from '../Planner/legMode'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { dayColor } from '../Roadtrip/dayColors'
|
||||
import type { Accommodation, AssignmentsMap, Day, Reservation, RouteSegment } from '../../types'
|
||||
|
||||
/** One travel day of the overview: the roads it covers, in its own colour. */
|
||||
export interface TripOverviewDay {
|
||||
dayId: number
|
||||
dayNumber: number
|
||||
date: string | null
|
||||
title: string | null
|
||||
/** The core and casing this day is drawn in — the road trip's palette, so a day is
|
||||
* the same colour whichever way the trip is being read. */
|
||||
color: { line: string; casing: string }
|
||||
/** One polyline per run of the day, `[lat, lng]`. */
|
||||
lines: [number, number][][]
|
||||
segments: RouteSegment[]
|
||||
/** Metres and seconds, summed over the day's legs. */
|
||||
distance: number
|
||||
duration: number
|
||||
/** The modes this day is actually travelled in, first use first. */
|
||||
modes: string[]
|
||||
}
|
||||
|
||||
export interface TripRouteOverview {
|
||||
days: TripOverviewDay[]
|
||||
/** Every day's polylines flattened in trip order — what the map draws. */
|
||||
lines: [number, number][][]
|
||||
/** The colour of each entry of `lines`, same index. */
|
||||
lineColors: { line: string; casing: string }[]
|
||||
segments: RouteSegment[]
|
||||
/** Every drawn coordinate, so the map can frame the whole trip at once. */
|
||||
focusPoints: [number, number][]
|
||||
totalDistance: number
|
||||
totalDuration: number
|
||||
/** True until every leg has answered — the totals are a partial sum until then. */
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const EMPTY: TripRouteOverview = {
|
||||
days: [], lines: [], lineColors: [], segments: [], focusPoints: [],
|
||||
totalDistance: 0, totalDuration: 0, loading: false,
|
||||
}
|
||||
|
||||
/** Neighbouring legs of one run that resolve to the same mode travel as one request,
|
||||
* exactly as the single-day route builds them. */
|
||||
interface Chunk { points: DayRoutePoint[]; mode: string }
|
||||
|
||||
function chunkRun(run: DayRoutePoint[], dayDefaultMode: string): Chunk[] {
|
||||
const chunks: Chunk[] = []
|
||||
let i = 0
|
||||
while (i < run.length - 1) {
|
||||
const mode = resolveLegMode(run[i], run[i + 1], dayDefaultMode)
|
||||
let end = i + 1
|
||||
while (end < run.length - 1 && resolveLegMode(run[end], run[end + 1], dayDefaultMode) === mode) end++
|
||||
chunks.push({ points: run.slice(i, end + 1), mode })
|
||||
i = end
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
const straight = (points: DayRoutePoint[]): [number, number][] => points.map(p => [p.lat, p.lng])
|
||||
|
||||
/**
|
||||
* Every travel day's route at once, each day in its own colour, with the trip's total
|
||||
* distance (#1736).
|
||||
*
|
||||
* The geometry is the day view's own — `buildDayRouteRuns` is the same builder
|
||||
* `useRouteCalculation` draws the selected day from, so a day looks identical whether
|
||||
* you are looking at it alone or at the whole trip. Only the aggregation is new.
|
||||
*
|
||||
* Legs go out a few at a time rather than all at once: a fortnight is dozens of legs and
|
||||
* the routing host is usually a shared OSRM. RouteCalculator's cache is shared with the
|
||||
* day route and the sidebar connectors, so the day already on screen costs nothing.
|
||||
*/
|
||||
export function useTripRouteOverview(
|
||||
tripId: number | null,
|
||||
days: Day[],
|
||||
assignments: AssignmentsMap,
|
||||
reservations: Reservation[],
|
||||
accommodations: Accommodation[],
|
||||
profile: RouteProfileKey,
|
||||
enabled: boolean,
|
||||
): TripRouteOverview {
|
||||
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
||||
// Leg text is formatted at compute time, so a km↔mi switch has to re-run (#1300).
|
||||
const distanceUnit = useSettingsStore(s => s.settings.distance_unit)
|
||||
const [result, setResult] = useState<TripRouteOverview>(EMPTY)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const plan = useMemo(() => {
|
||||
if (!enabled) return []
|
||||
return [...days]
|
||||
.sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
.map(day => ({
|
||||
day,
|
||||
runs: buildDayRouteRuns(day.id, { days, assignments, reservations, accommodations, optimizeFromAccommodation }),
|
||||
}))
|
||||
.filter(entry => entry.runs.length > 0)
|
||||
}, [enabled, days, assignments, reservations, accommodations, optimizeFromAccommodation])
|
||||
|
||||
// Only geometry and mode decide whether legs have to be fetched again: renaming a
|
||||
// place or editing its notes must not fire a routing round.
|
||||
const planKey = useMemo(
|
||||
() => plan.map(({ day, runs }) => `${day.id}@${day.default_transport_mode ?? ''}:${runs
|
||||
.map(run => run.map(p => `${p.lat.toFixed(5)},${p.lng.toFixed(5)},${p.leg_transport_mode ?? ''},${p.incoming_leg_transport_mode ?? ''}`).join('|'))
|
||||
.join('/')}`).join(';'),
|
||||
[plan],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
abortRef.current?.abort()
|
||||
if (!plan.length) { setResult(EMPTY); return }
|
||||
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
const colorFor = (day: Day) => dayColor(day.day_number ?? 0)
|
||||
|
||||
// Chunk every day up front, so the routed answers can be filed by position and
|
||||
// reassembled in order however they come back.
|
||||
const chunked = plan.map(({ day, runs }) => {
|
||||
const dayDefaultMode = day.default_transport_mode || profile
|
||||
return { day, runs: runs.map(run => chunkRun(run, dayDefaultMode)) }
|
||||
})
|
||||
|
||||
const assemble = (
|
||||
routed: (({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)[][][],
|
||||
): TripRouteOverview => {
|
||||
const overviewDays: TripOverviewDay[] = chunked.map(({ day, runs }, d) => {
|
||||
const lines: [number, number][][] = []
|
||||
const segments: RouteSegment[] = []
|
||||
runs.forEach((chunks, r) => {
|
||||
const polyline: [number, number][] = []
|
||||
chunks.forEach((chunk, c) => {
|
||||
const answer = routed[d][r][c]
|
||||
const coords = answer && answer.coordinates.length >= 2 ? answer.coordinates : straight(chunk.points)
|
||||
for (const point of coords) {
|
||||
// Drop the point shared with the previous chunk so concatenated legs
|
||||
// don't leave a duplicate at each junction.
|
||||
const last = polyline[polyline.length - 1]
|
||||
if (last && last[0] === point[0] && last[1] === point[1]) continue
|
||||
polyline.push(point)
|
||||
}
|
||||
if (answer) for (const leg of answer.legs) segments.push({ ...leg, mode: chunk.mode })
|
||||
})
|
||||
if (polyline.length >= 2) lines.push(polyline)
|
||||
})
|
||||
const modes: string[] = []
|
||||
for (const chunk of runs.flat()) if (!modes.includes(chunk.mode)) modes.push(chunk.mode)
|
||||
return {
|
||||
dayId: day.id,
|
||||
dayNumber: day.day_number ?? 0,
|
||||
date: day.date ?? null,
|
||||
title: day.title ?? null,
|
||||
color: colorFor(day),
|
||||
lines,
|
||||
segments,
|
||||
distance: segments.reduce((sum, s) => sum + s.distance, 0),
|
||||
duration: segments.reduce((sum, s) => sum + s.duration, 0),
|
||||
modes,
|
||||
}
|
||||
})
|
||||
const lines = overviewDays.flatMap(d => d.lines)
|
||||
return {
|
||||
days: overviewDays,
|
||||
lines,
|
||||
lineColors: overviewDays.flatMap(d => d.lines.map(() => d.color)),
|
||||
segments: overviewDays.flatMap(d => d.segments),
|
||||
focusPoints: lines.flat(),
|
||||
totalDistance: overviewDays.reduce((sum, d) => sum + d.distance, 0),
|
||||
totalDuration: overviewDays.reduce((sum, d) => sum + d.duration, 0),
|
||||
loading: false,
|
||||
}
|
||||
}
|
||||
|
||||
const routed = chunked.map(({ runs }) => runs.map(chunks => chunks.map(() => null as
|
||||
({ coordinates: [number, number][]; legs: RouteSegment[] }) | null)))
|
||||
|
||||
// Straight lines first so the shape of the trip is on screen immediately, then the
|
||||
// real roads replace them — the same two-step the single-day route draws with.
|
||||
setResult({ ...assemble(routed), loading: true })
|
||||
|
||||
;(async () => {
|
||||
const tasks: (() => Promise<void>)[] = []
|
||||
chunked.forEach(({ day, runs }, d) => {
|
||||
runs.forEach((chunks, r) => {
|
||||
chunks.forEach((chunk, c) => {
|
||||
tasks.push(async () => {
|
||||
try {
|
||||
const answer = await calculateRouteWithLegs(
|
||||
chunk.points.map(p => ({ lat: p.lat, lng: p.lng })),
|
||||
{ signal: controller.signal, profile: chunk.mode, tripId, dayId: day.id },
|
||||
)
|
||||
routed[d][r][c] = { coordinates: answer.coordinates, legs: answer.legs }
|
||||
} catch {
|
||||
// Routing refused this leg (usually a rate limit) — it keeps its
|
||||
// straight line and contributes no distance, exactly as a day route's
|
||||
// failed leg does.
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Small pool on purpose, the same one the sidebar connectors use: a fortnight is
|
||||
// dozens of legs and the routing host is often a shared OSRM.
|
||||
let next = 0
|
||||
const worker = async () => {
|
||||
while (next < tasks.length && !controller.signal.aborted) await tasks[next++]()
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(6, tasks.length) }, worker))
|
||||
|
||||
if (!controller.signal.aborted) setResult(assemble(routed))
|
||||
})()
|
||||
|
||||
return () => controller.abort()
|
||||
// planKey is derived from the same inputs as plan, so keying on the string is
|
||||
// equivalent while staying stable across unrelated renders.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [planKey, profile, tripId, distanceUnit])
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { valhallaBase, valhallaAvailable, valhallaRouteAvoiding, valhallaRun, runFrom, legAvoids } from './valhallaRoute'
|
||||
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de/route'
|
||||
|
||||
/**
|
||||
* A real polyline6, encoded from three known points and verified by decoding it back.
|
||||
* Precision matters here more than anywhere else in the file: read as polyline5 the
|
||||
* first vertex would be 535.511, which is not a latitude, and a wrong guess draws a
|
||||
* line into the sea rather than failing.
|
||||
*/
|
||||
const SHAPE = 'w~nceBg}}`RgkPwjr@~fn~@oirmE'
|
||||
const SHAPE_POINTS: [number, number][] = [[53.5511, 9.9937], [53.56, 10.02], [52.52, 13.405]]
|
||||
|
||||
const answer = (summary: Record<string, unknown> = {}) => ({
|
||||
trip: {
|
||||
legs: [{ shape: SHAPE }],
|
||||
summary: {
|
||||
has_time_restrictions: false,
|
||||
has_toll: false,
|
||||
has_highway: true,
|
||||
has_ferry: false,
|
||||
// Asked in kilometres, so this is 295.7 km and 191 minutes — the real
|
||||
// Hamburg-to-Berlin answer, which the adapter has to hand on in metres and seconds.
|
||||
length: 295.7,
|
||||
time: 11460,
|
||||
...summary,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const setSettings = (patch: Record<string, string>) =>
|
||||
useSettingsStore.setState(st => ({ settings: { ...st.settings, ...patch } }))
|
||||
|
||||
afterEach(() => {
|
||||
setSettings({ routing_base_url: '', valhalla_base_url: '' })
|
||||
})
|
||||
|
||||
const HAMBURG = { lat: 53.5511, lng: 9.9937 }
|
||||
const BERLIN = { lat: 52.52, lng: 13.405 }
|
||||
|
||||
describe('valhallaBase', () => {
|
||||
it('VALHALLA-BASE-001: falls back to the public instance when nothing is configured', () => {
|
||||
expect(valhallaBase()).toBe('https://valhalla1.openstreetmap.de')
|
||||
expect(valhallaAvailable()).toBe(true)
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-002: uses the configured instance and drops its trailing slashes', () => {
|
||||
setSettings({ valhalla_base_url: 'https://valhalla.example.org//' })
|
||||
expect(valhallaBase()).toBe('https://valhalla.example.org')
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-003: asks nothing when the instance runs its own OSRM', () => {
|
||||
// An operator who filled in their own router chose against the public hosts, usually
|
||||
// because the instance is not supposed to reach third parties at all. Adding one they
|
||||
// never asked for would undo that, and their MLD-built OSRM answers exclude itself.
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org' })
|
||||
expect(valhallaBase()).toBeNull()
|
||||
expect(valhallaAvailable()).toBe(false)
|
||||
})
|
||||
|
||||
it('VALHALLA-BASE-004: an own Valhalla wins over an own OSRM', () => {
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org', valhalla_base_url: 'https://v.example.org' })
|
||||
expect(valhallaBase()).toBe('https://v.example.org')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runFrom', () => {
|
||||
const total = (data: unknown) => runFrom(data)?.total ?? null
|
||||
|
||||
it('VALHALLA-PARSE-001: decodes the shape as polyline6 and converts km to metres', () => {
|
||||
const leg = total(answer())
|
||||
expect(leg).not.toBeNull()
|
||||
expect(leg!.coordinates).toEqual(SHAPE_POINTS)
|
||||
expect(leg!.distance).toBeCloseTo(295700, 0)
|
||||
expect(leg!.duration).toBe(11460)
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-002: carries the flags through as booleans', () => {
|
||||
const leg = total(answer({ has_toll: true, has_ferry: true, has_highway: false }))
|
||||
expect(leg).toMatchObject({ hasToll: true, hasFerry: true, hasHighway: false })
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-003: a missing flag reads as absent rather than unknown', () => {
|
||||
// The public instance omits a flag it has nothing to say about, and the caller turns
|
||||
// this into a label. Undefined has to become false, not a truthy object.
|
||||
const leg = total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 1, time: 60 } } })
|
||||
expect(leg).toMatchObject({ hasToll: false, hasHighway: false, hasFerry: false })
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-004: keeps a chain divided leg by leg, and adds it up as well', () => {
|
||||
// What a request with a via really answers: one summary per pair of stops. The
|
||||
// schedule is built from those, so flattening them into a total would lose the
|
||||
// arrival time at every stop but the last.
|
||||
const chain = {
|
||||
trip: {
|
||||
legs: [
|
||||
{ shape: SHAPE, summary: { length: 100, time: 3600, has_toll: false, has_highway: true, has_ferry: false } },
|
||||
{ shape: SHAPE, summary: { length: 50, time: 1800, has_toll: true, has_highway: false, has_ferry: false } },
|
||||
],
|
||||
summary: { length: 150, time: 5400, has_toll: true, has_highway: true, has_ferry: false },
|
||||
},
|
||||
}
|
||||
const run = runFrom(chain)!
|
||||
expect(run.legs.map(l => l.distance)).toEqual([100000, 50000])
|
||||
expect(run.legs.map(l => l.hasToll)).toEqual([false, true])
|
||||
expect(run.total.distance).toBe(150000)
|
||||
expect(run.total.duration).toBe(5400)
|
||||
// One tolled leg makes the drive tolled. `every` here would call a day with a
|
||||
// single toll booth in it toll-free.
|
||||
expect(run.total.hasToll).toBe(true)
|
||||
expect(run.total.coordinates).toHaveLength(SHAPE_POINTS.length * 2)
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-007: several legs with no summaries of their own are refused', () => {
|
||||
// There is no honest way to divide one total between them, and a guess would reach
|
||||
// the user as a wrong arrival time rather than as a missing one.
|
||||
const two = { trip: { legs: [{ shape: SHAPE }, { shape: SHAPE }], summary: { length: 2, time: 120 } } }
|
||||
expect(runFrom(two)).toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-005: an error body is not a leg', () => {
|
||||
// What a 400 carries: no trip at all. Parsing it as one would produce a route of
|
||||
// NaN kilometres rather than the null every caller reads as "no second way".
|
||||
expect(total({ error_code: 442, error: 'No path could be found for input' })).toBeNull()
|
||||
expect(total({ trip: { legs: [], summary: { length: 1, time: 1 } } })).toBeNull()
|
||||
expect(total({ trip: { legs: [{ shape: SHAPE }] } })).toBeNull()
|
||||
expect(total(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-PARSE-006: a summary without numbers is not a leg', () => {
|
||||
expect(total({ trip: { legs: [{ shape: SHAPE }], summary: { length: 'far', time: 60 } } })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('legAvoids', () => {
|
||||
const leg = (flags: Partial<{ hasToll: boolean; hasHighway: boolean; hasFerry: boolean }>) => ({
|
||||
coordinates: SHAPE_POINTS, distance: 1, duration: 1,
|
||||
hasToll: false, hasHighway: false, hasFerry: false, ...flags,
|
||||
})
|
||||
|
||||
it('VALHALLA-AVOID-001: reads the answer, not the request', () => {
|
||||
// use_tolls: 0 is a weighting. Between two points with no untolled connection the
|
||||
// answer is still a tolled road, honestly flagged, and labelling that "No tolls"
|
||||
// would put a lie on the map.
|
||||
expect(legAvoids(leg({ hasToll: false }), 'toll')).toBe(true)
|
||||
expect(legAvoids(leg({ hasToll: true }), 'toll')).toBe(false)
|
||||
})
|
||||
|
||||
it('VALHALLA-AVOID-002: each class reads its own flag', () => {
|
||||
expect(legAvoids(leg({ hasHighway: true }), 'motorway')).toBe(false)
|
||||
expect(legAvoids(leg({ hasHighway: true }), 'toll')).toBe(true)
|
||||
expect(legAvoids(leg({ hasFerry: true }), 'ferry')).toBe(false)
|
||||
expect(legAvoids(leg({ hasFerry: false }), 'ferry')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valhallaRouteAvoiding', () => {
|
||||
it('VALHALLA-REQ-001: posts the avoidance as a costing option under the right profile', async () => {
|
||||
let body: Record<string, unknown> | null = null
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
body = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json(answer())
|
||||
})
|
||||
)
|
||||
const leg = await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
|
||||
expect(leg!.distance).toBeCloseTo(295700, 0)
|
||||
expect(body).toMatchObject({
|
||||
costing: 'auto',
|
||||
costing_options: { auto: { use_tolls: 0 } },
|
||||
directions_options: { units: 'kilometers' },
|
||||
})
|
||||
// The radius is what makes a place in a pedestrian zone routable at all: without it
|
||||
// Vienna's Stephansdom answers error 442 and the whole leg is lost.
|
||||
expect(body!.locations).toEqual([
|
||||
{ lat: 53.5511, lon: 9.9937, radius: 50 },
|
||||
{ lat: 52.52, lon: 13.405, radius: 50 },
|
||||
])
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-002: each class maps to its own costing option', async () => {
|
||||
const seen: Record<string, unknown>[] = []
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
seen.push(await request.json() as Record<string, unknown>)
|
||||
return HttpResponse.json(answer())
|
||||
})
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway')
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'ferry')
|
||||
expect(seen.map(b => b.costing_options)).toEqual([
|
||||
{ auto: { use_highways: 0 } },
|
||||
{ auto: { use_ferry: 0 } },
|
||||
])
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-003: walking and cycling get their own costing models', async () => {
|
||||
const seen: string[] = []
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { costing: string }
|
||||
seen.push(body.costing)
|
||||
return HttpResponse.json(answer())
|
||||
})
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'walking', 'ferry')
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'cycling', 'ferry')
|
||||
expect(seen).toEqual(['pedestrian', 'bicycle'])
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-004: a refusal is a null, not a throw', async () => {
|
||||
// Every failure means the same thing to the caller — there is no second way to
|
||||
// show — so a 400 for an unroutable point and a 429 for the rate limit both end
|
||||
// here rather than as an error over a map that is drawing fine.
|
||||
server.use(
|
||||
http.post(FOSSGIS_VALHALLA, () =>
|
||||
HttpResponse.json({ error_code: 442, error: 'No path could be found for input' }, { status: 400 })
|
||||
)
|
||||
)
|
||||
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-005: a host that is not a Valhalla is only asked once', async () => {
|
||||
let calls = 0
|
||||
setSettings({ valhalla_base_url: 'https://not-valhalla.example.org' })
|
||||
server.use(
|
||||
http.post('https://not-valhalla.example.org/route', () => {
|
||||
calls++
|
||||
return new HttpResponse(null, { status: 404 })
|
||||
})
|
||||
)
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')
|
||||
await valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'motorway')
|
||||
// A 404 is about the URL rather than about this leg, so remembering it saves every
|
||||
// later leg a request against a host that will never answer.
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
it('VALHALLA-REQ-006: asks nothing at all when the instance has its own OSRM', async () => {
|
||||
let calls = 0
|
||||
setSettings({ routing_base_url: 'https://osrm.example.org' })
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(answer()) }))
|
||||
await expect(valhallaRouteAvoiding(HAMBURG, BERLIN, 'driving', 'toll')).resolves.toBeNull()
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valhallaRun over a whole day', () => {
|
||||
/** A chain answer with one summary per leg, which is what a real request returns. */
|
||||
const chain = (legCount: number) => ({
|
||||
trip: {
|
||||
legs: Array.from({ length: legCount }, () => ({
|
||||
shape: SHAPE,
|
||||
summary: { length: 10, time: 600, has_toll: false, has_highway: true, has_ferry: false },
|
||||
})),
|
||||
summary: { length: 10 * legCount, time: 600 * legCount, has_toll: false, has_highway: true, has_ferry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const stops = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({ lat: 53 - i * 0.1, lng: 10 + i * 0.1 }))
|
||||
|
||||
it('VALHALLA-RUN-001: a day inside the cap goes out as one request, one leg per pair', async () => {
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
calls++
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
const run = await valhallaRun(stops(6), 'driving', [])
|
||||
|
||||
expect(calls).toBe(1)
|
||||
expect(run!.legs).toHaveLength(5)
|
||||
expect(run!.total.distance).toBe(50000)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-002: a day over the cap is split on a shared point, and the legs still line up', async () => {
|
||||
// Ten locations is hard: the eleventh answers error 150. Split runs overlap by one
|
||||
// point, which is exact rather than approximate — measured over a six-stop day,
|
||||
// [0..3] plus [3..5] came back within two metres of the same day asked whole.
|
||||
const sizes: number[] = []
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
sizes.push(body.locations.length)
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
const run = await valhallaRun(stops(14), 'driving', ['toll'])
|
||||
|
||||
// Fourteen stops: the first run takes ten, the second starts on the tenth again and
|
||||
// takes the remaining five. Nine pairs plus four.
|
||||
expect(sizes).toEqual([10, 5])
|
||||
// Thirteen pairs across two requests, none lost and none counted twice.
|
||||
expect(run!.legs).toHaveLength(13)
|
||||
expect(run!.total.distance).toBe(130000)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-003: never sends more than ten locations, however long the day', async () => {
|
||||
const sizes: number[] = []
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
sizes.push(body.locations.length)
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
await valhallaRun(stops(28), 'driving', [])
|
||||
|
||||
expect(Math.max(...sizes)).toBeLessThanOrEqual(10)
|
||||
expect(sizes.reduce((sum, n) => sum + n - 1, 0)).toBe(27)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-004: a piece that will not answer loses the whole day rather than half of it', async () => {
|
||||
// Half a day of legs is worse than none. The schedule chains leg times blindly and
|
||||
// cannot tell that some are missing, so it would print a complete, plausible,
|
||||
// silently wrong timetable instead of leaving a visible gap.
|
||||
//
|
||||
// The SECOND CHUNK fails, not the second request: one attempt is retried, so failing
|
||||
// by call number would just be answered on the retry.
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
if (body.locations.length < 10) {
|
||||
return HttpResponse.json({ error_code: 442, error: 'No path' }, { status: 400 })
|
||||
}
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
await expect(valhallaRun(stops(14), 'driving', ['toll'])).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-007: a piece that fails once is asked again rather than losing the day', async () => {
|
||||
// The caller retries the whole task, so without this a rate limit on the last piece
|
||||
// of a split day throws away the pieces that already answered and asks for all of
|
||||
// them again — three times over, against a host that allows one request a second.
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, async ({ request }) => {
|
||||
const body = await request.json() as { locations: unknown[] }
|
||||
calls++
|
||||
if (calls === 2) return new HttpResponse(null, { status: 429 })
|
||||
return HttpResponse.json(chain(body.locations.length - 1))
|
||||
}))
|
||||
|
||||
const run = await valhallaRun(stops(14), 'driving', ['toll'])
|
||||
|
||||
expect(run!.legs).toHaveLength(13)
|
||||
// First piece, second piece refused, second piece again.
|
||||
expect(calls).toBe(3)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-005: fewer than two points is nothing to route', async () => {
|
||||
let calls = 0
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => { calls++; return HttpResponse.json(chain(1)) }))
|
||||
await expect(valhallaRun(stops(1), 'driving', [])).resolves.toBeNull()
|
||||
expect(calls).toBe(0)
|
||||
})
|
||||
|
||||
it('VALHALLA-RUN-006: an answer with the wrong number of legs is refused', async () => {
|
||||
// A day whose legs do not match its stops cannot be assigned to the rail, and
|
||||
// guessing which stop is missing would put the wrong time against the right place.
|
||||
server.use(http.post(FOSSGIS_VALHALLA, () => HttpResponse.json(chain(2))))
|
||||
await expect(valhallaRun(stops(6), 'driving', [])).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { decodePolyline } from './transitGeometry'
|
||||
import { haversineKm } from '../../utils/geo'
|
||||
import type { Waypoint, SnappedWaypoint, RouteAvoidClass } from '../../types'
|
||||
|
||||
/**
|
||||
* Valhalla, the second routing engine, asked only the questions OSRM cannot answer.
|
||||
*
|
||||
* The shipped OSRM hosts are built without excludable road classes: their car profile
|
||||
* says so in its own Lua ("no memory for classes on demand server"), and `exclude=`
|
||||
* comes back as HTTP 400 on every leg. So the one thing a road tripper keeps asking
|
||||
* for — drive this without the tolls — has never worked on a default install.
|
||||
*
|
||||
* FOSSGIS runs a public Valhalla on the whole planet next to it, keyless, and there
|
||||
* avoidance is a runtime weighting rather than a profile that has to be rebuilt. This
|
||||
* module is the adapter: same question, different engine, answer shaped like the OSRM
|
||||
* one so the callers do not have to care which engine spoke.
|
||||
*
|
||||
* Deliberately NOT a replacement for the OSRM path. Three measured reasons:
|
||||
* - it caps at ten locations, and following a track sends a day's stops plus up to
|
||||
* nine anchors, so the bundled requests would start failing;
|
||||
* - it refuses a point it cannot bind to a road, where OSRM still snaps one. The
|
||||
* Stephansdom, in a pedestrian zone, answers `No path could be found for input`
|
||||
* until a search radius is allowed;
|
||||
* - it prices roads differently. Over ten European legs its drive times sat 5.4 %
|
||||
* above OSRM's on average and 36 % above on one, and those times feed the arrival
|
||||
* clocks of a road trip day. Mixing engines inside one chain would move times
|
||||
* nobody asked to move.
|
||||
*
|
||||
* Which leaves exactly the shape this module serves: two points, one avoidance, asked
|
||||
* on demand, drawn as an offer next to the route rather than replacing it.
|
||||
*/
|
||||
|
||||
/** The public instance, matching how the OSRM defaults are shipped rather than configured. */
|
||||
const FOSSGIS_VALHALLA = 'https://valhalla1.openstreetmap.de'
|
||||
|
||||
/**
|
||||
* How far Valhalla may look for a road under a point, in metres.
|
||||
*
|
||||
* Zero is its default and it is the wrong default for TREK's data, which is full of
|
||||
* museums, viewpoints and restaurants that sit inside a pedestrian zone. Measured on
|
||||
* Vienna's Stephansdom: without a radius the whole route answers error 442, with 50 it
|
||||
* routes, and 200 changed nothing further. So 50 buys the fix and nothing beyond it.
|
||||
*/
|
||||
const SNAP_RADIUS_M = 50
|
||||
|
||||
/** TREK's profile names against Valhalla's costing models. */
|
||||
const COSTING: Record<'driving' | 'walking' | 'cycling', string> = {
|
||||
driving: 'auto',
|
||||
walking: 'pedestrian',
|
||||
cycling: 'bicycle',
|
||||
}
|
||||
|
||||
/**
|
||||
* What a caller can ask to be left out, against the costing option that asks for it.
|
||||
*
|
||||
* Every one of these is a weighting from 0 to 1, not a ban — which is the whole reason
|
||||
* this engine is worth having. Asked to avoid tolls between New York and Boston it
|
||||
* still returns a tolled road, because there is no untolled way, where OSRM's `exclude`
|
||||
* would refuse to answer at all. The caller has to read the answer to find out what it
|
||||
* got, which is what `hasToll` and friends are for.
|
||||
*/
|
||||
const AVOID_OPTION: Record<AvoidClass, string> = {
|
||||
motorway: 'use_highways',
|
||||
toll: 'use_tolls',
|
||||
ferry: 'use_ferry',
|
||||
}
|
||||
|
||||
/** The same three classes the rest of the app names; aliased so this file reads on its own. */
|
||||
export type AvoidClass = RouteAvoidClass
|
||||
|
||||
/**
|
||||
* Locations one request may carry. The eleventh answers
|
||||
* `{"error_code":150,"error":"Exceeded max locations: 10"}` with HTTP 400.
|
||||
*/
|
||||
const MAX_LOCATIONS = 10
|
||||
|
||||
/**
|
||||
* Pause between the pieces of a split day, in milliseconds.
|
||||
*
|
||||
* The public instance allows about one request a second per client, and a day long
|
||||
* enough to be split is exactly the case that would fire two back to back. Matches the
|
||||
* 1100 ms the road trip rail already leaves between its own days.
|
||||
*/
|
||||
const CHUNK_PAUSE_MS = 1100
|
||||
|
||||
/**
|
||||
* Extra attempts for one piece of a split day.
|
||||
*
|
||||
* One, not a ladder: the common failure here is the public instance's rate limit, which
|
||||
* a single pause clears, and anything a second attempt cannot fix is not going to be
|
||||
* fixed by a fourth while somebody waits for their map.
|
||||
*/
|
||||
const CHUNK_RETRIES = 1
|
||||
|
||||
/** One way of driving a leg, in the units OSRM answers in so callers need no branch. */
|
||||
export interface ValhallaLeg {
|
||||
coordinates: [number, number][]
|
||||
/** Metres. Valhalla is asked in kilometres and converted here. */
|
||||
distance: number
|
||||
/** Seconds. */
|
||||
duration: number
|
||||
/** What the route ACTUALLY has, as opposed to what was asked for. */
|
||||
hasToll: boolean
|
||||
hasHighway: boolean
|
||||
hasFerry: boolean
|
||||
}
|
||||
|
||||
/** A routed chain: one leg per pair of consecutive waypoints, plus the whole of it. */
|
||||
export interface ValhallaRun {
|
||||
legs: ValhallaLeg[]
|
||||
total: ValhallaLeg
|
||||
/**
|
||||
* Where each waypoint actually met the road.
|
||||
*
|
||||
* Reconstructed from the shape rather than read from a field, because Valhalla echoes
|
||||
* the coordinates it was GIVEN in `trip.locations` and never says where it bound them.
|
||||
* The ends of a leg's line are those points: measured against Füssen, its first vertex
|
||||
* came back at 47.571617 / 10.70153, which is OSRM's snapped location for the same
|
||||
* request to the digit. Without this the dashed kerb-to-door spur from #2273 would
|
||||
* quietly disappear from every day routed by this engine.
|
||||
*/
|
||||
snapped: SnappedWaypoint[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance's Valhalla, or null when it should not ask one.
|
||||
*
|
||||
* Blank falls back to the public instance, the same reading `routing_base_url` gets —
|
||||
* with one condition. An operator who filled in their OWN router chose deliberately
|
||||
* against the public hosts, usually because their instance is not supposed to talk to
|
||||
* third parties at all, and quietly adding a second public host they never asked for
|
||||
* would undo that choice. They lose nothing: a self-hosted OSRM built through the MLD
|
||||
* pipeline answers `exclude` on its own, which is exactly the fallback below.
|
||||
*/
|
||||
export function valhallaBase(): string | null {
|
||||
const settings = useSettingsStore.getState().settings
|
||||
const configured = settings.valhalla_base_url?.trim()
|
||||
if (configured) return configured.replace(/\/+$/, '')
|
||||
return settings.routing_base_url?.trim() ? null : FOSSGIS_VALHALLA
|
||||
}
|
||||
|
||||
/** True when this engine can be asked at all, so callers can skip building a request. */
|
||||
export function valhallaAvailable(): boolean {
|
||||
return valhallaBase() !== null
|
||||
}
|
||||
|
||||
/** Hosts that have already proved they are not a Valhalla, by base URL. */
|
||||
const notValhalla = new Set<string>()
|
||||
|
||||
/**
|
||||
* One leg with a road class weighted away, or null when it cannot be had.
|
||||
*
|
||||
* Null for every failure, deliberately: this is an offer next to a route that already
|
||||
* exists, so a host that is down, a rate limit, a point in a field and a genuinely
|
||||
* connected-only-by-motorway leg all mean the same thing to the caller — there is no
|
||||
* second way to show. The one failure worth remembering is a host that is not a
|
||||
* Valhalla at all, because that is true of every leg from now on rather than this one.
|
||||
*/
|
||||
export async function valhallaRouteAvoiding(
|
||||
from: Waypoint,
|
||||
to: Waypoint,
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: AvoidClass,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ValhallaLeg | null> {
|
||||
const run = await valhallaRun([from, to], profile, [avoid], signal)
|
||||
return run ? run.total : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole chain of waypoints, one leg per pair, or null when it cannot be had.
|
||||
*
|
||||
* Split when it has to be. Ten locations is a hard cap, and a road trip day is bundled
|
||||
* into one request precisely so it appears at once rather than trickling in — so a day
|
||||
* over the cap is cut into runs that overlap by one point and the legs are concatenated.
|
||||
* That is exact, not an approximation: measured over a six-stop day, [0..3] plus [3..5]
|
||||
* came back within two metres and under a second of the same day asked whole, because
|
||||
* the shared point is routed to and from identically.
|
||||
*
|
||||
* The pieces go out one after another with a pause, since a day long enough to need
|
||||
* splitting is exactly the one that would otherwise fire two requests inside a second
|
||||
* at a host that allows one.
|
||||
*/
|
||||
export async function valhallaRun(
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<ValhallaRun | null> {
|
||||
const base = valhallaBase()
|
||||
if (!base || notValhalla.has(base) || waypoints.length < 2) return null
|
||||
|
||||
const legs: ValhallaLeg[] = []
|
||||
const chunks = chunkWaypoints(waypoints)
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
if (signal?.aborted) return null
|
||||
if (index > 0) await pause(CHUNK_PAUSE_MS, signal)
|
||||
// Retried per piece rather than per day. The caller retries the whole task, so a
|
||||
// rate limit on the last piece of a split day would otherwise throw away the pieces
|
||||
// that already answered and ask for all of them again — three times over, against a
|
||||
// host that allows one request a second.
|
||||
let run = await requestRun(base, chunk, profile, avoid, signal)
|
||||
for (let attempt = 0; !run && attempt < CHUNK_RETRIES && !signal?.aborted; attempt++) {
|
||||
// Not when the host just proved it is not a Valhalla at all: that answer will not
|
||||
// change on a second ask, and asking anyway is the request the memo exists to save.
|
||||
if (notValhalla.has(base)) break
|
||||
await pause(CHUNK_PAUSE_MS, signal)
|
||||
run = await requestRun(base, chunk, profile, avoid, signal)
|
||||
}
|
||||
// A day is one answer or none. Half a day of legs would be worse than no answer:
|
||||
// the schedule chains leg times blindly and cannot tell that it is missing some, so
|
||||
// it would print a complete, plausible, wrong timetable rather than a gap.
|
||||
if (!run) return null
|
||||
legs.push(...run.legs)
|
||||
}
|
||||
if (legs.length !== waypoints.length - 1) return null
|
||||
return { legs, total: joinLegs(legs), snapped: snappedFrom(legs, waypoints) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each waypoint met the road, taken off the ends of the legs that touch it.
|
||||
*
|
||||
* A leg's line starts at the bound position of its first stop and ends at the bound
|
||||
* position of its second, so stop i is the head of leg i, and the last stop is the tail
|
||||
* of the last leg. The distance is measured here rather than reported, the same recovery
|
||||
* `readSnapped` already applies when OSRM leaves the field out.
|
||||
*/
|
||||
function snappedFrom(legs: ValhallaLeg[], waypoints: Waypoint[]): SnappedWaypoint[] {
|
||||
return waypoints.map((waypoint, i) => {
|
||||
const line = i < legs.length ? legs[i].coordinates : legs[legs.length - 1].coordinates
|
||||
const at = i < legs.length ? line[0] : line[line.length - 1]
|
||||
const asked: [number, number] = [waypoint.lat, waypoint.lng]
|
||||
return {
|
||||
asked,
|
||||
at,
|
||||
meters: haversineKm(waypoint, { lat: at[0], lng: at[1] }) * 1000,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Runs of at most ten points, each starting on the one the previous ended at. */
|
||||
function chunkWaypoints(waypoints: Waypoint[]): Waypoint[][] {
|
||||
if (waypoints.length <= MAX_LOCATIONS) return [waypoints]
|
||||
const chunks: Waypoint[][] = []
|
||||
for (let start = 0; start < waypoints.length - 1; start += MAX_LOCATIONS - 1) {
|
||||
chunks.push(waypoints.slice(start, start + MAX_LOCATIONS))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
function pause(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const timer = setTimeout(resolve, ms)
|
||||
signal?.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
|
||||
})
|
||||
}
|
||||
|
||||
/** One request, at most ten locations. */
|
||||
async function requestRun(
|
||||
base: string,
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling',
|
||||
avoid: readonly AvoidClass[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Omit<ValhallaRun, 'snapped'> | null> {
|
||||
const costing = COSTING[profile]
|
||||
// All of them in one request: the options are independent weightings, so asking to
|
||||
// leave out tolls and ferries together is one question rather than two routes to
|
||||
// reconcile. Measured Hamburg to Copenhagen: tolls, motorways and ferries weighted
|
||||
// away together answered 656.9 km against 339.2 km for the plain route.
|
||||
const options: Record<string, number> = {}
|
||||
for (const cls of avoid) options[AVOID_OPTION[cls]] = 0
|
||||
const body = {
|
||||
locations: waypoints.map(w => ({ lat: w.lat, lon: w.lng, radius: SNAP_RADIUS_M })),
|
||||
costing,
|
||||
costing_options: { [costing]: options },
|
||||
directions_options: { units: 'kilometers' },
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${base}/route`, {
|
||||
method: 'POST',
|
||||
// A browser cannot set User-Agent, so this is the whole of what FOSSGIS asks a
|
||||
// client to identify itself with. It costs a preflight, which is answered.
|
||||
headers: { 'Content-Type': 'application/json', 'X-Client-Id': 'trek' },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
// 404 and 405 mean this URL is not a Valhalla — an OSRM pointed at by mistake,
|
||||
// or a proxy that swallowed the path. Every other status is about this request:
|
||||
// 400 carries the real refusals (unroutable point, over 1500 km apart) and 429
|
||||
// is the public instance's one-per-second.
|
||||
if (response.status === 404 || response.status === 405) notValhalla.add(base)
|
||||
return null
|
||||
}
|
||||
return runFrom(await response.json())
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Several legs read as one, the way a caller that only wants the whole drive sees it.
|
||||
*
|
||||
* The seam matters more than it looks. Every leg ends where the next begins, so a plain
|
||||
* concatenation repeats that vertex — and this line is not only drawn: a via dropped on
|
||||
* the route is projected onto it to work out which leg it belongs after, and that index
|
||||
* is written to the database. A repeated point can put the via on the wrong leg, which
|
||||
* is a stored wrong answer rather than an invisible one.
|
||||
*/
|
||||
function joinLegs(legs: ValhallaLeg[]): ValhallaLeg {
|
||||
const coordinates: [number, number][] = []
|
||||
for (const leg of legs) {
|
||||
const last = coordinates[coordinates.length - 1]
|
||||
const first = leg.coordinates[0]
|
||||
const continues = last && first && last[0] === first[0] && last[1] === first[1]
|
||||
coordinates.push(...(continues ? leg.coordinates.slice(1) : leg.coordinates))
|
||||
}
|
||||
return {
|
||||
coordinates,
|
||||
distance: legs.reduce((sum, l) => sum + l.distance, 0),
|
||||
duration: legs.reduce((sum, l) => sum + l.duration, 0),
|
||||
// One tolled leg makes the drive a tolled drive: `some`, never `every`, or a day
|
||||
// with a single toll booth in it would be labelled toll-free.
|
||||
hasToll: legs.some(l => l.hasToll),
|
||||
hasHighway: legs.some(l => l.hasHighway),
|
||||
hasFerry: legs.some(l => l.hasFerry),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Valhalla answer as a leg, or null when it is not one.
|
||||
*
|
||||
* Split out because it is the whole of the format knowledge and the only part worth
|
||||
* testing without a network: the native shape, not `format=osrm`. That flag exists and
|
||||
* would have kept the response side unchanged, but it drops `has_toll` — the one field
|
||||
* this is all for — and carries every intersection instead, which measured 5.7 times
|
||||
* larger on Paris to Lyon. Translating a smaller answer beats parsing a familiar one.
|
||||
*/
|
||||
/**
|
||||
* The answer read leg by leg, which is what a whole day needs.
|
||||
*
|
||||
* Valhalla gives every leg its own summary, so a chain comes back already divided the
|
||||
* way a road trip day wants it: this stop to the next, with its own distance, its own
|
||||
* drive time, and its own honest `has_toll`. Reading only the trip summary would flatten
|
||||
* a day into one number and lose the per-leg times the schedule is built from.
|
||||
*
|
||||
* The fallback to the trip summary is for a single leg only. Where several legs carry no
|
||||
* summary of their own there is no honest way to divide one total between them, and a
|
||||
* guess here would arrive as a wrong arrival time rather than as a missing one.
|
||||
*/
|
||||
export function runFrom(data: unknown): Omit<ValhallaRun, 'snapped'> | null {
|
||||
const trip = tripOf(data)
|
||||
if (!trip || !Array.isArray(trip.legs) || !trip.legs.length) return null
|
||||
|
||||
const legs: ValhallaLeg[] = []
|
||||
for (const leg of trip.legs) {
|
||||
if (typeof leg.shape !== 'string') return null
|
||||
const summary = leg.summary ?? (trip.legs.length === 1 ? trip.summary : undefined)
|
||||
if (!summary) return null
|
||||
// Precision 6, verified against a known point: decoded as 5 the first vertex of a
|
||||
// Paris route reads 488.56, which is not a latitude; as 6 it reads 48.856193.
|
||||
const measure = measured(summary, decodePolyline(leg.shape, 6))
|
||||
if (!measure) return null
|
||||
legs.push(measure)
|
||||
}
|
||||
return { legs, total: joinLegs(legs) }
|
||||
}
|
||||
|
||||
interface ValhallaSummary { [key: string]: number | boolean | undefined }
|
||||
|
||||
function tripOf(data: unknown) {
|
||||
return (data as {
|
||||
trip?: { legs?: { shape?: string; summary?: ValhallaSummary }[]; summary?: ValhallaSummary }
|
||||
})?.trip
|
||||
}
|
||||
|
||||
/** A summary and a shape as a leg, or null when the summary carries no numbers. */
|
||||
function measured(summary: ValhallaSummary, coordinates: [number, number][]): ValhallaLeg | null {
|
||||
if (!coordinates.length) return null
|
||||
const length = Number(summary.length)
|
||||
const time = Number(summary.time)
|
||||
if (!Number.isFinite(length) || !Number.isFinite(time)) return null
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
// Asked in kilometres, answered in kilometres, handed on in metres because that is
|
||||
// what every caller already reads off an OSRM route.
|
||||
distance: length * 1000,
|
||||
duration: time,
|
||||
hasToll: summary.has_toll === true,
|
||||
hasHighway: summary.has_highway === true,
|
||||
hasFerry: summary.has_ferry === true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a route actually avoids what it was asked to avoid.
|
||||
*
|
||||
* The reason this function exists rather than a boolean on the request: `use_tolls: 0`
|
||||
* is a weighting, so between two points with no untolled connection Valhalla answers a
|
||||
* tolled route and reports it honestly. Labelling that "No tolls" would be a lie on
|
||||
* screen, and the flags are in the answer precisely so nobody has to guess.
|
||||
*/
|
||||
export function legAvoids(leg: ValhallaLeg, avoid: AvoidClass): boolean {
|
||||
if (avoid === 'toll') return !leg.hasToll
|
||||
if (avoid === 'motorway') return !leg.hasHighway
|
||||
return !leg.hasFerry
|
||||
}
|
||||
@@ -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'
|
||||
@@ -95,6 +95,8 @@ interface DayPlanSidebarProps {
|
||||
onSetRouteProfile?: (profile: string) => void
|
||||
onAddPlace?: () => void
|
||||
onAddPlaceToDay?: (placeId: number, dayId: number) => void
|
||||
/** Open the place form already pointed at this day, to create a new place there. */
|
||||
onCreatePlaceForDay?: (dayId: number) => void
|
||||
onExpandedDaysChange?: (expandedDayIds: Set<number>) => void
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
canUndo?: boolean
|
||||
@@ -146,6 +148,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onAddReservation,
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
onCreatePlaceForDay,
|
||||
onNavigateToFiles,
|
||||
routeShown = false,
|
||||
routeProfile = 'driving',
|
||||
@@ -696,19 +699,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 }[]) => {
|
||||
@@ -1079,6 +1076,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onAddReservation,
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
onCreatePlaceForDay,
|
||||
onNavigateToFiles,
|
||||
routeShown,
|
||||
routeProfile,
|
||||
@@ -1250,6 +1248,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onAddReservation,
|
||||
onAddPlace,
|
||||
onAddPlaceToDay,
|
||||
onCreatePlaceForDay,
|
||||
onNavigateToFiles,
|
||||
routeShown,
|
||||
routeProfile,
|
||||
@@ -1898,7 +1897,34 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
border: dragOverDayId === day.id ? '2px dashed rgba(17,24,39,0.2)' : '2px dashed transparent',
|
||||
}}
|
||||
>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('dayplan.emptyDay')}</span>
|
||||
{/* An empty day is where somebody wants to add something, so
|
||||
the slot offers to do it instead of only stating the fact.
|
||||
Without the handler (no edit rights) it stays the sentence. */}
|
||||
{onCreatePlaceForDay ? (
|
||||
<button type="button"
|
||||
onClick={e => { e.stopPropagation(); onCreatePlaceForDay(day.id) }}
|
||||
className="text-content-muted"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
width: '100%', padding: '8px 12px', borderRadius: 8,
|
||||
// A solid outline and a whisper of tint rather than the
|
||||
// dashed grey placeholder it replaced: it is the one thing
|
||||
// to do on an empty day, so it should read as an offer
|
||||
// without competing with the day rows around it.
|
||||
background: 'color-mix(in srgb, var(--accent) 4%, transparent)',
|
||||
border: '1px solid var(--border-primary)',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'color-mix(in srgb, var(--accent) 10%, transparent)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'color-mix(in srgb, var(--accent) 4%, transparent)' }}
|
||||
>
|
||||
<Plus size={13} strokeWidth={2} /> {t('dayplan.addPlaceHere')}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))' }}>{t('dayplan.emptyDay')}</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
merged.map((item, idx) => {
|
||||
@@ -2614,17 +2640,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 +2678,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 +2700,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 +2906,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 +2922,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'}
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { RouteSegment } from '../../types'
|
||||
// Walking gets the foot icon; a plugin route profile ('plugin:…') gets the bolt —
|
||||
// its legs carry the profile-true durationText anyway, the icon just signals that
|
||||
// the time came from a plugin router (e.g. EV routing with charge time folded in).
|
||||
function profileIcon(profile: string) {
|
||||
export function profileIcon(profile: string) {
|
||||
if (profile === 'walking') return Footprints
|
||||
if (profile.startsWith('plugin:')) return Zap
|
||||
return Car
|
||||
|
||||
@@ -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()
|
||||
@@ -445,41 +467,33 @@ describe('PlaceDetailsColumn — hours and rating', () => {
|
||||
})
|
||||
})
|
||||
/**
|
||||
* FE-PDC-027..029 — the nudge towards a Google key.
|
||||
* FE-PDC-027 — nothing came back for this place.
|
||||
*
|
||||
* Deliberately narrow: it appears only when the free sources came up empty AND
|
||||
* no key is configured. On an instance with a key there is nothing to suggest,
|
||||
* and on a place the free sources described it would read as an advert.
|
||||
* It used to be a line of grey text plus, when no Google key was set, a panel
|
||||
* telling the reader to go and ask an administrator for one. The mascot says the
|
||||
* same thing in the shape every other empty state in TREK uses, and whose key is
|
||||
* missing is not this panel's business.
|
||||
*/
|
||||
describe('PlaceDetailsColumn — no Google key', () => {
|
||||
const empty = () => placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
|
||||
describe('PlaceDetailsColumn — nothing found', () => {
|
||||
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
|
||||
placeEnrichment.mockResolvedValue({ photos: [], facts: [], description: null })
|
||||
renderColumn()
|
||||
|
||||
it('FE-PDC-027: suggests a key when nothing was found and none is set', async () => {
|
||||
empty()
|
||||
renderColumn({ hasMapsKey: false })
|
||||
|
||||
expect(await screen.findByText('places.details.noKeyTitle')).toBeInTheDocument()
|
||||
expect(screen.getByText('places.details.noKeyHint')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-028: stays quiet when a key is already configured', async () => {
|
||||
empty()
|
||||
renderColumn({ hasMapsKey: true })
|
||||
|
||||
await screen.findByText('places.details.nothing')
|
||||
expect(await screen.findByText('places.details.nothing')).toBeInTheDocument()
|
||||
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('places.details.noKeyHint')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PDC-029: stays quiet when the free sources did find something', async () => {
|
||||
it('FE-PDC-028: stays quiet when the free sources did find something', async () => {
|
||||
placeEnrichment.mockResolvedValue({
|
||||
photos: [],
|
||||
facts: [],
|
||||
description: { text: 'Ein Museum.', source: 'wikipedia', sourceUrl: null, license: 'CC BY-SA 4.0' },
|
||||
})
|
||||
renderColumn({ hasMapsKey: false })
|
||||
renderColumn()
|
||||
|
||||
await screen.findByText('Ein Museum.')
|
||||
expect(screen.queryByText('places.details.noKeyTitle')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('places.details.nothing')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
/**
|
||||
|
||||
@@ -10,13 +10,11 @@ import {
|
||||
ChevronUp,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
ImageOff,
|
||||
Landmark,
|
||||
Leaf,
|
||||
Loader2,
|
||||
ScrollText,
|
||||
ShoppingBag,
|
||||
Sparkles,
|
||||
Sprout,
|
||||
Star,
|
||||
Sun,
|
||||
@@ -27,6 +25,7 @@ import { mapsApi } from '../../api/client'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
|
||||
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import type { TranslationFn } from '../../types'
|
||||
|
||||
/** The place the column is describing. Null while nothing is selected. */
|
||||
@@ -53,7 +52,6 @@ interface PlaceDetailsColumnProps {
|
||||
/** For grouping the rating count's digits. */
|
||||
locale?: string
|
||||
/** False on an instance with no Google key, which is most of them. */
|
||||
hasMapsKey?: boolean
|
||||
t: TranslationFn
|
||||
}
|
||||
|
||||
@@ -119,7 +117,6 @@ export default function PlaceDetailsColumn({
|
||||
language,
|
||||
timeFormat = '24h',
|
||||
locale = 'en-US',
|
||||
hasMapsKey = false,
|
||||
t,
|
||||
}: PlaceDetailsColumnProps): React.ReactElement {
|
||||
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
|
||||
@@ -208,7 +205,17 @@ export default function PlaceDetailsColumn({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5">
|
||||
{!selection && <p className="text-caption text-content-muted">{t('places.details.empty')}</p>}
|
||||
{/* Nothing picked yet, which is a state rather than a fault: the mascot
|
||||
waits with an idle look instead of a sad one. */}
|
||||
{!selection && (
|
||||
<EmptyState
|
||||
scene="idle"
|
||||
title={t('places.details.empty')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{selection && state === 'loading' && (
|
||||
<div className="flex items-center gap-2 text-caption text-content-muted">
|
||||
@@ -223,17 +230,20 @@ export default function PlaceDetailsColumn({
|
||||
<p className="text-caption text-content-muted">{t('places.details.disabled')}</p>
|
||||
)}
|
||||
|
||||
{/* The mascot rather than a line of grey text and, under it, a paragraph
|
||||
telling the reader to go and ask an administrator for a Google key.
|
||||
Nothing came back for this place, which is worth saying once and
|
||||
plainly; whose key is missing is not this panel's business, and on a
|
||||
place the free sources describe fine it read as an advert. */}
|
||||
{isEmpty && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-caption text-content-muted">
|
||||
<ImageOff className="w-4 h-4 shrink-0" />
|
||||
{t('places.details.nothing')}
|
||||
</div>
|
||||
{/* Only when both are true. With a key configured there is nothing
|
||||
to suggest, and on a place the free sources DID describe the
|
||||
suggestion would be an advert. */}
|
||||
{!hasMapsKey && <NoKeyHint t={t} />}
|
||||
</div>
|
||||
<EmptyState
|
||||
scene="search"
|
||||
mood="sad"
|
||||
title={t('places.details.nothing')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
)}
|
||||
|
||||
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
|
||||
@@ -379,6 +389,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'
|
||||
@@ -431,19 +472,6 @@ function PhotoCredit({ photo }: { photo: PlacePhotoCandidate }): React.ReactElem
|
||||
* It also names who to ask: the key is an instance-wide setting, so on most
|
||||
* installs the person reading this cannot act on it themselves.
|
||||
*/
|
||||
function NoKeyHint({ t }: { t: TranslationFn }): React.ReactElement {
|
||||
return (
|
||||
<div className="rounded-xl border border-accent/25 bg-accent-subtle p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-accent-text">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
</span>
|
||||
<p className="text-caption font-semibold text-content">{t('places.details.noKeyTitle')}</p>
|
||||
</div>
|
||||
<p className="mt-2 text-caption leading-relaxed text-content-secondary">{t('places.details.noKeyHint')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The star rating, as stars.
|
||||
@@ -659,14 +687,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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user