diff --git a/.gitattributes b/.gitattributes index b88429c87..36b00d92f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -26,3 +26,5 @@ *.eot binary *.pdf binary *.zip binary +# A GPX fixture whose byte-order mark and CRLF line endings are what it tests +server/tests/fixtures/gpx/bom-crlf.gpx -text diff --git a/.github/workflows/docker-dev.yml b/.github/workflows/docker-dev.yml index 1f9495a30..b8ff35a65 100644 --- a/.github/workflows/docker-dev.yml +++ b/.github/workflows/docker-dev.yml @@ -132,7 +132,8 @@ jobs: with: path: /tmp/digests pattern: digests-* - merge-multiple: true + # Deliberately NOT merged: the per-platform folder names are what lets + # the manifest below be assembled in a fixed order. - uses: docker/setup-buildx-action@v3 @@ -144,9 +145,24 @@ jobs: - name: Create and push multi-arch manifest working-directory: /tmp/digests run: | + set -euo pipefail VERSION="${{ needs.version-bump.outputs.version }}" - mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *) MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)-pre" + # amd64 first, always. This used to be a glob over hash-named files, so + # the order changed from one release to the next: 3.4.1 listed amd64 + # first and 4.0.0 listed arm64 first. Clients that read the first entry + # of the index rather than matching their own platform then compare the + # wrong digest and report "no update" — which is what Unraid users hit + # on :latest after 4.0.0 shipped. The image was fine; the order was not. + digests=() + for platform in linux-amd64 linux-arm64; do + dir="digests-$platform" + [ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; } + for f in "$dir"/*; do + [ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; } + digests+=("mauriceboe/trek@sha256:$(basename "$f")") + done + done docker buildx imagetools create \ -t "mauriceboe/trek:latest-pre" \ -t "mauriceboe/trek:$MAJOR_TAG" \ diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 50d8751e3..34dfee87e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -176,7 +176,8 @@ jobs: with: path: /tmp/digests pattern: digests-* - merge-multiple: true + # Deliberately NOT merged: the per-platform folder names are what lets + # the manifest below be assembled in a fixed order. - uses: docker/setup-buildx-action@v3 @@ -188,9 +189,24 @@ jobs: - name: Create and push multi-arch manifest working-directory: /tmp/digests run: | + set -euo pipefail VERSION="${{ needs.version-bump.outputs.version }}" - mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *) MAJOR_TAG="$(echo "$VERSION" | cut -d. -f1)" + # amd64 first, always. This used to be a glob over hash-named files, so + # the order changed from one release to the next: 3.4.1 listed amd64 + # first and 4.0.0 listed arm64 first. Clients that read the first entry + # of the index rather than matching their own platform then compare the + # wrong digest and report "no update" — which is what Unraid users hit + # on :latest after 4.0.0 shipped. The image was fine; the order was not. + digests=() + for platform in linux-amd64 linux-arm64; do + dir="digests-$platform" + [ -d "$dir" ] || { echo "::error::missing digest artifact for $platform"; exit 1; } + for f in "$dir"/*; do + [ -e "$f" ] || { echo "::error::no digest file in $dir"; exit 1; } + digests+=("mauriceboe/trek@sha256:$(basename "$f")") + done + done docker buildx imagetools create \ -t "mauriceboe/trek:latest" \ -t "mauriceboe/trek:$MAJOR_TAG" \ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 293d8fd92..4d7a54e81 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,14 +30,15 @@ permissions: # Automatic Analysis must stay disabled on sonarcloud.io — it is mutually # exclusive with CI-based analysis, and it cannot import the lcov coverage # this workflow produces. +# A push or PR that touches none of these paths can't change the tests or the +# analysis, so don't spend the coverage fan-out on it. For a docs-only push +# that means no run and no fresh branch analysis — harmless, since the last +# analysis is still accurate for unchanged code. GitHub Actions doesn't +# support YAML anchors, so the two lists are literal copies: keep them +# identical when editing either one. on: push: branches: [main, dev] - pull_request: - branches: [main, dev] - # A PR that touches none of these can't change the tests or the analysis, - # so don't spend the coverage fan-out on it. Pushes stay unfiltered so the - # branch view is always refreshed from the merge result. paths: - 'server/**' - 'client/**' @@ -48,10 +49,42 @@ on: # a native binding like better-sqlite3 is exactly what needs the suite. - 'package.json' - 'package-lock.json' - - 'Dockerfile' - - 'docker-compose.yml' - 'sonar-project.properties' - - '.github/workflows/sonarqube.yml' + - '.github/workflows/test.yml' + # The image's start-up shape is pinned by a unit test + # (server/tests/unit/docker-entrypoint.test.ts), so a change to the + # Dockerfile or to the entrypoint script has to run the suite — that is + # exactly the change the test exists to catch. + - 'Dockerfile' + - 'server/scripts/**' + # Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect + # neither the tests nor the scan — no test reads checked-in markdown + # and SonarCloud has no markdown analyzer. Negations must stay last. + - '!**/*.md' + pull_request: + branches: [main, dev] + paths: + - 'server/**' + - 'client/**' + - 'shared/**' + - 'plugin-sdk/**' + # npm workspaces hoist every dependency into the single root lockfile, + # so a dependency bump touches none of the four directories above — and + # a native binding like better-sqlite3 is exactly what needs the suite. + - 'package.json' + - 'package-lock.json' + - 'sonar-project.properties' + - '.github/workflows/test.yml' + # The image's start-up shape is pinned by a unit test + # (server/tests/unit/docker-entrypoint.test.ts), so a change to the + # Dockerfile or to the entrypoint script has to run the suite — that is + # exactly the change the test exists to catch. + - 'Dockerfile' + - 'server/scripts/**' + # Docs inside the workspaces (CLAUDE.md, READMEs, PATTERN.md) affect + # neither the tests nor the scan — no test reads checked-in markdown + # and SonarCloud has no markdown analyzer. Negations must stay last. + - '!**/*.md' workflow_dispatch: # A newer push to the same branch/PR supersedes the running one — cancel it diff --git a/CLAUDE.md b/CLAUDE.md index 9fa2d44b9..49dfc82f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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=`, 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//`. `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//` (`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//`. -- **`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//`): `.controller.ts` + `.service.ts` + `.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//.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('')` 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`), 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('')` 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 `.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()` 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()` 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//`, rebuild shared, then both server (validation + DTO types) and client (typed requests) pick it up. -- **i18n locale files live in `shared/src/i18n//`**, 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//`, 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/` ↔ `client/src/mobile/screens/.../M`) 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. diff --git a/Dockerfile b/Dockerfile index aa603f0d6..01d6f0ab6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,13 +20,15 @@ WORKDIR /app COPY package.json package-lock.json ./ COPY shared/package.json ./shared/ COPY client/package.json ./client/ +COPY client/scripts/patch-maplibre.mjs ./client/scripts/ RUN npm ci --workspace=client COPY --from=shared-builder /app/shared/dist ./shared/dist COPY client/ ./client/ RUN npm run build --workspace=client # ── Stage 3: server ────────────────────────────────────────────────────────── -# --ignore-scripts skips native builds (better-sqlite3); they happen in the production stage. +# --ignore-scripts: nothing here needs a postinstall, tsc only reads types. The one +# native module, better-sqlite3, ships its binaries inside the npm tarball since 13.x. FROM node:24-alpine AS server-builder WORKDIR /app COPY package.json package-lock.json ./ @@ -51,17 +53,27 @@ COPY server/package.json ./server/ # changeset, so it costs nothing. Everything copied after this point carries # --chown=node:node for the same reason — a recursive chown in a later layer # would copy up every inode it touches and duplicate the whole tree in the image. +# +# No compiler in this stage. better-sqlite3 13.x carries prebuilt binaries for +# amd64 and arm64 inside its tarball, so there is nothing left to build: the old +# python3 + build-essential fallback compiled it against whatever Node headers +# the base image had that day, and 24.19+ headers give an addon that aborts with +# "Assertion failed: (env) != nullptr" the first time a statement is collected +# (#2421). --ignore-scripts because a lockfile-driven npm ci still synthesises +# `node-gyp rebuild` for the binding.gyp it finds, blind to the package's +# gypfile:false (npm/cli#9837). The only other install scripts among the runtime +# deps are esbuild's binary check, redundant next to its optional platform +# package, and a telemetry ping. RUN apt-get update && \ - apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential \ - libkitinerary-bin && \ - npm ci --workspace=server --omit=dev && \ + apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates \ + libkitinerary-bin libsqlite3-0 && \ + npm ci --workspace=server --omit=dev --ignore-scripts && \ ln -sf "$(find /usr/lib -name kitinerary-extractor -type f | head -1)" /usr/local/bin/kitinerary-extractor; \ - apt-get purge -y python3 build-essential && \ - apt-get autoremove -y && \ rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx && \ chown -R node:node /app -# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node. +# gosu rebuilt with a current Go toolchain (stage 0) — the entrypoint script uses +# it to drop to node. COPY --from=gosu-build /out/gosu /usr/local/bin/gosu ENV XDG_CACHE_HOME=/tmp/kf6-cache @@ -116,9 +128,14 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ CMD wget -qO- http://localhost:3000/api/health || exit 1 +# Start-up lives in a script, not an inline `sh -c` string: container management +# UIs re-tokenise Config.Cmd when you edit a container in place, and a command +# carrying quotes does not survive that round-trip (the container then dies with +# a shell syntax error before Node starts). A CMD of one bare token does. +COPY server/scripts/entrypoint.sh /usr/local/bin/trek-entrypoint +# Root-owned on purpose — this is what calls gosu, so it runs before the drop to +# node. chmod is explicit because a checkout on Windows carries no exec bit. +RUN chmod 0755 /usr/local/bin/trek-entrypoint + ENTRYPOINT ["dumb-init", "--"] -# Preflight: if the app code is missing, a volume was almost certainly mounted -# over /app (it hides the image's node_modules + dist). Fail with actionable -# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'". -# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly. -CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"] +CMD ["/usr/local/bin/trek-entrypoint"] diff --git a/README.md b/README.md index 3d25c2354..4ed6050db 100644 --- a/README.md +++ b/README.md @@ -365,6 +365,16 @@ Caddy handles TLS and WebSockets automatically. Every variable, its default and what it does: see [Environment Variables](https://github.com/liketrek/TREK/wiki/Environment-Variables). +## Star History + + + + + + Star History Chart + + + ## Data sources The Atlas map's country and sub-national (province/county) boundaries come from diff --git a/charts/README.md b/charts/README.md index 77feeaadf..32f981f5e 100644 --- a/charts/README.md +++ b/charts/README.md @@ -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,8 +46,10 @@ 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. +- Set `env.ALLOW_LINK_LOCAL_IPS` to a single link-local address (e.g. `"169.254.1.2"`) only when a service TREK needs sits behind one, such as a host gateway. Cloud metadata addresses (`169.254.169.x`, `169.254.170.x`) cannot be listed. - `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. - Set `env.TRUST_PROXY: "1"` (or the number of proxy hops) when running behind ingress or a load balancer. Required for `FORCE_HTTPS` to detect the forwarded protocol correctly. In production it defaults to `1` automatically. - `COOKIE_SECURE` is auto-derived (on when `NODE_ENV=production` or `FORCE_HTTPS=true`). Set `env.COOKIE_SECURE: "false"` only during local testing without TLS. **Not recommended for production.** diff --git a/charts/trek/Chart.yaml b/charts/trek/Chart.yaml index 3870c27bf..726979bb2 100644 --- a/charts/trek/Chart.yaml +++ b/charts/trek/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 name: trek -version: 3.4.1 +version: 4.3.1 description: Minimal Helm chart for TREK app -appVersion: "3.4.1" +appVersion: "4.3.1" diff --git a/charts/trek/templates/configmap.yaml b/charts/trek/templates/configmap.yaml index d6d6e8c86..a7397e76f 100644 --- a/charts/trek/templates/configmap.yaml +++ b/charts/trek/templates/configmap.yaml @@ -43,6 +43,9 @@ data: {{- if .Values.env.ALLOW_INTERNAL_NETWORK }} ALLOW_INTERNAL_NETWORK: {{ .Values.env.ALLOW_INTERNAL_NETWORK | quote }} {{- end }} + {{- if .Values.env.ALLOW_LINK_LOCAL_IPS }} + ALLOW_LINK_LOCAL_IPS: {{ .Values.env.ALLOW_LINK_LOCAL_IPS | quote }} + {{- end }} {{- if .Values.env.OIDC_ISSUER }} OIDC_ISSUER: {{ .Values.env.OIDC_ISSUER | quote }} {{- end }} @@ -73,9 +76,24 @@ data: {{- if .Values.env.MCP_RATE_LIMIT }} MCP_RATE_LIMIT: {{ .Values.env.MCP_RATE_LIMIT | quote }} {{- end }} + {{- if .Values.env.NOMINATIM_URL }} + NOMINATIM_URL: {{ .Values.env.NOMINATIM_URL | quote }} + {{- end }} {{- if .Values.env.OVERPASS_URL }} OVERPASS_URL: {{ .Values.env.OVERPASS_URL | quote }} {{- end }} {{- if .Values.env.OVERPASS_TIMEOUT_MS }} OVERPASS_TIMEOUT_MS: {{ .Values.env.OVERPASS_TIMEOUT_MS | quote }} {{- end }} + {{- /* Tested for presence rather than truth: the one value this switch gets set to is + false, and an unquoted false (or --set env.TREK_PLACES_ENABLED=false) would + otherwise drop the key and leave the index on. */}} + {{- if not (kindIs "invalid" .Values.env.TREK_PLACES_ENABLED) }} + TREK_PLACES_ENABLED: {{ .Values.env.TREK_PLACES_ENABLED | quote }} + {{- end }} + {{- if .Values.env.TREK_PLACES_URL }} + TREK_PLACES_URL: {{ .Values.env.TREK_PLACES_URL | quote }} + {{- end }} + {{- if .Values.env.AMAP_API_BASE }} + AMAP_API_BASE: {{ .Values.env.AMAP_API_BASE | quote }} + {{- end }} diff --git a/charts/trek/templates/deployment.yaml b/charts/trek/templates/deployment.yaml index 6727b683f..24e67325c 100644 --- a/charts/trek/templates/deployment.yaml +++ b/charts/trek/templates/deployment.yaml @@ -75,23 +75,31 @@ spec: name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} key: UNSPLASH_ACCESS_KEY optional: true + - name: AMAP_API_KEY + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: AMAP_API_KEY + optional: true + - name: AMAP_API_SECRET + valueFrom: + secretKeyRef: + name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }} + key: AMAP_API_SECRET + optional: true volumeMounts: - name: data 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 }} diff --git a/charts/trek/templates/secret.yaml b/charts/trek/templates/secret.yaml index 08d81cabd..c465e8e34 100644 --- a/charts/trek/templates/secret.yaml +++ b/charts/trek/templates/secret.yaml @@ -20,6 +20,12 @@ data: {{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }} UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY | b64enc | quote }} {{- end }} + {{- if .Values.secretEnv.AMAP_API_KEY }} + AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY | b64enc | quote }} + {{- end }} + {{- if .Values.secretEnv.AMAP_API_SECRET }} + AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET | b64enc | quote }} + {{- end }} {{- end }} {{- if and (not .Values.existingSecret) (.Values.generateEncryptionKey) }} @@ -50,4 +56,10 @@ stringData: {{- if .Values.secretEnv.UNSPLASH_ACCESS_KEY }} UNSPLASH_ACCESS_KEY: {{ .Values.secretEnv.UNSPLASH_ACCESS_KEY }} {{- end }} + {{- if .Values.secretEnv.AMAP_API_KEY }} + AMAP_API_KEY: {{ .Values.secretEnv.AMAP_API_KEY }} + {{- end }} + {{- if .Values.secretEnv.AMAP_API_SECRET }} + AMAP_API_SECRET: {{ .Values.secretEnv.AMAP_API_SECRET }} + {{- end }} {{- end }} diff --git a/charts/trek/values.yaml b/charts/trek/values.yaml index 4af6bd9ba..0d4bb1aeb 100644 --- a/charts/trek/values.yaml +++ b/charts/trek/values.yaml @@ -54,6 +54,9 @@ env: # ALLOW_INTERNAL_NETWORK: "false" # Set to "true" if Immich or other integrated services are hosted on a private/RFC-1918 network address. # Loopback (127.x) and link-local/metadata addresses (169.254.x) are always blocked. + # ALLOW_LINK_LOCAL_IPS: "" + # Comma-separated single link-local addresses TREK may reach after all, e.g. "169.254.1.2" for a host gateway. + # 169.254.169.x and 169.254.170.x (cloud metadata) cannot be listed; an invalid entry aborts startup. # OIDC_ISSUER: "" # OpenID Connect provider URL. # OIDC_CLIENT_ID: "" @@ -78,12 +81,33 @@ env: # Max MCP API requests per user per minute. Defaults to 300. # MCP_MAX_SESSION_PER_USER: "20" # Max concurrent MCP sessions per user. Defaults to 20. + # NOMINATIM_URL: "" + # Custom Nominatim base URL for every geocoding call the server makes (place search, reverse lookup, Atlas region + # fill). When set it REPLACES the public OpenStreetMap service, with no fallback, so the instance behind it needs + # the coverage you expect and has to speak the Nominatim API. TREK keeps its ~1 request per second spacing either + # way. Defaults to https://nominatim.openstreetmap.org. # OVERPASS_URL: "" # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled # public mirrors — point it at an internal/self-hosted Overpass instance when the public mirrors are unreachable # from the cluster (e.g. locked-down egress). Non-http(s) entries are ignored. - # OVERPASS_TIMEOUT_MS: "12000" - # Per-endpoint timeout (ms) for Overpass POI requests. Raise it for a slow self-hosted Overpass instance. Defaults to 12000. + # OVERPASS_TIMEOUT_MS: "25000" + # Per-endpoint timeout (ms) for Overpass POI requests. The query itself gives Overpass 20 seconds of work, so a + # lower value hangs up on answers that were still coming. Raise it for a slow self-hosted Overpass instance. + # Defaults to 25000. + # TREK_PLACES_ENABLED: "false" + # Place search (suggestions, full search, category buttons, Road trip search, import geocoding, offline downloads) + # asks the TREK Places API at https://places.liketrek.com by default, so the pod makes outbound requests to that + # host. Set "false" to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send + # nothing there. There is no switch in the admin panel on purpose: whether searches leave the instance is decided + # here. Defaults to on. + # TREK_PLACES_URL: "" + # Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from + # configuration rather than typed by a user, so an in-cluster address works without ALLOW_INTERNAL_NETWORK. + # Defaults to https://places.liketrek.com. + # AMAP_API_BASE: "" + # Send the Amap (高德地图) calls through an egress proxy, a cache or a gateway that speaks the same API instead of + # restapi.amap.com directly. The key itself is a credential and goes under secretEnv.AMAP_API_KEY, or into + # Admin > Settings > API Keys. Defaults to https://restapi.amap.com. # Secret environment variables stored in a Kubernetes Secret. @@ -109,6 +133,15 @@ secretEnv: # Get a free key at https://unsplash.com/developers. Can also be set per-admin # in Admin > Settings; this value overrides that. Leave empty to disable. UNSPLASH_ACCESS_KEY: "" + # Optional Amap (高德地图) Web 服务 key for place search and geocoding inside mainland + # China. Overrides the key set in Admin > Settings > API Keys. A Web 端 (JS API) key is + # a different credential and is rejected with INVALID_USER_KEY. Leave empty to use the + # key from the admin panel, or none. + AMAP_API_KEY: "" + # The private secret (数字签名) of that key. Required exactly when the key was created + # with signing enabled, because such a key rejects every unsigned request. Leave empty + # otherwise. The endpoint itself is not a secret and is set under env.AMAP_API_BASE. + AMAP_API_SECRET: "" # If true, a random ENCRYPTION_KEY is generated at install and preserved across upgrades generateEncryptionKey: false @@ -134,6 +167,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 diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 57337de94..49fcc8518 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -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()` hook** under `src/pages//`. The page body must **not** call `useState/useReducer/useEffect/useLayoutEffect/useMemo/useCallback/useRef` — only context hooks like `useTranslation()` are allowed. Optional `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()` hook** under `src/pages//`. The page body must **not** call React state/effect/memo/ref hooks — only context hooks like `useTranslation()`. An optional `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/.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/.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()` 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 `` 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 `` 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/')`** 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//` (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` / ``; 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/')` 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`). diff --git a/client/e2e/ipad-scroll-1432.spec.ts b/client/e2e/ipad-scroll-1432.spec.ts index 55631b4ce..c7cc854a9 100644 --- a/client/e2e/ipad-scroll-1432.spec.ts +++ b/client/e2e/ipad-scroll-1432.spec.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() }) diff --git a/client/e2e/mobile-day-swipe-2051.spec.ts b/client/e2e/mobile-day-swipe-2051.spec.ts new file mode 100644 index 000000000..9d3999c4b --- /dev/null +++ b/client/e2e/mobile-day-swipe-2051.spec.ts @@ -0,0 +1,98 @@ +import { test, expect, devices } from '@playwright/test' +import type { Page } from '@playwright/test' +import { dismissSystemNotices } from './helpers' + +// Phone coverage for #2051: the day details area steps days under a horizontal +// swipe, so the trip is navigable one-handed instead of only from the chip rail +// pinned to the top of the screen. +// +// jsdom can drive the handlers but not the browser around them: whether the +// gesture survives real touch dispatch, whether the browser hands the vertical +// axis to its own scroller first, and whether a swipe at the first day stays put +// are all engine behaviour. This drives CDP's Input.dispatchTouchEvent, so the +// events are the ones a finger produces — not synthesised TouchEvent objects. +// +// Chromium, because CDP is where real touch injection lives. The file-level +// device sets a phone viewport with a coarse pointer, which is what puts the +// mobile shell on screen at all. +test.use({ ...devices['Pixel 7'] }) + +/** One finger, dispatched through the browser's own input pipeline. */ +async function swipe(page: Page, from: { x: number; y: number }, to: { x: number; y: number }, steps = 8) { + const cdp = await page.context().newCDPSession(page) + await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: from.x, y: from.y }] }) + for (let i = 1; i <= steps; i++) { + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ + x: from.x + ((to.x - from.x) * i) / steps, + y: from.y + ((to.y - from.y) * i) / steps, + }], + }) + } + await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) + await cdp.detach() + // The out leg is 150ms and the in leg 220ms. + await page.waitForTimeout(500) +} + +const activeChip = (page: Page) => page.locator('button[aria-current="true"]') + +test('#2051 phone: the day details area swipes between days', async ({ page }) => { + await page.goto('/dashboard') + await dismissSystemNotices(page) + + // Without a phone viewport and a coarse pointer the mobile shell never mounts, + // and everything below would be proving nothing. + const env = await page.evaluate(() => ({ + width: window.innerWidth, + coarse: window.matchMedia('(pointer: coarse)').matches, + })) + expect(env.width, 'Pixel sits below the 768px phone breakpoint').toBeLessThan(768) + expect(env.coarse, 'Pixel reports a coarse primary pointer').toBe(true) + + const stamp = Date.now() + const created = await page.request.post('/api/trips', { + data: { title: `Day swipe ${stamp}`, start_date: '2099-06-01', end_date: '2099-06-05' }, + }) + expect(created.ok(), 'seed trip').toBeTruthy() + const tripId = (await created.json()).trip?.id ?? (await created.json()).id + + await page.goto(`/trips/${tripId}`) + await expect(activeChip(page)).toBeVisible({ timeout: 30_000 }) + + const chips = page.locator('button[aria-current], button[aria-current="true"]') + const rail = activeChip(page).locator('xpath=..') + expect(await rail.locator('button').count(), 'the seeded trip has five days').toBe(5) + expect(await chips.count()).toBeGreaterThan(0) + + // The swipe zone: below the chip rail, above the dock, across the middle. + const mid = Math.round(env.width / 2) + const y = 460 + + const day1 = (await activeChip(page).textContent())!.trim() + + // 1. A swipe left steps forward. + await swipe(page, { x: mid + 90, y }, { x: mid - 90, y }) + const day2 = (await activeChip(page).textContent())!.trim() + expect(day2, 'swiping left moved to the next day').not.toBe(day1) + + // 2. A swipe right steps back to where it started. + await swipe(page, { x: mid - 90, y }, { x: mid + 90, y }) + expect((await activeChip(page).textContent())!.trim()).toBe(day1) + + // 3. The first day has nowhere to go back to — it must stay put, and the + // browser must not have been handed a page-back either. + await swipe(page, { x: mid - 90, y }, { x: mid + 90, y }) + expect((await activeChip(page).textContent())!.trim()).toBe(day1) + expect(page.url()).toContain(`/trips/${tripId}`) + + // 4. A vertical drag belongs to the card's own scroller, never to the day. + await swipe(page, { x: mid, y: y + 120 }, { x: mid, y: y - 120 }) + expect((await activeChip(page).textContent())!.trim()).toBe(day1) + + // 5. And the rail itself still works, so the gesture added a path rather than + // replacing one. + await rail.locator('button').nth(2).click() + await expect(activeChip(page)).not.toHaveText(day1) +}) diff --git a/client/package.json b/client/package.json index 6e091f288..9afe4e9f7 100644 --- a/client/package.json +++ b/client/package.json @@ -1,11 +1,11 @@ { "name": "@trek/client", - "version": "3.4.1", + "version": "4.3.1", "private": true, "type": "module", "scripts": { "dev": "vite", - "prebuild": "node scripts/generate-icons.mjs", + "prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs", "icons:native": "node scripts/generate-native-icons.mjs", "build": "vite build", "build:analyze": "vite build --mode analyze", @@ -27,7 +27,9 @@ "shots:promote": "node e2e/screenshots/promote.mjs", "e2e:report": "playwright show-report", "format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"", - "format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"" + "format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"", + "postinstall": "node scripts/patch-maplibre.mjs", + "predev": "node scripts/patch-maplibre.mjs" }, "dependencies": { "@capacitor/android": "^8.5.0", @@ -59,6 +61,8 @@ "@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": "*", "axios": "^1.6.7", @@ -69,7 +73,7 @@ "leaflet": "^1.9.4", "lucide-react": "^0.344.0", "mapbox-gl": "^3.22.0", - "maplibre-gl": "^5.24.0", + "maplibre-gl": "5.24.0", "marked": "^18.0.0", "plyr": "^3.8.4", "react": "^19.2.6", @@ -100,7 +104,7 @@ "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", - "@vitest/coverage-v8": "^4.1.9", + "@vitest/coverage-v8": "^4.1.11", "autoprefixer": "^10.4.18", "eslint": "^10.2.1", "eslint-config-flat-gitignore": "^2.3.0", @@ -115,12 +119,12 @@ "prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-tailwindcss": "^0.8.0", "rollup-plugin-visualizer": "^7.0.1", - "sharp": "^0.35.0", + "sharp": "^0.35.4", "tailwindcss": "^3.4.1", "typescript": "^6.0.2", "typescript-eslint": "^8.58.2", "vite": "8.1.0", "vite-plugin-pwa": "^1.3.0", - "vitest": "^4.1.9" + "vitest": "^4.1.11" } } diff --git a/client/public/brands/airtrail.svg b/client/public/brands/airtrail.svg new file mode 100644 index 000000000..6349a0370 --- /dev/null +++ b/client/public/brands/airtrail.svg @@ -0,0 +1,11 @@ + + + + + + + + + \ No newline at end of file diff --git a/client/public/brands/dawarich.svg b/client/public/brands/dawarich.svg new file mode 100644 index 000000000..cfe2843ee --- /dev/null +++ b/client/public/brands/dawarich.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/public/images/portals/check24.png b/client/public/images/portals/check24.png new file mode 100644 index 000000000..ef931c5da Binary files /dev/null and b/client/public/images/portals/check24.png differ diff --git a/client/public/images/portals/pincamp.png b/client/public/images/portals/pincamp.png new file mode 100644 index 000000000..d878e863a Binary files /dev/null and b/client/public/images/portals/pincamp.png differ diff --git a/client/public/images/portals/pitchup.ico b/client/public/images/portals/pitchup.ico new file mode 100644 index 000000000..24b7881cd Binary files /dev/null and b/client/public/images/portals/pitchup.ico differ diff --git a/client/public/images/portals/trivago.png b/client/public/images/portals/trivago.png new file mode 100644 index 000000000..1eb2abb50 Binary files /dev/null and b/client/public/images/portals/trivago.png differ diff --git a/client/rtlTextAlias.js b/client/rtlTextAlias.js new file mode 100644 index 000000000..9709c55b1 --- /dev/null +++ b/client/rtlTextAlias.js @@ -0,0 +1,39 @@ +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', + ), +}; + +/** + * Same shape for Plyr's icon sprite. The package publishes its JS and CSS entries + * and nothing else, so `plyr/dist/plyr.svg` is refused by the resolver although + * the file is right there. The player has to point at a copy on this origin + * (#2341): its default is the sprite on cdn.plyr.io, which the CSP refuses. + */ +export const plyrSpriteAlias = { + find: /^plyr\/dist\/plyr\.svg/, + replacement: path.join( + path.dirname(createRequire(import.meta.url).resolve('plyr')), + 'plyr.svg', + ), +}; diff --git a/client/scripts/generate-icons.mjs b/client/scripts/generate-icons.mjs index 71faed966..0c1b808e2 100644 --- a/client/scripts/generate-icons.mjs +++ b/client/scripts/generate-icons.mjs @@ -26,4 +26,25 @@ for (const { name, size } of sizes) { console.log(` \u2713 ${name} (${size}x${size})`); } +// Maskable variants: Android lays a maskable icon edge to edge under the round +// launcher mask, so the glyph has to sit inside the safe zone (a circle of 80% +// of the canvas). The master SVG draws the glyph nearly full bleed; shrink and +// recenter it, keeping the brand gradient as the full background. +const maskableSvg = svgBuffer + .toString() + .replace('translate(56,51) scale(0.267)', 'translate(81,81) scale(0.234)'); + +const maskableSizes = [ + { name: 'icon-maskable-192x192.png', size: 192 }, + { name: 'icon-maskable-512x512.png', size: 512 }, +]; + +for (const { name, size } of maskableSizes) { + await sharp(Buffer.from(maskableSvg), { density: 300 }) + .resize(size, size) + .png({ compressionLevel: 9 }) + .toFile(join(iconsDir, name)); + console.log(` \u2713 ${name} (${size}x${size}, maskable)`); +} + console.log('PWA icons generated.'); diff --git a/client/scripts/patch-maplibre.mjs b/client/scripts/patch-maplibre.mjs new file mode 100644 index 000000000..d9f99a89d --- /dev/null +++ b/client/scripts/patch-maplibre.mjs @@ -0,0 +1,34 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' + +const require = createRequire(import.meta.url) +const packagePath = require.resolve('maplibre-gl/package.json') +const { version } = JSON.parse(readFileSync(packagePath, 'utf8')) + +if (version !== '5.24.0') throw new Error('Recheck the MapLibre sanitizer backport before changing its version') + +// Backport maplibre-gl-js#8189 without removing WebGL1 support in version 6. +const loops = [ + ['of elem.attributes)', 'of Array.from(elem.attributes))'], + ['of e.attributes)', 'of Array.from(e.attributes))'], +] + +for (const filename of [ + 'src/util/dom.ts', + 'dist/maplibre-gl.js', + 'dist/maplibre-gl-dev.js', + 'dist/maplibre-gl-csp.js', + 'dist/maplibre-gl-csp-dev.js', +]) { + const target = join(dirname(packagePath), filename) + const code = readFileSync(target, 'utf8') + const loop = loops.find(([before, after]) => code.includes(before) || code.includes(after)) + if (!loop) throw new Error(`MapLibre sanitizer loop not found in ${filename}`) + const [before, after] = loop + if (code.split(before).length === 1 && code.split(after).length === 2) continue + if (code.split(before).length !== 2 || code.includes(after)) { + throw new Error(`Unexpected MapLibre sanitizer in ${filename}`) + } + writeFileSync(target, code.replace(before, after)) +} diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index ff9c2fe90..566d1b4e2 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -522,6 +522,103 @@ 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: '', 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: an update path that throws is retried on the next launch, harmlessly (#2228)', async () => { + // A handover that never happened must not be recorded as done, or the + // device stays on the old build for good. The retry this buys has to be + // harmless: the purge that once repeated on every launch is gone, and a + // failure neither deletes anything nor reloads into the old worker's shell. + localStorage.setItem('trek_app_version', '2.9.9') + const reload = vi.fn() + Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } }) + const cachesDelete = vi.fn().mockResolvedValue(true) + const unregister = vi.fn().mockResolvedValue(true) + vi.stubGlobal('caches', { keys: vi.fn().mockResolvedValue(['workbox-precache-v2', 'map-tiles']), delete: cachesDelete }) + const getRegistration = vi.fn().mockRejectedValue(new Error('no worker')) + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { getRegistration, getRegistrations: vi.fn().mockResolvedValue([{ unregister }]), addEventListener: vi.fn() }, + }) + + server.use( + http.get('/api/auth/app-config', () => + HttpResponse.json({ version: '2.9.10', places_provider: 'google' }) + ) + ) + seedAuth() + const first = renderApp('/') + await waitFor(() => expect(getRegistration).toHaveBeenCalledTimes(1)) + await act(async () => { await Promise.resolve() }) + first.unmount() + expect(localStorage.getItem('trek_app_version')).toBe('2.9.9') + + renderApp('/') + await waitFor(() => expect(getRegistration).toHaveBeenCalledTimes(2)) + await act(async () => { await Promise.resolve() }) + expect(localStorage.getItem('trek_app_version')).toBe('2.9.9') + expect(reload).not.toHaveBeenCalled() + expect(cachesDelete).not.toHaveBeenCalled() + expect(unregister).not.toHaveBeenCalled() + // Without a reload the session carries on, so the rest of the config applies. + expect(useAuthStore.getState().placesProvider).toBe('google') + }) }) // Regression: a device that has never mirrored the preference must not treat diff --git a/client/src/App.tsx b/client/src/App.tsx index 523e6150b..0df993c00 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -17,13 +17,15 @@ import MobileShell from './mobile/MobileShell' import MRouteFallback from './mobile/components/MRouteFallback' import ErrorBoundary from './components/shared/ErrorBoundary' import { lazyWithRetry } from './utils/lazyWithRetry' +import { reconcileAppVersion } from './utils/versionHandover' 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' import { useInAppNotificationListener } from './hooks/useInAppNotificationListener.ts' +import { useRoadtripPreferencesSync } from './hooks/useRoadtripPreferencesSync' import { registerSyncTriggers, unregisterSyncTriggers } from './sync/syncTriggers' import OfflineBanner from './components/Layout/OfflineBanner' import { SystemNoticeHost } from './components/SystemNotices/SystemNoticeHost.js' @@ -106,7 +108,7 @@ function ProtectedRoute({ children, adminRequired = false, addonId }: ProtectedR // A session that ended on its own should come back to where it left off; a // deliberate sign-out is a fresh start and gets no return ticket, so the // startup destination decides where the next login lands. - if (loggingOut) return + if (loggingOut) return const redirectParam = encodeURIComponent(location.pathname + location.search + location.hash) return } @@ -283,7 +285,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, setHasAmapKey, setPlacesProvider, setServerTimezone, setAppRequireMfa, setTripRemindersEnabled, setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled } = useAuthStore() const { loadSettings } = useSettingsStore() const { loadAddons } = useAddonStore() const { loadPlugins } = usePluginStore() @@ -300,13 +302,15 @@ 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 }) => { + authApi.getAppConfig().then(async (config: { managed?: boolean; demo_mode?: boolean; dev_mode?: boolean; is_prerelease?: boolean; has_maps_key?: boolean; has_amap_key?: boolean; places_provider?: string; 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 }) => { setManaged(!!config?.managed) setDemoMode(!!config?.demo_mode) if (config?.dev_mode) setDevMode(true) if (config?.is_prerelease !== undefined) setIsPrerelease(config.is_prerelease) if (config?.version) setAppVersion(config.version) if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key) + if (config?.has_amap_key !== undefined) setHasAmapKey(config.has_amap_key) + if (config?.places_provider) setPlacesProvider(config.places_provider) if (config?.timezone) setServerTimezone(config.timezone) if (config?.require_mfa !== undefined) setAppRequireMfa(!!config.require_mfa) if (config?.trip_reminders_enabled !== undefined) setTripRemindersEnabled(config.trip_reminders_enabled) @@ -314,33 +318,17 @@ 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) { - const storedVersion = localStorage.getItem('trek_app_version') - if (storedVersion && storedVersion !== config.version) { - try { - if ('caches' in window) { - const names = await caches.keys() - await Promise.all(names.map(n => caches.delete(n))) - } - 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) - window.location.reload() - return - } - localStorage.setItem('trek_app_version', config.version) - } + // Last, since a new release reloads the page from here. + await reconcileAppVersion(config?.version) }).catch(() => {}) }, []) const { settings } = useSettingsStore() useInAppNotificationListener() + useRoadtripPreferencesSync() useEffect(() => { if (isAuthenticated) { @@ -380,13 +368,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 ( - {!isAuthPage && } + {!hideAuthedWidgets && } - {!isAuthPage && } - {!isAuthPage && (isPhone ? : )} + {!hideAuthedWidgets && } + {!hideAuthedWidgets && (isPhone ? : )} {/* One boundary for all route chunks, above so it stays mounted across navigations. react-router runs location updates inside a transition, diff --git a/client/src/App.versionHandover.test.tsx b/client/src/App.versionHandover.test.tsx new file mode 100644 index 000000000..473a24cf7 --- /dev/null +++ b/client/src/App.versionHandover.test.tsx @@ -0,0 +1,281 @@ +import React from 'react' +import { render, waitFor, act } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +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 { resetAllStores } from '../tests/helpers/store' +import App from './App' + +/* + * #2458: an installed PWA kept serving the previous UI after a server upgrade. + * + * The launch probe compares the server's version with a marker in localStorage + * and, on a mismatch, asks the service worker to fetch the new build and reloads + * once it has taken over. These tests drive that probe across consecutive + * launches against a fake service-worker container, so a handover that never + * completed can be told apart from one that did. + * + * The fake is installed before App is imported (vi.hoisted), so code that + * notes the controlling worker when its module loads sees the worker that + * served this page, exactly as in the browser. + */ +const sw = vi.hoisted(() => { + class FakeWorker extends EventTarget { + state: string + readonly scriptURL = '/sw.js' + constructor(state: string) { + super() + this.state = state + } + become(state: string) { + this.state = state + this.dispatchEvent(new Event('statechange')) + } + } + class FakeRegistration extends EventTarget { + active: FakeWorker | null = null + installing: FakeWorker | null = null + waiting: FakeWorker | null = null + readonly scope = '/' + update = vi.fn() + } + class FakeContainer extends EventTarget { + controller: FakeWorker | null = null + getRegistration = vi.fn() + // Listeners a page adds die with that page. Those added while modules load + // (before `trackPage` is switched on) stand for module-level code and stay. + trackPage = false + private pageListeners: Array<[string, EventListenerOrEventListenerObject, boolean | AddEventListenerOptions | undefined]> = [] + addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) { + super.addEventListener(type, listener, options) + if (this.trackPage && listener) this.pageListeners.push([type, listener, options]) + } + dropPageListeners() { + for (const [type, listener, options] of this.pageListeners) super.removeEventListener(type, listener, options) + this.pageListeners = [] + } + } + const oldWorker = new FakeWorker('activated') + const registration = new FakeRegistration() + const container = new FakeContainer() + registration.active = oldWorker + container.controller = oldWorker + container.getRegistration.mockImplementation(async () => registration) + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container }) + return { FakeWorker, registration, container, oldWorker } +}) + +vi.mock('./pages/LoginPage', () => ({ default: () =>
Login
})) +vi.mock('./pages/DashboardPage', () => ({ default: () =>
Dashboard
})) +vi.mock('./hooks/useInAppNotificationListener.ts', () => ({ useInAppNotificationListener: vi.fn() })) + +// Every import above has been evaluated by now; from here on a listener belongs to a page. +sw.container.trackPage = true + +const reload = vi.fn() +let configHits = 0 + +function serveVersion(version: string, gate?: Promise) { + server.use( + http.get('/api/auth/app-config', async () => { + configHits++ + if (gate) await gate + return HttpResponse.json({ version }) + }), + ) +} + +/** The browser's handover: the new worker installs, activates and claims the page. */ +function handOver() { + const next = new sw.FakeWorker('activated') + sw.registration.installing = null + sw.registration.active = next + sw.container.controller = next + sw.container.dispatchEvent(new Event('controllerchange')) +} + +/** update() finds a new sw.js and starts installing it; `then` decides how the install ends. */ +function updateStartsInstall(then: 'activates' | 'fails') { + sw.registration.update.mockImplementation(async () => { + const next = new sw.FakeWorker('installing') + sw.registration.installing = next + setTimeout(() => { + if (then === 'activates') { + next.become('activated') + handOver() + } else { + // Workbox precaching is all-or-nothing: one failed fetch out of ~463 + // rejects the install event and the new worker is thrown away. + sw.registration.installing = null + next.become('redundant') + } + }, 0) + return sw.registration + }) +} + +/** One cold start of the installed app: fresh page, fresh session, same localStorage. */ +async function launch() { + sessionStorage.clear() + const hitsBefore = configHits + const view = render( + + + , + ) + await waitFor(() => expect(configHits).toBe(hitsBefore + 1)) + // Let the probe, update() and the fake install run to completion. + await act(async () => { await new Promise(r => setTimeout(r, 50)) }) + view.unmount() + sw.container.dropPageListeners() +} + +beforeEach(() => { + resetAllStores() + vi.clearAllMocks() + configHits = 0 + sw.registration.active = sw.oldWorker + sw.registration.installing = null + sw.registration.waiting = null + sw.registration.update.mockReset() + sw.registration.update.mockResolvedValue(sw.registration) + sw.container.controller = sw.oldWorker + sw.container.dropPageListeners() + sw.container.getRegistration.mockReset() + sw.container.getRegistration.mockImplementation(async () => sw.registration) + reload.mockReset() + Object.defineProperty(window, 'location', { writable: true, value: { ...window.location, reload } }) + server.use( + http.get('/api/health/features', () => HttpResponse.json({ bookingImport: false, aiParsing: false })), + http.get('/api/system-notices/active', () => HttpResponse.json([])), + ) + useAuthStore.setState({ + isLoading: false, + isAuthenticated: false, + user: null, + appRequireMfa: false, + loadUser: vi.fn().mockResolvedValue(undefined), + }) +}) + +describe('#2458 version handover: behaviour that must survive the fix', () => { + it('FE-COMP-APP-2458-P1: a confirmed handover reloads once and is not repeated on the next launch', async () => { + localStorage.setItem('trek_app_version', '4.3.0') + serveVersion('4.3.1') + updateStartsInstall('activates') + + await launch() + expect(sw.registration.update).toHaveBeenCalledTimes(1) + expect(reload).toHaveBeenCalledTimes(1) + expect(localStorage.getItem('trek_app_version')).toBe('4.3.1') + + await launch() + expect(sw.registration.update).toHaveBeenCalledTimes(1) + expect(reload).toHaveBeenCalledTimes(1) + }) + + it('FE-COMP-APP-2458-P2: nothing to hand over (sw.js unchanged, same worker in control) neither reloads nor retries', async () => { + // A release that changed nothing the precache holds leaves sw.js byte-identical, + // so update() installs nothing. The bundle this page runs is already current. + localStorage.setItem('trek_app_version', '4.3.0') + serveVersion('4.3.1') + + await launch() + expect(sw.registration.update).toHaveBeenCalledTimes(1) + expect(reload).not.toHaveBeenCalled() + + await launch() + expect(sw.registration.update).toHaveBeenCalledTimes(1) + expect(reload).not.toHaveBeenCalled() + }) + + it.each([ + ['a self-built image without the build arg', 'dev'], + ['a test instance pinned to an older tag', '4.0.0'], + ['a prerelease image, whose bundle keeps the last stable version', '4.4.0-pre.3'], + ])('FE-COMP-APP-2458-P3: %s (server says %s, bundle is built as another version) never loops', async (_label, serverVersion) => { + // The server reports APP_VERSION, the bundle carries client/package.json. On + // these setups the two never agree, so a check against the bundle's own + // version would see an update on every launch. + serveVersion(serverVersion) + + await launch() + await launch() + await launch() + expect(reload).not.toHaveBeenCalled() + expect(sw.registration.update).not.toHaveBeenCalled() + }) +}) + +describe('#2458 version handover: the reported defect', () => { + it('FE-COMP-APP-2458-01: an update() that rejects is retried on the next launch', async () => { + localStorage.setItem('trek_app_version', '4.3.0') + serveVersion('4.3.1') + sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error')) + + await launch() + expect(sw.registration.update).toHaveBeenCalledTimes(1) + + // Next cold start, network fine this time. + updateStartsInstall('activates') + await launch() + expect(sw.registration.update).toHaveBeenCalledTimes(2) + expect(reload).toHaveBeenCalled() + }) + + it('FE-COMP-APP-2458-02: a failed update() does not reload into the shell the same worker serves again', async () => { + // The old worker still controls the page, so a plain reload is answered from + // its precache: the same old UI, now with the marker saying it is current. + localStorage.setItem('trek_app_version', '4.3.0') + serveVersion('4.3.1') + sw.registration.update.mockRejectedValue(new TypeError('Failed to update a ServiceWorker: network error')) + + await launch() + expect(reload).not.toHaveBeenCalled() + }) + + it('FE-COMP-APP-2458-03: an install that goes redundant is retried on the next launch', async () => { + localStorage.setItem('trek_app_version', '4.3.0') + serveVersion('4.3.1') + updateStartsInstall('fails') + + await launch() + expect(sw.registration.update).toHaveBeenCalledTimes(1) + expect(reload).not.toHaveBeenCalled() + + updateStartsInstall('activates') + await launch() + expect(sw.registration.update).toHaveBeenCalledTimes(2) + expect(reload).toHaveBeenCalledTimes(1) + }) + + // Kept last: it swaps the controlling worker mid-launch, which code that + // remembers "the controller changed since this page loaded" keeps seeing. + it('FE-COMP-APP-2458-04: a worker that took over before the probe answered still reloads the page', async () => { + // The browser checks sw.js on every navigation by itself. When that check + // installs and activates the new worker before /api/auth/app-config has + // answered, the takeover happens while nobody listens, and update() then + // finds nothing newer. The page keeps running the bundle the old worker served. + localStorage.setItem('trek_app_version', '4.3.0') + let open!: () => void + const gate = new Promise(r => { open = r }) + serveVersion('4.3.1', gate) + + sessionStorage.clear() + const view = render( + + + , + ) + await waitFor(() => expect(configHits).toBe(1)) + act(() => handOver()) + open() + await act(async () => { await new Promise(r => setTimeout(r, 50)) }) + view.unmount() + sw.container.dropPageListeners() + + expect(reload).toHaveBeenCalledTimes(1) + }) +}) diff --git a/client/src/api/assignmentEndDay.ts b/client/src/api/assignmentEndDay.ts new file mode 100644 index 000000000..ba146d4f7 --- /dev/null +++ b/client/src/api/assignmentEndDay.ts @@ -0,0 +1,7 @@ +import { assignmentSchema, type AssignmentEndDayRequest } from '@trek/shared' +import { apiClient } from './client' + +export async function saveAssignmentEndDay(tripId: number | string, id: number, body: AssignmentEndDayRequest) { + const saved = await apiClient.put(`/trips/${tripId}/assignments/${id}/end-day`, body) + return assignmentSchema.parse(saved.data.assignment) +} diff --git a/client/src/api/client.behavior.test.ts b/client/src/api/client.behavior.test.ts index e33de84f8..087965f83 100644 --- a/client/src/api/client.behavior.test.ts +++ b/client/src/api/client.behavior.test.ts @@ -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 }))) diff --git a/client/src/api/client.surface.test.ts b/client/src/api/client.surface.test.ts index bca591154..b5076504c 100644 --- a/client/src/api/client.surface.test.ts +++ b/client/src/api/client.surface.test.ts @@ -1,4 +1,4 @@ -// FE-APISURF-001 to FE-APISURF-054 +// FE-APISURF-001 to FE-APISURF-058 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import type { AxiosResponse } from 'axios' import { http, HttpResponse } from 'msw' @@ -10,6 +10,7 @@ import { mapsApi, airportsApi, budgetApi, filesApi, reservationsApi, healthApi, weatherApi, configApi, helpApi, settingsApi, accommodationsApi, dayNotesApi, collabApi, backupApi, shareApi, transitApi, tripInviteApi, notificationsApi, inAppNotificationsApi, memoriesApi, + docsyncApi, DOCSYNC_RUN_TIMEOUT_MS, DOCSYNC_UPSTREAM_TIMEOUT_MS, } from './client' interface Recorded { method: string; url: string; body: unknown } @@ -301,6 +302,9 @@ describe('client > endpoint wiring', () => { { n: 'pluginRescan', r: () => adminApi.pluginRescan(), e: 'POST /api/admin/plugins/rescan' }, { n: 'pluginLink', r: () => adminApi.pluginLink('/srv/plugin'), e: 'POST /api/admin/plugins/link' }, { n: 'pluginReload', r: () => adminApi.pluginReload('koffi'), e: 'POST /api/admin/plugins/koffi/reload' }, + { n: 'pluginConfig', r: () => adminApi.pluginConfig('koffi'), e: 'GET /api/admin/plugins/koffi/config' }, + { n: 'pluginSaveConfig', r: () => adminApi.pluginSaveConfig('koffi', { apiUrl: 'x' }), e: 'PUT /api/admin/plugins/koffi/config' }, + { n: 'runPluginAction', r: () => adminApi.runPluginAction('koffi', 'purge cache'), e: 'POST /api/admin/plugins/koffi/actions/purge%20cache' }, { n: 'pluginEgressHosts', r: () => adminApi.pluginEgressHosts('koffi'), e: 'GET /api/admin/plugins/koffi/egress-hosts' }, { n: 'pluginSetEgressHosts', r: () => adminApi.pluginSetEgressHosts('koffi', ['a.example']), e: 'PUT /api/admin/plugins/koffi/egress-hosts' }, { n: 'pluginErrors', r: () => adminApi.pluginErrors('koffi'), e: 'GET /api/admin/plugins/koffi/errors' }, @@ -410,9 +414,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' }, @@ -555,6 +572,11 @@ describe('client > request payloads', () => { expect((await traceOne(() => journeyApi.reorderEntries(2, [8, 7]))).body).toEqual({ orderedIds: [8, 7] }) }) + it('FE-APISURF-058: a dated day is asked for with the dated flag alone', async () => { + const rec = await traceOne(() => daysApi.create(1, { dated: true })) + expect(rec).toMatchObject({ method: 'POST', url: '/api/trips/1/days', body: { dated: true } }) + }) + it('FE-APISURF-023: user-id collections are sent as user_ids', async () => { expect((await traceOne(() => assignmentsApi.setParticipants(1, 7, [4, 5]))).body).toEqual({ user_ids: [4, 5] }) expect((await traceOne(() => budgetApi.setMembers(1, 2, [4]))).body).toEqual({ user_ids: [4] }) @@ -649,6 +671,12 @@ describe('client > request payloads', () => { expect(rec.method).toBe('DELETE') expect(rec.body).toEqual({ password: 'hunter2' }) }) + + it('FE-APISURF-056: docsyncApi.createScope names the connection in the path only', async () => { + const rec = await traceOne(() => docsyncApi.createScope(1, 5, 'Norway')) + expect(`${rec.method} ${rec.url}`).toBe('POST /api/trips/1/docsync/connections/5/scopes') + expect(rec.body).toEqual({ name: 'Norway' }) + }) }) describe('client > query parameters', () => { @@ -862,4 +890,64 @@ describe('client > multipart uploads', () => { expect(post.mock.calls[1][0]).toBe('/backup/upload-restore') expect(((post.mock.calls[1][1] as FormData).get('backup') as File).name).toBe('backup.zip') }) + + it('FE-APISURF-053: every channel test outlives the 8s global timeout', async () => { + const post = spyPost() + + // The server budgets up to 20s for an SMTP dial and 10s for a webhook or + // ntfy ping; aborting at 8s threw away the reason and left the admin with a + // bare "failed" (#2196). + await notificationsApi.testSmtp('a@b.c') + await notificationsApi.testWebhook('https://hook') + await notificationsApi.testNtfy({ topic: 't' }) + await notificationsApi.testChannel('plugin/ch') + + expect(post.mock.calls).toHaveLength(4) + for (const call of post.mock.calls) { + expect(call[2]).toMatchObject({ timeout: 40000 }) + } + }) + + it('FE-APISURF-057: every document-sync call that reaches the store outlives the 8s global timeout', async () => { + const post = spyPost() + const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse) + const del = vi.spyOn(apiClient, 'delete').mockResolvedValue({ data: { ok: true } } as unknown as AxiosResponse) + + // The server allows each request to a store 15 s, and a run waits for the + // whole listing and every transfer; cut off at 8 s the browser reported a + // failure while the server carried on and finished. + await docsyncApi.testConnection(1, { providerId: 'paperless' }) + await docsyncApi.createScope(1, 5, 'Norway') + await docsyncApi.createLink(1, { connectionId: 5 }) + await docsyncApi.listScopes(1, 5) + await docsyncApi.deleteLink(1, 9) + + expect(post.mock.calls.map(c => c[0])).toEqual([ + '/trips/1/docsync/connections/test', + '/trips/1/docsync/connections/5/scopes', + '/trips/1/docsync/links', + ]) + for (const call of post.mock.calls) { + expect(call[2]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS }) + } + expect(get.mock.calls[0][0]).toBe('/trips/1/docsync/connections/5/scopes') + expect(get.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS }) + expect(del.mock.calls[0][0]).toBe('/trips/1/docsync/links/9') + expect(del.mock.calls[0][1]).toMatchObject({ timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS }) + expect(DOCSYNC_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(8000) + + // A run, and a conflict choice that runs the binding afterwards. + post.mockClear() + await docsyncApi.syncNow(1, 9) + await docsyncApi.resolve(1, 4, 'trek') + + expect(post.mock.calls.map(c => c[0])).toEqual([ + '/trips/1/docsync/links/9/sync', + '/trips/1/docsync/items/4/resolve', + ]) + for (const call of post.mock.calls) { + expect(call[2]).toMatchObject({ timeout: DOCSYNC_RUN_TIMEOUT_MS }) + } + expect(DOCSYNC_RUN_TIMEOUT_MS).toBeGreaterThan(DOCSYNC_UPSTREAM_TIMEOUT_MS) + }) }) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 09e90df47..fbf25b227 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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 { Day, Place, Trip } from '../types' +import type { TransitProvider } from '@trek/shared' import { randomId } from '../utils/randomId' import { weatherResultSchema, type WeatherResult, @@ -19,6 +21,7 @@ import { type RegisterRequest, type LoginRequest, type ForgotPasswordRequest, type ResetPasswordRequest, type ChangePasswordRequest, type MfaVerifyLoginRequest, type MfaEnableRequest, type McpTokenCreateRequest, + type ApiTokenCreateRequest, type PublicApiScope, type TripAddMemberRequest, type TripTransferOwnershipRequest, type TripCreateGuestRequest, type TripRenameGuestRequest, type AssignmentReorderRequest, type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest, @@ -30,7 +33,7 @@ import { type BudgetCreateItemRequest, type BudgetUpdateItemRequest, type PackingCreateItemRequest, type PackingUpdateItemRequest, type PackingSetSharingRequest, type TodoCreateItemRequest, type TodoUpdateItemRequest, - type AssignmentCreateRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest, + type AssignmentCreateRequest, type AssignmentNotesRequest, type AssignmentParticipantsRequest, type AssignmentTimeRequest, type AssignmentTransportRequest, type PlaceBulkDeleteRequest, type PlaceBulkUpdateRequest, type DayNoteCreateRequest, type DayNoteUpdateRequest, @@ -53,6 +56,18 @@ import { type StorageConfigPut, type StorageTestResponse, type StorageUsage, + type PluginSettingsField, + type PluginInstanceConfigResponse, + type PluginInstanceConfigUpdated, + type PluginActionDescriptor, + type PluginActionResult, + type PluginInstallRequest, + RoadtripDayTrack, + RoadtripVia, + RoadtripViaBatchRequest, + RoadtripViaCreateRequest, + RoadtripViaReanchorRequest, + RoadtripViaUpdateRequest, } from '@trek/shared' import { getSocketId } from './websocket' import { probeNow } from '../sync/connectivity' @@ -122,7 +137,7 @@ const RATE_LIMIT_MESSAGES: Record = { function translateRateLimit(): string { const fallback = RATE_LIMIT_MESSAGES['en']! try { - const lang = localStorage.getItem('app_language') || 'en' + const lang = localStorage.getItem('app_language') || localStorage.getItem('app_language_server') || 'en' return RATE_LIMIT_MESSAGES[lang] ?? fallback } catch { return fallback @@ -208,16 +223,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) + } } } } @@ -315,6 +338,17 @@ export const authApi = { create: (name: string) => apiClient.post('/auth/mcp-tokens', { name } satisfies McpTokenCreateRequest).then(r => r.data), delete: (id: number) => apiClient.delete(`/auth/mcp-tokens/${id}`).then(r => r.data), }, + // Keys for the public API. Same shape as the MCP tokens above and a separate + // credential: one drives the assistant tools, the other reads trips over HTTP, + // and a key of the wrong kind is refused like one that does not exist. + apiKeys: { + list: () => apiClient.get('/auth/api-tokens').then(r => r.data), + // `scopes` narrows what the key may read (#2279). Omitted means everything, + // which is what every key minted before scopes existed still does. + create: (name: string, scopes?: PublicApiScope[]) => + apiClient.post('/auth/api-tokens', { name, ...(scopes?.length ? { scopes } : {}) } satisfies ApiTokenCreateRequest).then(r => r.data), + delete: (id: number) => apiClient.delete(`/auth/api-tokens/${id}`).then(r => r.data), + }, passkey: { registerOptions: (password: string) => apiClient.post('/auth/passkey/register/options', { password }).then(r => r.data), registerVerify: (attestationResponse: unknown, name?: string) => apiClient.post('/auth/passkey/register/verify', { attestationResponse, name }).then(r => r.data), @@ -400,17 +434,26 @@ export const tripsApi = { export const daysApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data), - create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data), + // `trip` comes along when the new day changed the trip itself: a dated day + // (`dated: true`) moved its end date, an insert at a position grew it by a day. + create: (tripId: number | string, data: DayCreateRequest): Promise<{ day: Day; trip?: Trip }> => + apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data), update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data), // Whole-day default route mode (#1281); per-segment leg modes override it. updateTransport: (tripId: number | string, dayId: number | string, mode: string | null) => apiClient.put(`/trips/${tripId}/days/${dayId}/transport`, { transport_mode: mode }).then(r => r.data), - delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data), + // Answers with the trip in list shape: its day count changed, and its end + // date when the day took the last date along. + delete: (tripId: number | string, dayId: number | string): Promise<{ success: boolean; trip?: Trip }> => + apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data), reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data), } export const placesApi = { list: (tripId: number | string, params?: Record) => 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), @@ -439,8 +482,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[]) => @@ -459,6 +509,8 @@ export const assignmentsApi = { getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data), setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds } satisfies AssignmentParticipantsRequest).then(r => r.data), updateTime: (tripId: number | string, id: number, times: AssignmentTimeRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data), + // Day-specific note on an assignment (#2163) — null clears it. + updateNotes: (tripId: number | string, id: number, data: AssignmentNotesRequest) => apiClient.put(`/trips/${tripId}/assignments/${id}/notes`, data).then(r => r.data), // Per-segment travel mode (#1281): mode of the leg leaving this stop (null = inherit day default). // direction defaults to 'outgoing' server-side, so only send it for the incoming (boundary-leg) case // and keep the outgoing payload byte-for-byte identical to the pre-#1281 shape. @@ -527,8 +579,8 @@ export const adminApi = { plugins: () => apiClient.get('/admin/plugins').then(r => r.data), pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data), pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data), - pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) => - apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data), + pluginInstall: (id: string, opts?: Pick) => + apiClient.post('/admin/plugins/install', { id, ...opts } satisfies PluginInstallRequest).then(r => r.data), pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data), pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data), // `version` pins the exact version to install (the rollback path); omitted, the server @@ -548,6 +600,17 @@ export const adminApi = { // Dev-link (dev-only): register a plugin from a local built dir + hot-reload it. pluginLink: (path: string) => apiClient.post('/admin/plugins/link', { path }).then(r => r.data), pluginReload: (id: string) => apiClient.post(`/admin/plugins/${id}/reload`).then(r => r.data), + // The admin-owned `scope:'instance'` settings: the declared fields plus the stored + // values (secrets masked). Saving may RESTART a running plugin — the child gets its + // config once at init — and `restarted` reports whether that happened. + pluginConfig: (id: string): Promise => + apiClient.get(`/admin/plugins/${id}/config`).then(r => r.data), + pluginSaveConfig: (id: string, config: Record): Promise => + apiClient.put(`/admin/plugins/${id}/config`, config).then(r => r.data), + // Run a `scope:'instance'` action the plugin declared ("Purge cache"). Runs AS the + // clicking admin. 404 when the plugin is inactive (no child process to run it). + runPluginAction: (id: string, key: string): Promise => + apiClient.post(`/admin/plugins/${id}/actions/${encodeURIComponent(key)}`).then(r => r.data), // Operator-supplied egress hosts: a plugin talking to a SELF-HOSTED service can't name // the operator's hostname in its manifest, so the admin adds it here. Saving re-spawns // the plugin with the widened allow-list. @@ -608,12 +671,18 @@ export const adminApi = { updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data), getPlacesPhotos: () => apiClient.get('/admin/places-photos').then(r => r.data), updatePlacesPhotos: (enabled: boolean) => apiClient.put('/admin/places-photos', { enabled }).then(r => r.data), + 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), + getPlacesGoogleOnly: () => apiClient.get('/admin/places-google-only').then(r => r.data), + updatePlacesGoogleOnly: (enabled: boolean) => apiClient.put('/admin/places-google-only', { 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) => apiClient.put('/admin/collab-features', features).then(r => r.data), packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data), @@ -774,16 +843,13 @@ export interface PluginAtlasLayer { countries: Array<{ code: string; tone: 'default' | 'success' | 'warn' | 'danger'; label?: string }> } -export interface PluginUserSettingField { - key: string; label?: string | null; input_type?: string; placeholder?: string | null; - hint?: string | null; required?: boolean; secret?: boolean; - options?: Array<{ value: string; label: string }> -} +/** The settings-field descriptor is the SHARED contract (both scopes emit the same + * shape) — aliased so existing per-user settings consumers keep their import path. */ +export type PluginUserSettingField = PluginSettingsField -/** A button a plugin contributes to its own settings page ("Test connection"). */ -export interface PluginAction { - key: string; label: string; hint?: string; danger: boolean -} +/** A button a plugin contributes to a settings form ("Test connection", "Purge cache"). + * `scope` says which form: the user tab or the admin instance-settings dialog. */ +export type PluginAction = PluginActionDescriptor export const pluginsApi = { // Active plugins the client renders (page nav entries, dashboard widgets). @@ -854,7 +920,7 @@ export const pluginsApi = { // caller, so it reads the caller's own settings. runAction: (id: string, key: string) => apiClient.post(`/plugin-settings/${id}/actions/${encodeURIComponent(key)}`) - .then(r => r.data as { ok: boolean; message?: string }), + .then(r => r.data as PluginActionResult), saveUserSettings: (id: string, config: Record) => apiClient.post(`/plugin-settings/${id}`, { config }).then(r => r.data as { config: Record }), // Host-brokered outbound OAuth (the host owns the tokens; the plugin only triggers). @@ -945,6 +1011,9 @@ export const journeyApi = { postMultipart(`/journeys/${journeyId}/gallery/photos`, formData, opts), uploadGalleryVideo: (journeyId: number, formData: FormData, opts?: UploadOptions) => postMultipart(`/journeys/${journeyId}/gallery/video`, formData, opts), + /** A clip on one entry: the video plus the poster frame the browser grabbed (issue #2341). */ + uploadEntryVideo: (entryId: number, formData: FormData, opts?: UploadOptions) => + postMultipart(`/journeys/entries/${entryId}/video`, formData, opts), addProviderPhotosToGallery: (journeyId: number, provider: string, assetIds: string[], passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/${journeyId}/gallery/provider-photos`, { provider, asset_ids: assetIds, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) } satisfies JourneyProviderPhotosRequest).then(r => r.data), addProviderPhoto: (entryId: number, provider: string, assetId: string, caption?: string, passphrase?: string) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_id: assetId, caption, ...(passphrase ? { passphrase } : {}) }).then(r => r.data), addProviderPhotos: (entryId: number, provider: string, assetIds: string[], caption?: string, passphrase?: string, mediaTypes?: string[]) => apiClient.post(`/journeys/entries/${entryId}/provider-photos`, { provider, asset_ids: assetIds, caption, ...(passphrase ? { passphrase } : {}), ...(mediaTypes ? { media_types: mediaTypes } : {}) }).then(r => r.data), @@ -964,6 +1033,9 @@ export const journeyApi = { // Preferences updatePreferences: (id: number, data: { hide_skeletons?: boolean }) => apiClient.patch(`/journeys/${id}/preferences`, data).then(r => r.data), + /** Bring every waved-away trip suggestion back. Answers with how many returned. */ + restoreSuggestions: (id: number): Promise<{ restored: number }> => + apiClient.post(`/journeys/${id}/suggestions/restore`).then(r => r.data), // Share getShareLink: (id: number) => apiClient.get(`/journeys/${id}/share-link`).then(r => r.data), @@ -986,7 +1058,10 @@ const MEMORIES_TIMEOUT = 0 export const memoriesApi = { status: (provider: string): Promise<{ connected: boolean }> => apiClient.get(`/integrations/memories/${provider}/status`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data), - search: (provider: string, body: { from: string; to: string; page: number; size: number }, signal?: AbortSignal) => + // utc_offset_minutes says which 24 hours from/to name. It is NOT the Synology + // `offset`, which is rows to skip: one body goes to whichever provider, so the + // two names have to stay apart (#2336). + search: (provider: string, body: { from: string; to: string; page: number; size: number; utc_offset_minutes?: number }, signal?: AbortSignal) => apiClient.post(`/integrations/memories/${provider}/search`, body, { timeout: MEMORIES_TIMEOUT, signal }).then(r => r.data), albums: (provider: string) => apiClient.get(`/integrations/memories/${provider}/albums`, { timeout: MEMORIES_TIMEOUT }).then(r => r.data), @@ -998,11 +1073,198 @@ 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 | null }>, +): Promise<{ place: Record | null }> { + const fromCache = async (): Promise<{ place: Record | 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( + query: string, + shape: (places: Record[]) => T, + online: () => Promise, + signal?: AbortSignal, +): Promise { + const fromCache = async (): Promise[]> => { + 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[]> { + 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[]) : [] + } 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. + * + * `provider: 'google'` sends this one search to Google alone, the "search Google + * instead" link under a list the index answered with the wrong place. The server + * ignores it unless Google holds the keyed slot: without a Google key, or with + * Amap or OpenStreetMap picked as the provider, the index answers as usual. + */ + search: (query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }, provider?: 'google') => + 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, ...(provider ? { provider } : {}) }).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 @@ -1022,10 +1284,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[] + 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), @@ -1041,8 +1353,8 @@ export const budgetApi = { setPayers: (tripId: number | string, id: number, payers: { user_id: number; amount: number }[]) => apiClient.put(`/trips/${tripId}/budget/${id}/payers`, { payers }).then(r => r.data), perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data), - createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data), - updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data), + createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data), + updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number; currency?: string; settled_at?: string | null }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data), deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data), reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data), reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data), @@ -1062,6 +1374,64 @@ export const filesApi = { getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).then(r => r.data), } +/** + * How long the browser waits for a document-sync call that makes the server go + * and ask the store. + * + * The shared 8 s on `apiClient` is right for TREK's own routes and wrong for + * these, for the reason `dawarich.ts` gives: the server allows each request to + * a store 15 s by itself (20 s for Papra), a connection test may probe more + * than once, and binding or unbinding first registers or removes a webhook at + * the store. Cut off at 8 s, the browser reported a failure while the server + * carried on and finished, so a listing that took twelve seconds read as a + * broken store. + */ +export const DOCSYNC_UPSTREAM_TIMEOUT_MS = 60_000 + +/** + * A run is longer again. The server answers only once the whole of it is + * through: a listing of up to 40 pages, then up to 25 transfers at up to 120 s + * each, and Paperless-ngx holds every push until its consume task has run. + * Resolving a conflict runs the binding afterwards, so it waits just as long. + * Ten minutes covers a full transfer budget of ordinary documents; a run that + * outlasts it still finishes on the server, and the `docsync:changed` ping + * refreshes the dialog when it does. + */ +export const DOCSYNC_RUN_TIMEOUT_MS = 600_000 + +const docsyncUpstream = { timeout: DOCSYNC_UPSTREAM_TIMEOUT_MS } +const docsyncRun = { timeout: DOCSYNC_RUN_TIMEOUT_MS } + +/** + * Document sync: one provider connection per trip, shared by every member. + * + * Trip-scoped rather than user-scoped on purpose: a per-user connection would + * make a document's visibility depend on whose credentials fetched it, which is + * the opposite of what a shared trip needs. + */ +export const docsyncApi = { + providers: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/providers`).then(r => r.data), + status: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/status`).then(r => r.data), + listConnections: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/connections`).then(r => r.data), + saveConnection: (tripId: number | string, data: unknown) => apiClient.put(`/trips/${tripId}/docsync/connections`, data).then(r => r.data), + testConnection: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/connections/test`, data, docsyncUpstream).then(r => r.data), + deleteConnection: (tripId: number | string, connectionId: number) => apiClient.delete(`/trips/${tripId}/docsync/connections/${connectionId}`).then(r => r.data), + listScopes: (tripId: number | string, connectionId: number, q?: string) => + apiClient.get(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { params: q ? { q } : {}, ...docsyncUpstream }).then(r => r.data), + createScope: (tripId: number | string, connectionId: number, name: string) => + apiClient.post(`/trips/${tripId}/docsync/connections/${connectionId}/scopes`, { name }, docsyncUpstream).then(r => r.data), + listLinks: (tripId: number | string) => apiClient.get(`/trips/${tripId}/docsync/links`).then(r => r.data), + createLink: (tripId: number | string, data: unknown) => apiClient.post(`/trips/${tripId}/docsync/links`, data, docsyncUpstream).then(r => r.data), + updateLink: (tripId: number | string, linkId: number, data: unknown) => apiClient.patch(`/trips/${tripId}/docsync/links/${linkId}`, data).then(r => r.data), + deleteLink: (tripId: number | string, linkId: number) => apiClient.delete(`/trips/${tripId}/docsync/links/${linkId}`, docsyncUpstream).then(r => r.data), + syncNow: (tripId: number | string, linkId: number, full = false) => + apiClient.post(`/trips/${tripId}/docsync/links/${linkId}/sync`, { full }, docsyncRun).then(r => r.data), + items: (tripId: number | string, state?: string) => + apiClient.get(`/trips/${tripId}/docsync/items`, { params: state ? { state } : {} }).then(r => r.data), + resolve: (tripId: number | string, itemId: number, keep: 'trek' | 'provider' | 'both') => + apiClient.post(`/trips/${tripId}/docsync/items/${itemId}/resolve`, { keep }, docsyncRun).then(r => r.data), +} + export const reservationsApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data), upcoming: () => apiClient.get('/reservations/upcoming').then(r => r.data), @@ -1100,7 +1470,8 @@ export const healthApi = { export const weatherApi = { // `time` (HH:MM) makes a past date answer for that hour instead of the day (#1614). - get: (lat: number, lng: number, date: string, time?: string): Promise => apiClient.get('/weather', { params: { lat, lng, date, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')), + // `lang` localizes the description; when omitted the server keeps its own default (#2167). + get: (lat: number, lng: number, date: string, lang?: string, time?: string): Promise => apiClient.get('/weather', { params: { lat, lng, date, lang, time } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.get')), getCurrent: (lat: number, lng: number, lang?: string): Promise => apiClient.get('/weather', { params: { lat, lng, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getCurrent')), getDetailed: (lat: number, lng: number, date: string, lang?: string): Promise => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => parseInDev(weatherResultSchema, r.data, 'weather.getDetailed')), } @@ -1137,7 +1508,7 @@ export const accommodationsApi = { list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data), create: (tripId: number | string, data: AccommodationCreateRequest) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data), update: (tripId: number | string, id: number, data: AccommodationUpdateRequest) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data), - delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data), + delete: (tripId: number | string, id: number, opts?: { keepStop?: boolean }) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`, opts?.keepStop ? { params: { keepStop: 'true' } } : undefined).then(r => r.data), } export const dayNotesApi = { @@ -1154,13 +1525,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), @@ -1194,11 +1571,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), } @@ -1212,15 +1591,21 @@ export const tripInviteApi = { accept: (token: string) => apiClient.post(`/trip-invites/${token}/accept`).then(r => r.data), } +// A channel test dials a third party the admin just typed in, and the server +// budgets for it: up to 20s for a wrong SMTP port, 10s for a webhook or ntfy +// endpoint. The 8s instance timeout aborted those before the reason came back, +// so the toast could only ever say "failed" (#2196). +const CHANNEL_TEST_TIMEOUT = 40000 + export const notificationsApi = { getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data), updatePreferences: (prefs: Record>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data), - testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')), - testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')), - testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')), + testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }, { timeout: CHANNEL_TEST_TIMEOUT }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testSmtp')), + testWebhook: (url?: string) => apiClient.post('/notifications/test-webhook', { url }, { timeout: CHANNEL_TEST_TIMEOUT }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testWebhook')), + testNtfy: (payload: { topic?: string; server?: string | null; token?: string | null }) => apiClient.post('/notifications/test-ntfy', payload, { timeout: CHANNEL_TEST_TIMEOUT }).then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testNtfy')), // Generic channel test — this is how a PLUGIN channel's "Send test" button works. testChannel: (channelId: string) => - apiClient.post(`/notifications/test/${encodeURIComponent(channelId)}`) + apiClient.post(`/notifications/test/${encodeURIComponent(channelId)}`, undefined, { timeout: CHANNEL_TEST_TIMEOUT }) .then(r => checkInDev(channelTestResultSchema, r.data, 'notifications.testChannel')), } diff --git a/client/src/api/collections.test.ts b/client/src/api/collections.test.ts index 8bb4875e4..65ca3e974 100644 --- a/client/src/api/collections.test.ts +++ b/client/src/api/collections.test.ts @@ -1,4 +1,4 @@ -// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-032 +// FE-API-COLLECTIONS-001 to FE-API-COLLECTIONS-034 // // The Collections addon wrapper is thin, but every method encodes a URL, a verb and a // request-body shape that the server contract depends on. These tests drive each method @@ -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 () => { @@ -339,4 +342,23 @@ describe('collectionsApi', () => { expect(requestBody).toEqual({ label_ids: [3], place_ids: [10] }) expect(res.changed).toBe(1) }) + + it('FE-API-COLLECTIONS-033: exportGpx() reads the GPX of a list by id', async () => { + server.use(http.get(`${BASE}/:id/export/gpx`, record({ name: 'Tokyo', gpx: '', waypoints: 0, omitted: 2 }))) + + const res = await collectionsApi.exportGpx(1) + + expect(requestUrl).toContain(`${BASE}/1/export/gpx`) + expect(res).toEqual({ name: 'Tokyo', gpx: '', waypoints: 0, omitted: 2 }) + }) + + it('FE-API-COLLECTIONS-034: readGpx() posts the document and its file name', async () => { + const file = { format: 'trek.collection', version: 1, name: 'Tokyo', places: [] } + server.use(http.post(`${BASE}/gpx/read`, record({ file, skipped: 0, track_points: 12 }))) + + const res = await collectionsApi.readGpx({ gpx: '', file_name: 'tokyo.gpx' }) + + expect(requestBody).toEqual({ gpx: '', file_name: 'tokyo.gpx' }) + expect(res).toEqual({ file, skipped: 0, track_points: 12 }) + }) }) diff --git a/client/src/api/collections.ts b/client/src/api/collections.ts index be4b4c085..71f1ed8dd 100644 --- a/client/src/api/collections.ts +++ b/client/src/api/collections.ts @@ -10,6 +10,13 @@ import type { CollectionSavePlaceRequest, CollectionSaveFromTripRequest, CollectionImportablesResponse, + CollectionFile, + CollectionImportRequest, + CollectionImportIntoRequest, + CollectionImportResult, + CollectionGpxExport, + CollectionGpxReadRequest, + CollectionGpxReadResult, CollectionPlaceUpdateRequest, CollectionCopyToTripRequest, CollectionInviteRequest, @@ -53,9 +60,28 @@ export const collectionsApi = { ax.get(base).then((r: AxiosResponse) => r.data), get: (id: number): Promise => ax.get(`${base}/${id}`).then((r: AxiosResponse) => r.data), - create: (body: CollectionCreateRequest): Promise<{ collection: Collection }> => + // Export / import as a file (#2198). The export body IS the file; the import + // sends the parsed file back through the same contract. + exportFile: (id: number): Promise => + ax.get(`${base}/${id}/export`).then((r: AxiosResponse) => r.data), + importFile: (body: CollectionImportRequest): Promise => + ax.post(`${base}/import`, body satisfies CollectionImportRequest).then((r: AxiosResponse) => r.data), + /** The same file into a list that already exists: it only ever adds to it. */ + importFileInto: (id: number, body: CollectionImportIntoRequest): Promise => + ax.post(`${base}/${id}/import`, body satisfies CollectionImportIntoRequest).then((r: AxiosResponse) => r.data), + // The same list as GPX (#2301). A GPX is read by the server into a list file, + // which then goes through importFile above like any other. + exportGpx: (id: number): Promise => + ax.get(`${base}/${id}/export/gpx`).then((r: AxiosResponse) => r.data), + readGpx: (body: CollectionGpxReadRequest): Promise => + ax.post(`${base}/gpx/read`, body satisfies CollectionGpxReadRequest).then((r: AxiosResponse) => r.data), + // 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 => 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 => ax.patch(`${base}/${id}`, body satisfies CollectionUpdateRequest).then((r: AxiosResponse) => r.data), uploadCover: (id: number, formData: FormData): Promise => postMultipart(`${base}/${id}/cover`, formData), diff --git a/client/src/api/dawarich.test.ts b/client/src/api/dawarich.test.ts new file mode 100644 index 000000000..7592420ad --- /dev/null +++ b/client/src/api/dawarich.test.ts @@ -0,0 +1,459 @@ +/** + * FE-DAWARICH-API-001 to FE-DAWARICH-API-019, plus FE-DAWARICH-API-040 and -041 + * for how long the browser waits. + * + * `dawarichApi` is one line per endpoint: a verb, a URL, and the shape of what + * goes into the body or the query string. There is no logic in it to get wrong, + * which is exactly why a mistake here is so quiet: a wrong path or a dropped + * query parameter does not throw, it hands the layer above an empty answer, and + * the panel three levels up simply shows nothing. Every method is therefore + * driven through MSW and its verb, path and payload pinned. + * + * Three things here matter more than the URL bookkeeping: + * + * - **`offset`.** Recorded points are bare instants, so the reader's own UTC + * offset is the one fact the server cannot derive. Both track calls send it, + * and they send it negated: `getTimezoneOffset()` counts minutes *behind* + * local time, the wire wants minutes *ahead* of UTC. Were it to stop being + * sent, or to be sent unsigned, an evening east of UTC would land on the + * previous day of the trip and nothing would fail anywhere. The line would + * just be drawn on the wrong day, which is the whole reason the parameter + * exists. Hence two zones rather than one: a single European fixture cannot + * tell a negation from a magnitude. + * - **The failure reaches the caller intact.** `useDawarichConnection` and + * `useDawarichSuggestions` both translate `err.response.data.code` into the + * reader's language. A `.catch()` added anywhere in this file would turn + * every upstream failure into a silent success with an undefined body. + * - **An abort really cancels.** The track overlay issues a fresh request + * whenever the looked-at window changes and aborts the previous one; a call + * that resolved anyway would repaint the map with the older answer. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { http, HttpResponse, delay, type JsonBodyType } from 'msw' +import { server } from '../../tests/helpers/msw/server' +import { dawarichApi, DAWARICH_ATLAS_TIMEOUT_MS, DAWARICH_UPSTREAM_TIMEOUT_MS } from './dawarich' +import apiClient from './client' +import type { + DawarichAtlasSuggestions, + DawarichBucketScan, + DawarichConnection, + DawarichSuggestion, + DawarichSuggestionList, + DawarichTrack, +} from '@trek/shared' + +const BASE = '/api/integrations/dawarich' + +let requestUrl = '' +let requestBody: unknown + +/** Records the url and the parsed JSON body of the intercepted request, then answers with `data`. */ +function record(data: T) { + return async ({ request }: { request: Request }) => { + requestUrl = request.url + const text = await request.text() + requestBody = text ? JSON.parse(text) : undefined + return HttpResponse.json(data) + } +} + +function query(): URLSearchParams { + return new URL(requestUrl).searchParams +} + +const connection: DawarichConnection = { + url: 'https://dawarich.example', + apiKeyMasked: '••••••••', + allowInsecureTls: false, + syncEnabled: true, + connected: true, + lastSyncAt: '2026-09-13T21:00:00Z', + lastSyncState: 'ok', + lastSyncError: null, + capabilities: null, +} + +const suggestion: DawarichSuggestion = { + id: 41, + sourceVisitId: 'v-41', + tripId: 7, + tripTitle: 'Koeln', + name: 'Cafe Reichard', + lat: 50.94, + lng: 6.96, + startedAt: '2026-09-10T10:15:00+02:00', + endedAt: '2026-09-10T12:40:00+02:00', + durationMinutes: 145, + localDate: '2026-09-10', + sourceStatus: 'suggested', + confidence: 82, + confidenceBand: 'high', + state: 'new', + target: null, + acceptedPlaceId: null, + acceptedJournalEntryId: null, + acceptedBucketListItemId: null, + sourceChanged: false, + sourceMissing: false, + matchedBucketListItemId: null, + matchedBucketListName: null, + countryCode: 'DE', + firstSeenAt: '2026-09-10T06:00:00Z', + lastSeenAt: '2026-09-10T06:00:00Z', +} + +const suggestionList: DawarichSuggestionList = { + suggestions: [suggestion], + connected: true, + lastSyncAt: '2026-09-13T21:00:00Z', + lastSyncState: 'ok', + lastSyncError: null, +} + +const track: DawarichTrack = { + days: [{ date: '2026-09-10', segments: [] }], + source: 'tracks', + fetchedAt: '2026-09-13T21:05:00Z', + pointCount: 0, + truncated: false, +} + +const bucketScan: DawarichBucketScan = { + matches: [{ itemId: 3, name: 'Uluru', match: null, alreadyVisited: false }], + skippedWithoutCoordinates: 1, + truncated: false, + fetchedAt: '2026-09-13T21:05:00Z', +} + +const atlas: DawarichAtlasSuggestions = { + countries: [{ countryCode: 'DE', sourceName: 'Germany', cities: [], alreadyVisited: false }], + unresolved: ['Kosovo'], + fetchedAt: '2026-09-13T21:05:00Z', +} + +beforeEach(() => { + requestUrl = '' + requestBody = undefined + // Berlin in summer. localOffsetMinutes() negates getTimezoneOffset(), so the + // value on the wire is +120 (positive for a zone ahead of UTC), which is the + // sign convention the server's day resolution expects. + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(-120) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('dawarichApi connection', () => { + it('FE-DAWARICH-API-001: getSettings() reads the connection card and unwraps the body', async () => { + server.use(http.get(`${BASE}/settings`, record(connection))) + + const res = await dawarichApi.getSettings() + + expect(requestUrl).toContain(`${BASE}/settings`) + expect(res).toEqual(connection) + }) + + // The three warning fields are why this endpoint does not just answer + // `{ success }`: the server writes its sentence in English, the client renders + // the code in the reader's language and quotes the resolved address back. + // Dropping any of them leaves a private-IP connection looking perfectly healthy. + it('FE-DAWARICH-API-002: saveSettings() puts the settings and keeps the warning triple', async () => { + server.use( + http.put( + `${BASE}/settings`, + record({ + success: true, + warning: 'That host resolves to a private address.', + warningCode: 'private_ip', + warningIp: '192.168.178.72', + }), + ), + ) + + const res = await dawarichApi.saveSettings({ + url: 'https://dawarich.lan', + apiKey: 'secret', + allowInsecureTls: true, + syncEnabled: false, + }) + + expect(requestBody).toEqual({ + url: 'https://dawarich.lan', + apiKey: 'secret', + allowInsecureTls: true, + syncEnabled: false, + }) + expect(res.warningCode).toBe('private_ip') + expect(res.warningIp).toBe('192.168.178.72') + expect(res.warning).toContain('private address') + }) + + it('FE-DAWARICH-API-003: disconnect() deletes the stored connection', async () => { + server.use(http.delete(`${BASE}/settings`, record({ success: true }))) + + expect(await dawarichApi.disconnect()).toEqual({ success: true }) + expect(requestUrl).toContain(`${BASE}/settings`) + }) + + // A probe runs before anything is stored, so the body is deliberately partial: + // a url with no key is the shape the connect dialog sends on its first attempt. + it('FE-DAWARICH-API-004: test() posts a partial settings body and returns the probe verdict', async () => { + server.use(http.post(`${BASE}/test`, record({ connected: false, error: 'unauthorized' }))) + + const res = await dawarichApi.test({ url: 'https://dawarich.example' }) + + expect(requestBody).toEqual({ url: 'https://dawarich.example' }) + expect(res.connected).toBe(false) + expect(res.error).toBe('unauthorized') + }) + + // `alreadyRunning` is how a second press of Sync Now is told apart from a sync + // that genuinely found nothing; both report created/updated/missing as zero. + it('FE-DAWARICH-API-005: syncNow() posts to the sync endpoint and passes alreadyRunning through', async () => { + server.use(http.post(`${BASE}/sync`, record({ state: 'ok', created: 0, updated: 0, missing: 0, alreadyRunning: true }))) + + const res = await dawarichApi.syncNow() + + expect(requestUrl).toContain(`${BASE}/sync`) + expect(res.alreadyRunning).toBe(true) + }) +}) + +describe('dawarichApi suggestions', () => { + it('FE-DAWARICH-API-006: listSuggestions() sends the filter as query params, and none when unfiltered', async () => { + server.use(http.get(`${BASE}/suggestions`, record(suggestionList))) + + const res = await dawarichApi.listSuggestions({ tripId: 7, state: 'new' }) + expect(query().get('tripId')).toBe('7') + expect(query().get('state')).toBe('new') + expect(res.suggestions).toHaveLength(1) + + await dawarichApi.listSuggestions() + expect(new URL(requestUrl).search).toBe('') + }) + + it('FE-DAWARICH-API-007: accept() posts the corrected stay to the suggestion it belongs to', async () => { + server.use( + http.post( + `${BASE}/suggestions/:id/accept`, + record({ suggestion, createdPlaceId: 900, createdJournalEntryId: null, bucketListItemId: null }), + ), + ) + + const res = await dawarichApi.accept(41, { target: 'place', tripId: 7, dayId: 12, name: 'Cafe Reichard', time: '10:15' }) + + expect(requestUrl).toContain(`${BASE}/suggestions/41/accept`) + expect(requestBody).toEqual({ target: 'place', tripId: 7, dayId: 12, name: 'Cafe Reichard', time: '10:15' }) + expect(res.createdPlaceId).toBe(900) + }) + + it('FE-DAWARICH-API-008: setState() puts the new state on the suggestion', async () => { + server.use(http.put(`${BASE}/suggestions/:id/state`, record({ ...suggestion, state: 'dismissed' }))) + + const res = await dawarichApi.setState(41, 'dismissed') + + expect(requestUrl).toContain(`${BASE}/suggestions/41/state`) + expect(requestBody).toEqual({ state: 'dismissed' }) + expect(res.state).toBe('dismissed') + }) +}) + +describe('dawarichApi bucket list', () => { + it('FE-DAWARICH-API-009: scanBucketList() posts to the scan endpoint', async () => { + server.use(http.post(`${BASE}/bucket-list/scan`, record(bucketScan))) + + const res = await dawarichApi.scanBucketList() + + expect(requestUrl).toContain(`${BASE}/bucket-list/scan`) + expect(res.skippedWithoutCoordinates).toBe(1) + }) + + // `visitedAt` is optional because the default is "whenever the recording says", + // which only the server knows. An omitted one must not turn into a null on the + // wire, which the server would read as an instruction to store no date at all. + it('FE-DAWARICH-API-010: confirmBucketVisits() sends visitedAt only when the caller supplied one', async () => { + server.use(http.post(`${BASE}/bucket-list/confirm`, record({ updated: 2 }))) + + const res = await dawarichApi.confirmBucketVisits([3, 4], '2026-09-10T10:15:00Z') + expect(requestBody).toEqual({ itemIds: [3, 4], visitedAt: '2026-09-10T10:15:00Z' }) + expect(res.updated).toBe(2) + + await dawarichApi.confirmBucketVisits([3]) + expect(requestBody).toEqual({ itemIds: [3] }) + }) + + it('FE-DAWARICH-API-011: clearBucketVisit() deletes the visit off one entry', async () => { + server.use(http.delete(`${BASE}/bucket-list/:id/visit`, record({ success: true }))) + + expect(await dawarichApi.clearBucketVisit(3)).toEqual({ success: true }) + expect(requestUrl).toContain(`${BASE}/bucket-list/3/visit`) + }) +}) + +describe('dawarichApi atlas', () => { + it('FE-DAWARICH-API-012: atlasSuggestions() sends the window as from/to', async () => { + server.use(http.get(`${BASE}/atlas/suggestions`, record(atlas))) + + const res = await dawarichApi.atlasSuggestions('2026-01-01', '2026-12-31') + + expect(query().get('from')).toBe('2026-01-01') + expect(query().get('to')).toBe('2026-12-31') + expect(res.unresolved).toEqual(['Kosovo']) + }) + + it('FE-DAWARICH-API-013: acceptAtlasCountries() posts the codes the user ticked', async () => { + server.use(http.post(`${BASE}/atlas/accept`, record({ marked: 2 }))) + + const res = await dawarichApi.acceptAtlasCountries(['DE', 'NL']) + + expect(requestBody).toEqual({ countryCodes: ['DE', 'NL'] }) + expect(res.marked).toBe(2) + }) +}) + +describe('dawarichApi track overlay', () => { + it('FE-DAWARICH-API-014: tripTrack() sends the range plus the reader\'s own UTC offset', async () => { + server.use(http.get(`${BASE}/trips/:id/track`, record(track))) + + const res = await dawarichApi.tripTrack(7, { from: '2026-09-10', to: '2026-09-12' }) + + expect(new URL(requestUrl).pathname).toBe(`${BASE}/trips/7/track`) + expect(query().get('from')).toBe('2026-09-10') + expect(query().get('to')).toBe('2026-09-12') + expect(query().get('offset')).toBe('120') + expect(res.source).toBe('tracks') + }) + + // The whole-trip case passes no range at all. The offset still has to be there: + // without it the server has to guess a day boundary for every recorded instant. + it('FE-DAWARICH-API-015: tripTrack() without a range still sends the offset and nothing else', async () => { + server.use(http.get(`${BASE}/trips/:id/track`, record(track))) + + await dawarichApi.tripTrack(7) + + expect([...query().keys()]).toEqual(['offset']) + expect(query().get('offset')).toBe('120') + }) + + it('FE-DAWARICH-API-016: windowTrack() sends from, to and the offset to the window endpoint', async () => { + server.use(http.get(`${BASE}/track`, record(track))) + + const res = await dawarichApi.windowTrack('2026-09-10', '2026-09-12') + + expect(new URL(requestUrl).pathname).toBe(`${BASE}/track`) + expect(query().get('from')).toBe('2026-09-10') + expect(query().get('to')).toBe('2026-09-12') + expect(query().get('offset')).toBe('120') + expect(res.pointCount).toBe(0) + }) + + // The health handler is here because the axios error interceptor probes + // reachability whenever an error carries no response, and a cancellation is + // exactly that. Answering the probe keeps the detour short; whichever verdict + // it comes back with, the cancellation has to be re-rejected untouched, and + // that is what this pins. + it('FE-DAWARICH-API-017: an aborted windowTrack rejects as a cancellation, not as a result', async () => { + server.use( + http.get('/api/health', () => HttpResponse.json({ status: 'ok' })), + http.get(`${BASE}/track`, async () => { + await delay(50) + return HttpResponse.json(track) + }), + ) + + const ctrl = new AbortController() + const pending = dawarichApi.windowTrack('2026-09-10', '2026-09-12', ctrl.signal) + ctrl.abort() + + await expect(pending).rejects.toMatchObject({ code: 'ERR_CANCELED' }) + }) + + // Nothing in this file catches, on purpose: the connection card and the + // suggestions hook both read the upstream reason off `response.data.code` to + // say it in the reader's language. A swallowed rejection would reach them as a + // successful call carrying an undefined body. + it('FE-DAWARICH-API-018: a 502 rejects with the upstream reason code intact', async () => { + server.use( + http.get(`${BASE}/suggestions`, () => + HttpResponse.json({ error: 'Dawarich is unreachable', code: 'unreachable' }, { status: 502 }), + ), + ) + + await expect(dawarichApi.listSuggestions()).rejects.toMatchObject({ + response: { status: 502, data: { code: 'unreachable' } }, + }) + }) + + // Every other test here runs in Berlin, where -120 negated and -120 made + // positive look identical. New York separates them: the platform reports +300 + // and the wire has to carry -300. A `Math.abs` slipped in would leave every + // European install working perfectly and put every American one a day out, + // which is the kind of bug that gets reported as "sometimes wrong". + // + // UTC is the second half of the pair: the offset there is falsy, and a guard + // that only sends it "when there is one" would drop it exactly for the readers + // whose day boundary the server would then have to guess. + it('FE-DAWARICH-API-019: the offset is the negation of getTimezoneOffset, not its magnitude', async () => { + server.use(http.get(`${BASE}/track`, record(track))) + + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(300) + await dawarichApi.windowTrack('2026-09-10', '2026-09-12') + expect(query().get('offset')).toBe('-300') + + vi.spyOn(Date.prototype, 'getTimezoneOffset').mockReturnValue(0) + await dawarichApi.windowTrack('2026-09-10', '2026-09-12') + expect(query().get('offset')).toBe('0') + }) +}) + +describe('dawarichApi: how long the browser waits', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('FE-DAWARICH-API-040: every call that makes the server ask Dawarich outlasts the shared 8 s', async () => { + // The server gives each request to Dawarich 15 s on its own. At the shared + // 8 s the browser gave up first, the server cached the answer anyway, and the + // recorded route turned up only after a reload. + const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} }) + const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} }) + const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ data: {} }) + + await dawarichApi.tripTrack(7) + await dawarichApi.windowTrack('2026-09-01T00:00:00Z', '2026-09-02T00:00:00Z') + await dawarichApi.test({ url: 'https://dawarich.example' }) + await dawarichApi.syncNow() + await dawarichApi.scanBucketList() + await dawarichApi.saveSettings({ url: 'https://dawarich.example', allowInsecureTls: false, syncEnabled: true }) + + const configs = [ + ...get.mock.calls.map(call => call[1]), + ...post.mock.calls.map(call => call[2]), + ...put.mock.calls.map(call => call[2]), + ] + expect(configs).toHaveLength(6) + for (const config of configs) { + expect(config).toEqual(expect.objectContaining({ timeout: DAWARICH_UPSTREAM_TIMEOUT_MS })) + } + expect(DAWARICH_UPSTREAM_TIMEOUT_MS).toBeGreaterThan(15_000) + // The track calls still carry their offset alongside the longer wait. + expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ params: expect.objectContaining({ offset: expect.any(Number) }) })) + }) + + it('FE-DAWARICH-API-041: the Atlas waits longest, and the purely local calls keep the shared default', async () => { + const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} }) + const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} }) + + await dawarichApi.atlasSuggestions('2025-09-14T00:00:00Z', '2026-09-14T00:00:00Z') + expect(get.mock.calls[0][1]).toEqual(expect.objectContaining({ timeout: DAWARICH_ATLAS_TIMEOUT_MS })) + expect(DAWARICH_ATLAS_TIMEOUT_MS).toBeGreaterThan(DAWARICH_UPSTREAM_TIMEOUT_MS) + + // Reading suggestions and confirming countries touch TREK's own database + // only; a two-minute wait there would just hide a stuck server. + await dawarichApi.listSuggestions() + await dawarichApi.acceptAtlasCountries(['DE']) + expect(get.mock.calls[1][1]).not.toEqual(expect.objectContaining({ timeout: expect.anything() })) + expect(post.mock.calls[0][2]).toBeUndefined() + }) +}) diff --git a/client/src/api/dawarich.ts b/client/src/api/dawarich.ts new file mode 100644 index 000000000..e63eac8c4 --- /dev/null +++ b/client/src/api/dawarich.ts @@ -0,0 +1,107 @@ +import apiClient from './client' +import type { + DawarichAccept, + DawarichAcceptResult, + DawarichAtlasSuggestions, + DawarichBucketScan, + DawarichConnection, + DawarichSettings, + DawarichStatus, + DawarichSuggestion, + DawarichSuggestionList, + DawarichTrack, +} from '@trek/shared' + +/** + * The Dawarich surface, in its own file rather than in the `client.ts` god + * module. Types come from the shared contracts, so a server change that the + * client has not caught up with is a typecheck failure rather than a runtime + * surprise. + */ +/** + * How long the browser waits for a call that makes the server go and ask + * Dawarich. + * + * The shared 8 s on `apiClient` is right for TREK's own routes and wrong for + * these: the server allows each request to Dawarich 15 s by itself, walks pages + * of points for a route, and runs six probes one after another to test a + * connection. Cut off at 8 s, the browser gave up while the server carried on + * and cached the answer, so the recorded route showed "unavailable" and then + * appeared on the next reload. + */ +export const DAWARICH_UPSTREAM_TIMEOUT_MS = 120_000 + +/** + * The Atlas asks for a year of visited cities a month at a time, one request + * after another, so it gets longer again. Still bounded, and under Node's own + * five-minute request limit on the server side. + */ +export const DAWARICH_ATLAS_TIMEOUT_MS = 240_000 + +const upstream = { timeout: DAWARICH_UPSTREAM_TIMEOUT_MS } + +export const dawarichApi = { + // ── Connection ───────────────────────────────────────────────────────────── + getSettings: (): Promise => + apiClient.get('/integrations/dawarich/settings').then(r => r.data), + saveSettings: (data: DawarichSettings): Promise<{ + success: boolean + /** The server's own sentence, English — the fallback when the code below is unknown here. */ + warning?: string + /** What the warning is about, so the UI can say it in the reader's language. */ + warningCode?: string + /** The address it resolved to, for the private-IP warning. */ + warningIp?: string + }> => + apiClient.put('/integrations/dawarich/settings', data, upstream).then(r => r.data), + disconnect: (): Promise<{ success: boolean }> => + apiClient.delete('/integrations/dawarich/settings').then(r => r.data), + test: (data: Partial): Promise => + apiClient.post('/integrations/dawarich/test', data, upstream).then(r => r.data), + syncNow: (): Promise<{ state: string; created: number; updated: number; missing: number; alreadyRunning?: boolean }> => + apiClient.post('/integrations/dawarich/sync', undefined, upstream).then(r => r.data), + + // ── Suggestions ──────────────────────────────────────────────────────────── + listSuggestions: (params?: { tripId?: number; state?: string }): Promise => + apiClient.get('/integrations/dawarich/suggestions', { params }).then(r => r.data), + accept: (id: number, body: DawarichAccept): Promise => + apiClient.post(`/integrations/dawarich/suggestions/${id}/accept`, body).then(r => r.data), + setState: (id: number, state: 'new' | 'dismissed'): Promise => + apiClient.put(`/integrations/dawarich/suggestions/${id}/state`, { state }).then(r => r.data), + + // ── Bucket list ──────────────────────────────────────────────────────────── + scanBucketList: (): Promise => + apiClient.post('/integrations/dawarich/bucket-list/scan', undefined, upstream).then(r => r.data), + confirmBucketVisits: (itemIds: number[], visitedAt?: string): Promise<{ updated: number }> => + apiClient.post('/integrations/dawarich/bucket-list/confirm', { itemIds, visitedAt }).then(r => r.data), + clearBucketVisit: (itemId: number): Promise<{ success: boolean }> => + apiClient.delete(`/integrations/dawarich/bucket-list/${itemId}/visit`).then(r => r.data), + + // ── Atlas ────────────────────────────────────────────────────────────────── + atlasSuggestions: (from: string, to: string): Promise => + apiClient + .get('/integrations/dawarich/atlas/suggestions', { params: { from, to }, timeout: DAWARICH_ATLAS_TIMEOUT_MS }) + .then(r => r.data), + acceptAtlasCountries: (countryCodes: string[]): Promise<{ marked: number }> => + apiClient.post('/integrations/dawarich/atlas/accept', { countryCodes }).then(r => r.data), + + // ── Track overlay ────────────────────────────────────────────────────────── + // + // Recorded points are bare instants, so the server cannot know which day they + // belong to without being told whose day is meant. The browser is the only + // party that knows, so it says so — otherwise an evening east of UTC lands on + // the wrong day of the trip. + tripTrack: (tripId: number, params?: { from?: string; to?: string }, signal?: AbortSignal): Promise => + apiClient + .get(`/integrations/dawarich/trips/${tripId}/track`, { ...upstream, params: { ...params, offset: localOffsetMinutes() }, signal }) + .then(r => r.data), + windowTrack: (from: string, to: string, signal?: AbortSignal): Promise => + apiClient + .get('/integrations/dawarich/track', { ...upstream, params: { from, to, offset: localOffsetMinutes() }, signal }) + .then(r => r.data), +} + +/** The reader's own UTC offset in minutes — +120 for Berlin in summer. */ +function localOffsetMinutes(): number { + return -new Date().getTimezoneOffset() +} diff --git a/client/src/api/oauthScopes.test.ts b/client/src/api/oauthScopes.test.ts index 81712c63f..f0efbbaab 100644 --- a/client/src/api/oauthScopes.test.ts +++ b/client/src/api/oauthScopes.test.ts @@ -1,9 +1,9 @@ -// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-011 +// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-014 import { describe, it, expect } from 'vitest' import { readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes' +import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup, PRESET_OPT_IN_ONLY, PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from './oauthScopes' // The consent page mirrors the server's scope list by hand, so read the server // file and compare. Parsing keeps this a client-only test (no server import). @@ -31,6 +31,7 @@ describe('SCOPE_GROUPS', () => { 'vacay:read', 'vacay:write', 'geo:read', 'weather:read', 'journey:read', 'journey:write', 'journey:share', + 'plugins:use', ] for (const scope of expected) { expect(SCOPE_GROUPS).toHaveProperty(scope) @@ -123,3 +124,26 @@ describe('getScopesByGroup', () => { expect(groups['oauth.scope.group.trips']).toBeUndefined() }) }) + +describe('client presets', () => { + // The presets are written as "everything except deletes", so a new scope joins + // them silently. That is right for a scope over the user's own data and wrong + // for one that runs third-party code, so the exclusion gets a test. + it('FE-OAUTH-SCOPES-012: never offers an opt-in-only scope by default', () => { + expect(PRESET_OPT_IN_ONLY.size).toBeGreaterThan(0) + for (const scope of PRESET_OPT_IN_ONLY) { + expect(PRESET_SCOPES_DEFAULT).not.toContain(scope) + expect(PRESET_SCOPES_READONLY).not.toContain(scope) + } + }) + + it('FE-OAUTH-SCOPES-013: still offers every other non-destructive scope', () => { + const expected = ALL_SCOPES.filter(s => !s.includes(':delete') && !PRESET_OPT_IN_ONLY.has(s)) + expect(PRESET_SCOPES_DEFAULT).toEqual(expected) + expect(PRESET_SCOPES_DEFAULT).toContain('trips:write') + }) + + it('FE-OAUTH-SCOPES-014: the read-only preset stays read-only', () => { + expect(PRESET_SCOPES_READONLY.every(s => s.endsWith(':read'))).toBe(true) + }) +}) diff --git a/client/src/api/oauthScopes.ts b/client/src/api/oauthScopes.ts index d11bf853e..eefce47dd 100644 --- a/client/src/api/oauthScopes.ts +++ b/client/src/api/oauthScopes.ts @@ -43,10 +43,32 @@ export const SCOPE_GROUPS: Record = { 'journey:read': { labelKey: 'oauth.scope.journey:read.label', descriptionKey: 'oauth.scope.journey:read.description', groupKey: 'oauth.scope.group.journey' }, 'journey:write': { labelKey: 'oauth.scope.journey:write.label', descriptionKey: 'oauth.scope.journey:write.description', groupKey: 'oauth.scope.group.journey' }, 'journey:share': { labelKey: 'oauth.scope.journey:share.label', descriptionKey: 'oauth.scope.journey:share.description', groupKey: 'oauth.scope.group.journey' }, + 'files:read': { labelKey: 'oauth.scope.files:read.label', descriptionKey: 'oauth.scope.files:read.description', groupKey: 'oauth.scope.group.files' }, + 'files:write': { labelKey: 'oauth.scope.files:write.label', descriptionKey: 'oauth.scope.files:write.description', groupKey: 'oauth.scope.group.files' }, + 'files:content': { labelKey: 'oauth.scope.files:content.label', descriptionKey: 'oauth.scope.files:content.description', groupKey: 'oauth.scope.group.files' }, + 'settings:read': { labelKey: 'oauth.scope.settings:read.label', descriptionKey: 'oauth.scope.settings:read.description', groupKey: 'oauth.scope.group.settings' }, + 'settings:write': { labelKey: 'oauth.scope.settings:write.label', descriptionKey: 'oauth.scope.settings:write.description', groupKey: 'oauth.scope.group.settings' }, + 'plugins:use': { labelKey: 'oauth.scope.plugins:use.label', descriptionKey: 'oauth.scope.plugins:use.description', groupKey: 'oauth.scope.group.plugins' }, } export const ALL_SCOPES = Object.keys(SCOPE_GROUPS) +/** + * Scopes a client preset must never tick for you. + * + * The presets are written as "everything except deletes", so a new scope joins + * them silently. That is right for a new read or write scope on data the user + * already owns, and wrong for plugins:use: it turns on third-party tool + * execution, which nobody would have chosen by installing an editor. + */ +export const PRESET_OPT_IN_ONLY = new Set(['plugins:use']) + +/** The five full-access presets: everything destructive or opt-in-only removed. */ +export const PRESET_SCOPES_DEFAULT = ALL_SCOPES.filter(s => !s.includes(':delete') && !PRESET_OPT_IN_ONLY.has(s)) + +/** The read-only preset (VS Code). Kept beside the default so the pair stays visible. */ +export const PRESET_SCOPES_READONLY = ALL_SCOPES.filter(s => s.endsWith(':read') && !PRESET_OPT_IN_ONLY.has(s)) + // Group all scopes for the client registration form export const SCOPE_GROUP_NAMES = [...new Set(Object.values(SCOPE_GROUPS).map(s => s.groupKey))] diff --git a/client/src/api/placeShadow.test.ts b/client/src/api/placeShadow.test.ts new file mode 100644 index 000000000..9ee99cd94 --- /dev/null +++ b/client/src/api/placeShadow.test.ts @@ -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(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 + 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() + }) +}) diff --git a/client/src/api/placeShadow.ts b/client/src/api/placeShadow.ts new file mode 100644 index 000000000..fae21700e --- /dev/null +++ b/client/src/api/placeShadow.ts @@ -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. + }) +} diff --git a/client/src/api/routeUsage.ts b/client/src/api/routeUsage.ts new file mode 100644 index 000000000..12916f553 --- /dev/null +++ b/client/src/api/routeUsage.ts @@ -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 => + 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 => + apiClient.get('/route-usage/summary').then(r => r.data), +} diff --git a/client/src/api/schoolHolidayCatalog.ts b/client/src/api/schoolHolidayCatalog.ts new file mode 100644 index 000000000..645207f36 --- /dev/null +++ b/client/src/api/schoolHolidayCatalog.ts @@ -0,0 +1,13 @@ +import { schoolHolidayCatalogSchema, schoolHolidayRegionDetailSchema, type SchoolHolidayCountryRequest, type SchoolHolidayRegionRequest } from '@trek/shared' +import apiClient from './client' + +const base = '/school-holiday-catalog' +export const schoolHolidayCatalogApi = { + catalog: () => apiClient.get(base).then(r => schoolHolidayCatalogSchema.parse(r.data)), + region: (id: number) => apiClient.get(`${base}/regions/${id}`).then(r => schoolHolidayRegionDetailSchema.parse(r.data)), + createCountry: (country: SchoolHolidayCountryRequest) => apiClient.post(`${base}/countries`, country), + deleteCountry: (code: string) => apiClient.delete(`${base}/countries/${code}`), + createRegion: (country: string, region: SchoolHolidayRegionRequest) => apiClient.post(`${base}/countries/${country}/regions`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)), + updateRegion: (id: number, region: SchoolHolidayRegionRequest) => apiClient.put(`${base}/regions/${id}`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)), + deleteRegion: (id: number, revision: number) => apiClient.delete(`${base}/regions/${id}`, { params: { revision } }), +} diff --git a/client/src/api/wsEventPolicy.ts b/client/src/api/wsEventPolicy.ts index f89b6dec6..504b8bd38 100644 --- a/client/src/api/wsEventPolicy.ts +++ b/client/src/api/wsEventPolicy.ts @@ -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,9 +30,22 @@ 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', + 'roadtripPreferences:changed', + // components/Roadtrip/useDayBoundaries reloads the shared boundaries. + 'roadtripBoundary:changed', // In-app notifications — hooks/useInAppNotificationListener 'notification:new', 'notification:updated', + // Bag weights (#2191) — content-free ping; components/Packing/usePackingListPanel + // and mobile/screens/trip/tabs/MPackingListTab re-read the bags on it. It carries + // no state to apply, which is the point: the totals are summed server-side across + // members whose items this client may not see. + 'packing:bag-totals', // Collections — pages/collections/useCollections ('collections:' prefix listener) 'collections:updated', 'collections:accepted', @@ -65,6 +81,12 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [ // who has the book open, and where their pointers are) 'journey:book:peers', 'journey:book:cursor', + // Document sync: components/Files/docsync/useDocSync re-reads the bindings + // and their counts, and components/Files/docsync/useDocSyncOffered asks + // again whether the Files screen offers the sync button. Content-free like + // the bag ping: what a run changed is per-binding server state, and the + // files it moved arrive as file:* events. + 'docsync:changed', // Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener) 'import:progress', 'import:done', diff --git a/client/src/components/Admin/AddonManager.test.tsx b/client/src/components/Admin/AddonManager.test.tsx index 14afcc882..1b86b373c 100644 --- a/client/src/components/Admin/AddonManager.test.tsx +++ b/client/src/components/Admin/AddonManager.test.tsx @@ -1,5 +1,5 @@ -// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-025 -import { render, screen, waitFor, within } from '../../../tests/helpers/render'; +// FE-ADMIN-ADDON-001 to FE-ADMIN-ADDON-037 +import { render, screen, waitFor } from '../../../tests/helpers/render'; import userEvent from '@testing-library/user-event'; import { delay, http, HttpResponse } from 'msw'; import { server } from '../../../tests/helpers/msw/server'; @@ -44,20 +44,16 @@ function modelsRoute(names: string[], seen?: (string | null)[]) { }); } -/** The pill toggle of a top-level addon row. */ +/** Every switch is labelled with the thing it controls, top-level tile or shelf + * row alike, so one lookup covers both. */ function addonToggle(name: string): HTMLElement { - const row = screen.getByText(name).closest('.px-6.py-4') as HTMLElement; - return within(row).getByRole('button'); + return screen.getByRole('button', { name }); } -/** The pill toggle of an indented sub-row (bag tracking, collab feature, photo provider). */ -function subToggle(label: string): HTMLElement { - const row = screen.getByText(label).closest('.flex.items-center.gap-4') as HTMLElement; - return within(row).getByRole('button'); -} +const subToggle = addonToggle; function isOn(toggle: HTMLElement): boolean { - return toggle.style.background === 'var(--text-primary)'; + return toggle.getAttribute('aria-pressed') === 'true'; } beforeAll(() => { @@ -82,6 +78,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); + document.documentElement.classList.remove('dark'); }); describe('AddonManager', () => { @@ -144,13 +141,10 @@ describe('AddonManager', () => { render(<>); await screen.findByText('Todo List'); - // Get toggle button - use getAllByRole since there might be multiple buttons - const buttons = screen.getAllByRole('button'); - const toggleBtn = buttons.find(b => b.classList.contains('rounded-full')); - expect(toggleBtn).toBeInTheDocument(); + const toggleBtn = addonToggle('Todo List'); + expect(isOn(toggleBtn)).toBe(false); - // Before click - disabled state (border-primary bg) - await user.click(toggleBtn!); + await user.click(toggleBtn); // After click - success toast await screen.findByText('Addon updated'); @@ -169,17 +163,14 @@ describe('AddonManager', () => { render(<>); await screen.findByText('Todo List'); - const buttons = screen.getAllByRole('button'); - const toggleBtn = buttons.find(b => b.classList.contains('rounded-full')); - await user.click(toggleBtn!); + await user.click(addonToggle('Todo List')); // Error toast appears await screen.findByText('Failed to update addon'); - // The disabled text should be back after rollback + // The switch is back off after rollback await waitFor(() => { - const disabledTexts = screen.getAllByText('Disabled'); - expect(disabledTexts.length).toBeGreaterThan(0); + expect(isOn(addonToggle('Todo List'))).toBe(false); }); }); @@ -195,13 +186,7 @@ describe('AddonManager', () => { ); await screen.findByText('Bag Tracking'); - const bagTrackingToggle = screen.getAllByRole('button').find(b => - b.closest('[style*="paddingLeft: 70"]') !== null || b.closest('div')?.textContent?.includes('Bag Tracking') - ); - // Click the bag tracking toggle button (the h-6 w-11 button near "Bag Tracking") - const allBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full')); - // There should be two toggle buttons: one for the addon, one for bag tracking - await user.click(allBtns[allBtns.length - 1]); + await user.click(subToggle('Bag Tracking')); expect(mockToggle).toHaveBeenCalled(); }); @@ -251,9 +236,10 @@ describe('AddonManager', () => { // Journey addon is rendered expect(screen.getByText('Journey')).toBeInTheDocument(); - // Toggle buttons: journey toggle + 2 provider toggles - const toggleBtns = screen.getAllByRole('button').filter(b => b.classList.contains('rounded-full')); - expect(toggleBtns.length).toBe(3); + // Journey's own switch plus one per provider, each labelled with what it controls + expect(isOn(addonToggle('Journey'))).toBe(true); + expect(isOn(subToggle('Unsplash'))).toBe(true); + expect(isOn(subToggle('Pexels'))).toBe(false); }); it('FE-ADMIN-ADDON-011: icon falls back to Puzzle when icon name unknown', async () => { @@ -278,7 +264,8 @@ describe('AddonManager', () => { }); it('FE-ADMIN-ADDON-013: dark mode swaps the wordmark in the header', async () => { - seedStore(useSettingsStore, { settings: { dark_mode: 'dark' } }); + // The wordmark reads the .dark class, the same source applyAppearance() writes. + document.documentElement.classList.add('dark'); render(); await screen.findByText('No addons available'); @@ -309,11 +296,11 @@ describe('AddonManager', () => { await screen.findByText('Immich'); // immich and synologyphotos ship a vendor glyph, unsplash does not - const immichRow = screen.getByText('Immich').closest('.flex.items-center.gap-4') as HTMLElement; + const immichRow = screen.getByText('Immich').closest('li') as HTMLElement; expect(immichRow.querySelector('svg')).toBeInTheDocument(); - const synologyRow = screen.getByText('Synology Photos').closest('.flex.items-center.gap-4') as HTMLElement; + const synologyRow = screen.getByText('Synology Photos').closest('li') as HTMLElement; expect(synologyRow.querySelector('svg')).toBeInTheDocument(); - const unsplashRow = screen.getByText('Unsplash').closest('.flex.items-center.gap-4') as HTMLElement; + const unsplashRow = screen.getByText('Unsplash').closest('li') as HTMLElement; expect(unsplashRow.querySelector('svg')).not.toBeInTheDocument(); expect(isOn(subToggle('Immich'))).toBe(true); @@ -361,7 +348,172 @@ describe('AddonManager', () => { await waitFor(() => expect(isOn(subToggle('Unsplash'))).toBe(true)); }); - it('FE-ADMIN-ADDON-018: the collab sub-features render their state and report the toggled key', async () => { + it('FE-ADMIN-ADDON-030: provider sub-rows stay hidden while Journey itself is off', async () => { + server.use(addonsRoute([ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }), + buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: true }), + buildAddon({ id: 'synologyphotos', name: 'Synology Photos', description: 'NAS photos', type: 'photo_provider', enabled: false }), + ])); + render(); + + await screen.findByText('Journey'); + expect(screen.queryByText('Immich')).not.toBeInTheDocument(); + expect(screen.queryByText('Synology Photos')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-031: switching Journey off and on again shows the cascaded providers as off', async () => { + const user = userEvent.setup(); + let loads = 0; + let journeyOn = true; + let immichOn = true; + server.use( + http.get('/api/admin/addons', () => { + loads += 1; + return HttpResponse.json({ + addons: [ + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: journeyOn }), + buildAddon({ id: 'immich', name: 'Immich', description: 'Self-hosted photos', type: 'photo_provider', enabled: immichOn }), + ], + }); + }), + http.put('/api/admin/addons/journey', async ({ request }) => { + journeyOn = (await request.json() as { enabled: boolean }).enabled; + // Journey off takes its providers with it, see admin.service.ts. + if (!journeyOn) immichOn = false; + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + await screen.findByText('Immich'); + + await user.click(addonToggle('Journey')); + // The toast closes the whole toggle, re-read included. + await screen.findByText('Addon updated'); + expect(loads).toBe(2); + expect(screen.queryByText('Immich')).not.toBeInTheDocument(); + + await user.click(addonToggle('Journey')); + + await screen.findByText('Immich'); + expect(isOn(subToggle('Immich'))).toBe(false); + }); + + it('FE-ADMIN-ADDON-032: a toggle the server does not cascade keeps the snapshot it has', async () => { + const user = userEvent.setup(); + let loads = 0; + server.use( + http.get('/api/admin/addons', () => { + loads += 1; + return HttpResponse.json({ addons: [buildAddon({ id: 'todo', enabled: false })] }); + }), + http.put('/api/admin/addons/todo', () => HttpResponse.json({ success: true })), + ); + render(<>); + await screen.findByText('Todo List'); + + await user.click(addonToggle('Todo List')); + + await screen.findByText('Addon updated'); + expect(loads).toBe(1); + expect(isOn(addonToggle('Todo List'))).toBe(true); + }); + + it('FE-ADMIN-ADDON-033: document providers hang off Documents with their glyphs and stay out of the count', async () => { + server.use(addonsRoute([ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }), + buildAddon({ id: 'journey', name: 'Journey', type: 'global', icon: 'Compass', enabled: false }), + buildAddon({ id: 'paperless', name: 'Paperless-ngx', description: 'Document archive', type: 'document_provider', enabled: true }), + buildAddon({ id: 'nextcloud', name: 'Nextcloud', description: 'WebDAV files', type: 'document_provider', enabled: false }), + ])); + render(); + + await screen.findByText('Paperless-ngx'); + const paperlessRow = screen.getByText('Paperless-ngx').closest('li') as HTMLElement; + expect(paperlessRow.querySelector('svg')).toBeInTheDocument(); + expect(isOn(subToggle('Paperless-ngx'))).toBe(true); + expect(isOn(subToggle('Nextcloud'))).toBe(false); + // Documents and Journey are the addons; an enabled provider is a shelf row + // and must not turn 1/2 into 2/2. + expect(screen.getByText('1/2')).toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-034: document providers stay hidden while Documents itself is off', async () => { + server.use(addonsRoute([ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: false }), + buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: true }), + ])); + render(); + + await screen.findByText('Documents'); + expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-035: switching Documents off and on again shows the cascaded providers as off', async () => { + const user = userEvent.setup(); + let loads = 0; + let documentsOn = true; + let paperlessOn = true; + server.use( + http.get('/api/admin/addons', () => { + loads += 1; + return HttpResponse.json({ + addons: [ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: documentsOn }), + buildAddon({ id: 'paperless', name: 'Paperless-ngx', type: 'document_provider', enabled: paperlessOn }), + ], + }); + }), + http.put('/api/admin/addons/documents', async ({ request }) => { + documentsOn = (await request.json() as { enabled: boolean }).enabled; + if (!documentsOn) paperlessOn = false; + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + await screen.findByText('Paperless-ngx'); + + await user.click(addonToggle('Documents')); + await screen.findByText('Addon updated'); + expect(loads).toBe(2); + expect(screen.queryByText('Paperless-ngx')).not.toBeInTheDocument(); + + await user.click(addonToggle('Documents')); + + await screen.findByText('Paperless-ngx'); + expect(isOn(subToggle('Paperless-ngx'))).toBe(false); + }); + + it('FE-ADMIN-ADDON-036: toggling a document provider persists it without re-reading the list', async () => { + const user = userEvent.setup(); + let loads = 0; + let body: unknown = null; + server.use( + http.get('/api/admin/addons', () => { + loads += 1; + return HttpResponse.json({ + addons: [ + buildAddon({ id: 'documents', name: 'Documents', icon: 'FileText', enabled: true }), + buildAddon({ id: 'papra', name: 'Papra', type: 'document_provider', enabled: false }), + ], + }); + }), + http.put('/api/admin/addons/papra', async ({ request }) => { + body = await request.json(); + return HttpResponse.json({ success: true }); + }), + ); + render(<>); + await screen.findByText('Papra'); + + await user.click(subToggle('Papra')); + + await screen.findByText('Addon updated'); + expect(body).toEqual({ enabled: true }); + expect(loads).toBe(1); + expect(isOn(subToggle('Papra'))).toBe(true); + }); + + it('FE-ADMIN-ADDON-018:the collab sub-features render their state and report the toggled key', async () => { const user = userEvent.setup(); const onToggleCollabFeature = vi.fn(); server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })])); @@ -391,19 +543,48 @@ describe('AddonManager', () => { expect(screen.queryByText('Polls')).not.toBeInTheDocument(); }); + it('FE-ADMIN-ADDON-037: the links row describes the feature, not an empty list', async () => { + // The row's tooltip is its description. The links tab's empty state read as a + // status here, and a status that never changes reads as a broken feature. + server.use(addonsRoute([buildAddon({ id: 'collab', name: 'Collab', enabled: true })])); + render( + , + ); + + const links = await screen.findByText('Links'); + expect(links).toHaveAttribute('title'); + expect(links.getAttribute('title')).not.toBe('No shared links yet'); + }); + it('FE-ADMIN-ADDON-020: a disabled AI-parsing addon renders the row without its config block', async () => { server.use(addonsRoute([{ ...llmAddon({ provider: 'local' }), enabled: false }])); render(); await screen.findByText('AI Parsing'); - expect(screen.getByText('Extract bookings from files')).toBeInTheDocument(); - expect(screen.queryByText('Connection')).not.toBeInTheDocument(); + // The catalog key now exists, so the tile shows the translation rather than falling + // back to the English description the server sent. + expect(screen.getByText('Reads bookings the built-in parser cannot, using an AI model you choose')).toBeInTheDocument(); + expect(screen.queryByText('Provider')).not.toBeInTheDocument(); + }); + + it('FE-ADMIN-ADDON-020b: the config lives in the tile shelf, not as a band below the grid', async () => { + server.use(addonsRoute([llmAddon({ provider: 'openai' })])); + render(); + + const label = await screen.findByText('Provider'); + // Inside the AI Parsing tile's
, like the collab toggles sit in theirs. + const tile = label.closest('article'); + expect(tile).not.toBeNull(); + expect(tile!.textContent).toContain('AI Parsing'); }); it('FE-ADMIN-ADDON-021: the local provider lists installed models and a chip fills the model field', async () => { const user = userEvent.setup(); const urls: (string | null)[] = []; - server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3:8b', 'llama3:8b'], urls)); + server.use(addonsRoute([llmAddon({ provider: 'local' })]), modelsRoute(['qwen3.5:4b', 'llama3:8b'], urls)); render(); await screen.findByText('Installed on the server'); @@ -413,9 +594,9 @@ describe('AddonManager', () => { await user.click(screen.getByRole('button', { name: 'llama3:8b' })); expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('llama3:8b'); - // qwen3:8b is already installed, so the recommended row offers "Use" instead of "Pull" + // qwen3.5:4b is already installed, so the recommended row offers "Use" instead of "Pull" await user.click(screen.getByRole('button', { name: 'Use' })); - expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b'); + expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b'); expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled(); }); @@ -475,7 +656,7 @@ describe('AddonManager', () => { addonsRoute([llmAddon({ provider: 'local' })]), http.get('/api/admin/llm/local/models', () => { modelCalls += 1; - return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3:8b', size: 1 }] }); + return HttpResponse.json({ models: modelCalls === 1 ? [] : [{ name: 'qwen3.5:4b', size: 1 }] }); }), http.post('/api/admin/llm/local/pull', async ({ request }) => { pulled = await request.json(); @@ -495,8 +676,8 @@ describe('AddonManager', () => { expect(screen.getByText('starting…')).toBeInTheDocument(); await screen.findByText('Model pulled'); - expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3:8b' }); - expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3:8b'); + expect(pulled).toEqual({ baseUrl: 'http://localhost:11434/v1', model: 'qwen3.5:4b' }); + expect(screen.getByPlaceholderText('select or pull below')).toHaveValue('qwen3.5:4b'); await waitFor(() => expect(screen.getByRole('button', { name: 'Selected' })).toBeDisabled()); }); @@ -615,7 +796,7 @@ describe('AddonManager', () => { server.use(addonsRoute([llmAddon({ provider: 'openai' })]), modelsRoute([], urls)); render(); - await screen.findByText('Connection'); + await screen.findByText('Provider'); expect(screen.queryByText('Installed on the server')).not.toBeInTheDocument(); await user.type(screen.getByPlaceholderText('https://api.openai.com/v1'), 'https://proxy.local/v1'); diff --git a/client/src/components/Admin/AddonManager.tsx b/client/src/components/Admin/AddonManager.tsx index c5ea9e6ce..94755412a 100644 --- a/client/src/components/Admin/AddonManager.tsx +++ b/client/src/components/Admin/AddonManager.tsx @@ -1,14 +1,26 @@ import { useEffect, useState, type ComponentType } from 'react' import { adminApi } from '../../api/client' import { useTranslation } from '../../i18n' -import { useSettingsStore } from '../../store/settingsStore' import { useAddonStore } from '../../store/addonStore' +import { useIsDark } from '../../hooks/useIsDark' import { useToast } from '../shared/Toast' -import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark } from 'lucide-react' +import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane, Server, Cloud, Bookmark, Users, Loader2 } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' +import EmptyState from '../shared/EmptyState' +import DawarichIcon from '../shared/DawarichIcon' +import AirTrailIcon from '../shared/AirTrailIcon' +import { DOCUMENT_PROVIDER_ICONS } from '../shared/DocumentProviderIcons' +import AddonTile from './AddonTile' +import AddonSubRow from './AddonSubRow' +// Keys are the `icon` column from the addons table (see server seeds.ts); anything +// unknown falls back to Puzzle. Users/Sparkles cover collab and llm_parsing, which +// used to land on the fallback. const ICON_MAP = { - ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, + ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane, Bookmark, Users, Sparkles, + // Dawarich ships a brand mark rather than a lucide name, the same way the photo + // providers below do. Keyed by the string in the addons table. + Dawarich: DawarichIcon, } function ImmichIcon({ size = 14 }: { size?: number }) { @@ -30,6 +42,9 @@ function SynologyIcon({ size = 14 }: { size?: number }) { const PROVIDER_ICONS: Record> = { immich: ImmichIcon, synologyphotos: SynologyIcon, + // The document providers' own marks, single-colour so they read as glyphs in + // a row of lucide icons and invert with the theme. + ...DOCUMENT_PROVIDER_ICONS, } interface Addon { @@ -53,89 +68,100 @@ interface ProviderOption { interface AddonIconProps { name: string size?: number + /** A switched-off addon greys its icon out, brand marks included. */ + enabled?: boolean } -function AddonIcon({ name, size = 20 }: AddonIconProps) { +function AddonIcon({ name, size = 20, enabled = true }: AddonIconProps) { + // The brand marks are badges, not glyphs: they fill the framed slot instead of + // sitting in the middle of it at glyph size, and they grey out when the addon + // is off — the lucide glyphs get that from the slot's text colour, an + // has to be told. + if (name === 'Dawarich') return + // 'Plane' is the icon string airtrail was seeded with, and INSERT OR IGNORE + // means every existing install still carries it — so the brand is keyed on + // that rather than on a new name no row would ever have. + if (name === 'Plane') return const Icon = ICON_MAP[name] || Puzzle return } -interface CollabFeatures { chat: boolean; notes: boolean; polls: boolean; whatsnext: boolean } +/** What each type means, shown on the tile itself now that the sections are gone. The + * hint rides along as the tooltip, so "Global" still explains itself. */ +const TYPE_META: Record; labelKey: string; hintKey: string }> = { + trip: { icon: Briefcase, labelKey: 'admin.addons.type.trip', hintKey: 'admin.addons.tripHint' }, + global: { icon: Globe, labelKey: 'admin.addons.type.global', hintKey: 'admin.addons.globalHint' }, + integration: { icon: Link2, labelKey: 'admin.addons.type.integration', hintKey: 'admin.addons.integrationHint' }, +} + +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: 'admin.collab.links.subtitle' }, { 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 export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, collabFeatures, onToggleCollabFeature }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void; collabFeatures?: CollabFeatures; onToggleCollabFeature?: (key: string) => void }) { const { t } = useTranslation() - const dm = useSettingsStore(s => s.settings.dark_mode) - const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) + const dark = useIsDark() const toast = useToast() const refreshGlobalAddons = useAddonStore(s => s.loadAddons) const [addons, setAddons] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { - loadAddons() + loadAddons().finally(() => setLoading(false)) }, []) const loadAddons = async () => { - setLoading(true) try { const data = await adminApi.addons() setAddons(data.addons) } catch (err: unknown) { toast.error(t('admin.addons.toast.error')) - } finally { - setLoading(false) } } + /** Optimistic flip, per-row rollback on failure. Rolling the whole addons + * snapshot back instead would undo a toggle the user hit in parallel. */ const handleToggle = async (addon: Addon) => { const newEnabled = !addon.enabled - // Optimistic update setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a)) try { await adminApi.updateAddon(addon.id, { enabled: newEnabled }) - refreshGlobalAddons() - toast.success(t('admin.addons.toast.updated')) } catch (err: unknown) { - // Rollback setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a)) toast.error(t('admin.addons.toast.error')) + return } + refreshGlobalAddons() + // Journey off disables every photo provider with it, and the response carries + // only Journey. Without re-reading, switching Journey back on brings the shelf + // up with providers the database has long since turned off. + // Same for Documents: switching it off disables every document provider in + // the database, and the response carries only the addon itself. + if (addon.id === 'journey' || addon.id === 'documents') await loadAddons() + toast.success(t('admin.addons.toast.updated')) } const isPhotoProviderAddon = (addon: Addon) => { return addon.type === 'photo_provider' } + const isDocumentProviderAddon = (addon: Addon) => { + return addon.type === 'document_provider' + } + const isPhotosAddon = (addon: Addon) => { const haystack = `${addon.id} ${addon.name} ${addon.description}`.toLowerCase() return addon.type === 'trip' && (addon.icon === 'Image' || haystack.includes('photo') || haystack.includes('memories')) } - const handleTogglePhotoProvider = async (providerAddon: Addon) => { - const enableProvider = !providerAddon.enabled - const prev = addons - - setAddons(current => current.map(a => a.id === providerAddon.id ? { ...a, enabled: enableProvider } : a)) - - try { - await adminApi.updateAddon(providerAddon.id, { enabled: enableProvider }) - refreshGlobalAddons() - toast.success(t('admin.addons.toast.updated')) - } catch { - setAddons(prev) - toast.error(t('admin.addons.toast.error')) - } - } - const photoProviderAddons = addons.filter(isPhotoProviderAddon) - const photosAddon = addons.filter(a => a.type === 'trip').find(isPhotosAddon) + const documentProviderAddons = addons.filter(isDocumentProviderAddon) const tripAddons = addons.filter(a => a.type === 'trip' && !isPhotosAddon(a)) const globalAddons = addons.filter(a => a.type === 'global') const integrationAddons = addons.filter(a => a.type === 'integration') @@ -144,173 +170,186 @@ export default function AddonManager({ bagTrackingEnabled, onToggleBagTracking, label: provider.name, description: provider.description, enabled: provider.enabled, - toggle: () => handleTogglePhotoProvider(provider), + toggle: () => handleToggle(provider), + })) + const documentProviderOptions: ProviderOption[] = documentProviderAddons.map((provider) => ({ + key: provider.id, + label: provider.name, + description: provider.description, + enabled: provider.enabled, + toggle: () => handleToggle(provider), })) - const photosDerivedEnabled = providerOptions.some(p => p.enabled) if (loading) { return ( -
-
+
+
) } + /** Bag tracking, the collab features and the photo providers all hang off their + * parent as shelf rows, and each shelf only shows while its parent is on — + * a provider toggled under a disabled Journey would hit the server's 409. */ + const shelfFor = (addon: Addon) => { + if (addon.id === 'packing' && addon.enabled && onToggleBagTracking) { + return ( + } + title={t('admin.bagTracking.title')} + description={t('admin.bagTracking.subtitle')} + enabled={!!bagTrackingEnabled} + onToggle={onToggleBagTracking} + /> + ) + } + if (addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature) { + return COLLAB_SUB_FEATURES.map(feat => { + const Icon = feat.icon + return ( + } + title={t(feat.titleKey)} + description={t(feat.subtitleKey)} + enabled={collabFeatures[feat.key]} + onToggle={() => onToggleCollabFeature(feat.key)} + /> + ) + }) + } + // Document providers are the Documents tile's shelf, the way photo providers + // are Journey's. Unlike those they carry no credential form here: a document + // connection belongs to a trip, not to a user, so it is entered in the trip's + // file manager. The admin decides only whether a provider may be offered. + if (addon.id === 'documents' && addon.enabled && documentProviderOptions.length > 0) { + return documentProviderOptions.map(provider => { + const ProviderIcon = PROVIDER_ICONS[provider.key] + return ( + : undefined} + title={provider.label} + description={provider.description} + enabled={provider.enabled} + onToggle={provider.toggle} + /> + ) + }) + } + if (addon.id === 'journey' && addon.enabled && providerOptions.length > 0) { + return providerOptions.map(provider => { + const ProviderIcon = PROVIDER_ICONS[provider.key] + return ( + : undefined} + title={provider.label} + description={provider.description} + enabled={provider.enabled} + onToggle={provider.toggle} + /> + ) + }) + } + // The AI-parsing settings hang off their tile like every other sub-feature, + // just as a form instead of toggle rows. + if (addon.id === 'llm_parsing' && addon.enabled) { + return + } + return null + } + + const tile = (addon: Addon) => { + const label = getAddonLabel(t, addon) + return ( + } + name={label.name} + description={label.description} + enabled={addon.enabled} + onToggle={() => handleToggle(addon)} + > + {shelfFor(addon)} + + ) + } + + /* One column per type, side by side, instead of three stacked sections. Stacked, each + section opened its own set of columns and the tall tiles (Collab 261px, Journey + 185px) set a section's height while its neighbours ran out early. Side by side the + column heading carries the type, so the tiles need no badge of their own. */ + const groups = [ + { key: 'trip', addons: tripAddons }, + { key: 'global', addons: globalAddons }, + { key: 'integration', addons: integrationAddons }, + ].filter(g => g.addons.length > 0) + const enabledCount = addons.filter(a => a.type !== 'photo_provider' && a.type !== 'document_provider' && a.enabled).length + const totalCount = tripAddons.length + globalAddons.length + integrationAddons.length + return ( -
- {/* Header */} -
-
-

{t('admin.addons.title')}

-

- {t('admin.addons.subtitleBefore')}TREK{t('admin.addons.subtitleAfter')} +

+
+
+

{t('admin.addons.title')}

+

+ {t('admin.addons.subtitleBefore')} + TREK + {t('admin.addons.subtitleAfter')}

- - {addons.length === 0 ? ( -
- {t('admin.addons.noAddons')} -
- ) : ( -
- {/* Trip Addons */} - {tripAddons.length > 0 && ( -
-
- - - {t('admin.addons.type.trip')} — {t('admin.addons.tripHint')} - -
- {tripAddons.map(addon => ( -
- - {addon.id === 'packing' && addon.enabled && onToggleBagTracking && ( -
- -
-
{t('admin.bagTracking.title')}
-
{t('admin.bagTracking.subtitle')}
-
-
- - {bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} - - -
-
- )} - {addon.id === 'collab' && addon.enabled && collabFeatures && onToggleCollabFeature && ( -
-
- {COLLAB_SUB_FEATURES.map(feat => { - const enabled = collabFeatures[feat.key] - const Icon = feat.icon - return ( -
- -
-
{t(feat.titleKey)}
-
{t(feat.subtitleKey)}
-
-
- - {enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} - - -
-
- ) - })} -
-
- )} -
- ))} -
- )} - - {/* Global Addons */} - {globalAddons.length > 0 && ( -
-
- - - {t('admin.addons.type.global')} — {t('admin.addons.globalHint')} - -
- {globalAddons.map(addon => ( -
- - {/* Memories providers as sub-items under Journey addon */} - {addon.id === 'journey' && providerOptions.length > 0 && ( -
-
- {providerOptions.map(provider => { - const ProviderIcon = PROVIDER_ICONS[provider.key] - return ( -
- {ProviderIcon && } -
-
{provider.label}
-
{provider.description}
-
-
- - {provider.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')} - - -
-
- ) - })} -
-
- )} -
- ))} -
- )} - - {/* Integration Addons */} - {integrationAddons.length > 0 && ( -
-
- - - {t('admin.addons.type.integration')} — {t('admin.addons.integrationHint')} - -
- {integrationAddons.map(addon => ( -
- - {addon.id === 'llm_parsing' && addon.enabled && ( - - )} -
- ))} -
- )} -
+ {totalCount > 0 && ( + + {enabledCount}/{totalCount} + )}
+ + {addons.length === 0 ? ( + + ) : ( +
+ {groups.map(g => { + const meta = TYPE_META[g.key] + return ( +
+ +
{g.addons.map(tile)}
+
+ ) + })} +
+ )} +
+ ) +} + +function GroupHead({ icon: Icon, label, hint, addons, t }: { + icon: ComponentType<{ size?: number; className?: string }> + label: string + hint: string + addons: Addon[] + t: (key: string, params?: Record) => string +}) { + const enabled = addons.filter(a => a.enabled).length + return ( +
+
+ +

{label}

+ + {enabled}/{addons.length} + +
+ {/* Says what the type means — the only place that still explains it. */} +

{hint}

) } @@ -322,7 +361,7 @@ const DEFAULT_OLLAMA_URL = 'http://localhost:11434/v1' * one model per document via Ollama's grammar-constrained `format`; "thinking" is disabled * automatically, so the Qwen3 family works without any tuning. A host only needs one. */ const RECOMMENDED_MODELS: { id: string; label: string; note: string; recommended: boolean; vision: boolean }[] = [ - { id: 'qwen3:8b', label: 'Qwen3 — 8B', note: 'Recommended · best extraction quality & speed on CPU (thinking auto-disabled) · Apache-2.0', recommended: true, vision: false }, + { id: 'qwen3.5:4b', label: 'Qwen3.5 — 4B', note: 'Recommended · small and quick on CPU, 3.4 GB download, 256K context (thinking auto-disabled) · Apache-2.0', recommended: true, vision: true }, ] /** @@ -408,9 +447,8 @@ function LlmParsingConfig({ addon }: { addon: Addon }) { } } - const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-3 py-2 text-sm text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none' - const labelCls = 'mb-1.5 block text-xs font-medium text-content-secondary' - const sectionCls = 'text-[11px] font-semibold uppercase tracking-wide text-content-faint' + const fieldCls = 'w-full rounded-lg border border-edge-secondary bg-surface px-2.5 py-1.5 text-caption text-content placeholder:text-content-faint transition-colors focus:border-edge focus:outline-none' + const labelCls = 'mb-1 block text-caption font-medium text-content-secondary' const providerOptions = [ { value: 'local', label: 'Local · OpenAI-compatible', icon: , badge: 'Ollama' }, @@ -418,131 +456,113 @@ function LlmParsingConfig({ addon }: { addon: Addon }) { { value: 'anthropic', label: 'Anthropic', icon: }, ] + /* Lives in the tile's shelf like the collab toggles, so everything is caption- + sized and single-column — the band this used to be had a whole page width. */ return ( -
-
-

- Set instance-wide config (applies to all users). Leave blank to let each user configure their own provider. -

+
  • +

    + Instance-wide — applies to all users. Leave blank to let each user configure their own provider. +

    - {/* Connection */} -
    -
    Connection
    -
    - Provider - setProvider(String(v))} options={providerOptions} /> +
    + Provider + setProvider(String(v))} options={providerOptions} /> +
    + {provider !== 'anthropic' && ( + + )} + + {provider === 'anthropic' && ( +

    Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.

    + )} + + + {/* Local model management (Ollama) */} + {provider === 'local' && ( +
    +
    + Installed on the server +
    - {provider !== 'anthropic' && ( - + {modelsErr &&

    {modelsErr}

    } + {!modelsErr && installed.length === 0 && !loadingModels && ( +

    No models installed yet — pull one below.

    )} - - {provider === 'anthropic' && ( -

    Anthropic reads PDFs (including scans) natively. Local/OpenAI models receive extracted text — scanned PDFs need Anthropic.

    - )} -
    - - {/* Model */} -
    -
    Model
    - - - {/* Local model management (Ollama) */} - {provider === 'local' && ( -
    -
    - Installed on the server - -
    - {modelsErr &&

    {modelsErr}

    } - {!modelsErr && installed.length === 0 && !loadingModels && ( -

    No models installed yet — pull one below.

    - )} - {installed.length > 0 && ( -
    - {installed.map(name => ( - - ))} -
    - )} - -
    -
    Pull a recommended model
    -
    - {RECOMMENDED_MODELS.map(m => { - const installedHere = isInstalled(m.id) - const isPulling = pulling === m.id - const active = model === m.id - return ( -
    -
    -
    - {m.label} - {m.recommended && ( - Recommended - )} -
    -
    {m.note}
    - {isPulling && ( -
    -
    -
    -
    -
    {pullStatus}{pullPct ? ` · ${pullPct}%` : ''}
    -
    - )} -
    - {installedHere ? ( - - ) : ( - - )} -
    - ) - })} -
    -
    + ))}
    )} -
    - + ) : ( + + )} +
  • + {isPulling && ( +
    +
    +
    +
    +
    {pullStatus}{pullPct ? ` · ${pullPct}%` : ''}
    +
    + )} +
    + ) + })} +
    +
    +
    + )} + +
    +
    -
    + ) } -interface AddonRowProps { - addon: Addon - onToggle: (addon: Addon) => void - t: (key: string) => string - statusOverride?: boolean - hideToggle?: boolean -} - function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } { const nameKey = `admin.addons.catalog.${addon.id}.name` const descKey = `admin.addons.catalog.${addon.id}.description` @@ -555,56 +575,3 @@ function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string } } -function AddonRow({ addon, onToggle, t, nameOverride, descriptionOverride, statusOverride, hideToggle }: AddonRowProps & { nameOverride?: string; descriptionOverride?: string }) { - const isComingSoon = false - const label = getAddonLabel(t, addon) - const displayName = nameOverride || label.name - const displayDescription = descriptionOverride || label.description - const enabledState = statusOverride ?? addon.enabled - return ( -
    - {/* Icon */} -
    - -
    - - {/* Info */} -
    -
    - {displayName} - {isComingSoon && ( - - Coming Soon - - )} - - {addon.type === 'global' ? t('admin.addons.type.global') : addon.type === 'integration' ? t('admin.addons.type.integration') : t('admin.addons.type.trip')} - -
    -

    {displayDescription}

    -
    - - {/* Toggle */} -
    - - {isComingSoon ? t('admin.addons.disabled') : enabledState ? t('admin.addons.enabled') : t('admin.addons.disabled')} - - {!hideToggle && ( - - )} -
    -
    - ) -} diff --git a/client/src/components/Admin/AddonSubRow.tsx b/client/src/components/Admin/AddonSubRow.tsx new file mode 100644 index 000000000..dc529c9e8 --- /dev/null +++ b/client/src/components/Admin/AddonSubRow.tsx @@ -0,0 +1,40 @@ +import type { ReactNode } from 'react' +import ToggleSwitch from '../Settings/ToggleSwitch' + +/** + * One child row inside an {@link AddonTile}'s sub-shelf: bag tracking under + * Lists, a collab feature under Collab, a photo provider under Journey. + * + * A photo provider without a vendor mark has to stay free of `` + * (FE-ADMIN-ADDON-015), which is why `icon` falls back to a fixed-width spacer + * rather than a placeholder glyph. Nothing else belongs in this row either: no + * chevron, no status dot, no lucide fallback. + * + * The row carries its title only; the description rides along as the `title` + * tooltip, because a second line of text is what makes a child weigh as much as + * an addon. + */ +export default function AddonSubRow({ + icon, + title, + description, + enabled, + onToggle, +}: { + /** Vendor mark or lucide glyph at 14px. Omit it and the slot keeps its width. */ + icon?: ReactNode + title: string + description?: string + enabled: boolean + onToggle: () => void +}) { + return ( +
  • + {icon ? {icon} : } + + {title} + + +
  • + ) +} diff --git a/client/src/components/Admin/AddonTile.tsx b/client/src/components/Admin/AddonTile.tsx new file mode 100644 index 000000000..10760615a --- /dev/null +++ b/client/src/components/Admin/AddonTile.tsx @@ -0,0 +1,82 @@ +import type { ReactNode } from 'react' +import ToggleSwitch from '../Settings/ToggleSwitch' + +/** + * One addon in the admin grid: a card whose fill carries the on/off state, with + * an optional shelf at the foot holding its sub-features. + * + * The fill ladder is `--bg-secondary` (on) against `--bg-card` (the panel it + * sits on) — that pair reads in both themes, because secondary is *darker* than + * the card in light and *lighter* in dark. The obvious alternative, on = + * `bg-surface-card`, inverts in dark and turns the disabled tiles into the loud + * ones. + * + * Hover and focus ride on border and fill rather than a shadow: `--shadow-card` + * is only declared in `:root`, so the `.dark` palette never overrides it and a + * shadow-based affordance would be invisible there. + * + * The card is not a click target — only the switches are. No lift, no pointer + * cursor, no `role="button"`. + */ +export default function AddonTile({ + icon, + name, + description, + enabled, + onToggle, + children, +}: { + icon: ReactNode + name: string + description?: string + enabled: boolean + onToggle: () => void + /** The sub-shelf: `` children. */ + children?: ReactNode +}) { + return ( +
    +
    +
    + {icon} +
    + +
    + {/* The name stays full-strength when off: it is the tile's identity, not its + state. Greying it out reads as "broken" rather than "not enabled". */} +

    + {name} +

    +

    + {description} +

    +
    + + +
    + + {children && ( +
    + {/* Sitting inside the parent on its own fill is enough to say these belong + to it — no rail, no indent guides. */} +
      {children}
    +
    + )} +
    + ) +} diff --git a/client/src/components/Admin/AdminPluginsPanel.test.tsx b/client/src/components/Admin/AdminPluginsPanel.test.tsx index 693925b0c..3f8b4137c 100644 --- a/client/src/components/Admin/AdminPluginsPanel.test.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx @@ -129,7 +129,9 @@ describe('AdminPluginsPanel — Discover modal, operator-egress pill', () => { * the UI. It must escape every overflow ancestor, and flip up when the bottom is tight. */ describe('AdminPluginsPanel — row ⋯ menu is never clipped (#1523)', () => { - const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: false }) + // operatorEgress so the menu shows every action, Allowed hosts included (it is + // gated on the declaration, like the row chip). + const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: true }) const realRect = HTMLButtonElement.prototype.getBoundingClientRect afterEach(() => { HTMLButtonElement.prototype.getBoundingClientRect = realRect }) @@ -281,6 +283,10 @@ describe('AdminPluginsPanel — a refused update', () => { expect(await screen.findByText(/key it was installed with/i)).toBeInTheDocument() expect(screen.getByText(/key it is offering now/i)).toBeInTheDocument() expect(screen.getByRole('button', { name: /trust the new key/i })).toBeInTheDocument() + // The dialog body scrolls; the decision buttons sit outside it, so they never leave the viewport (#2159). + const body = screen.getByText(/key it is offering now/i).closest('.overflow-y-auto') + expect(body).not.toBeNull() + expect(body).not.toContainElement(screen.getByRole('button', { name: /trust the new key/i })) }) // D2, at the UI. An invalid signature means the bytes are not what the author signed. @@ -843,6 +849,30 @@ describe('AdminPluginsPanel — row actions', () => { expect(screen.getByText('View error log')).toBeInTheDocument() expect(screen.queryByText('Restart')).not.toBeInTheDocument() }) + + it('FE-COMP-PLUGINS-PANEL-049: Allowed hosts is offered only to a plugin that declared operatorEgress', async () => { + // An admin must never be invited to widen egress for a plugin that didn't ask for it + // — same rule the row chip already follows. + await openRowMenu(plugin({ operatorEgress: false })) + + expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open + expect(screen.queryByText('Allowed hosts')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-PANEL-050: a long host list scrolls inside the dialog instead of growing past the viewport (#2159)', async () => { + server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => + HttpResponse.json({ supported: true, hosts: ['gotify.lan', 'ntfy.lan'] }))) + await openRowMenu() + + fireEvent.click(screen.getByText('Allowed hosts')) + + // The card is capped and its body scrolls, so the input, the Add button and the + // restart note below the list stay reachable however many hosts the admin entered. + const body = (await screen.findByText('gotify.lan')).closest('.overflow-y-auto') + expect(body).not.toBeNull() + expect(body).toContainElement(screen.getByPlaceholderText('gotify.example.com')) + expect(body!.parentElement!.className).toContain('max-h-[88vh]') + }) }) describe('AdminPluginsPanel — enabling a plugin', () => { @@ -947,7 +977,11 @@ describe('AdminPluginsPanel — enabling a plugin', () => { await screen.findByText('Gotify') fireEvent.click(rowToggle('Gotify')) - fireEvent.click(await screen.findByRole('button', { name: /^cancel$/i })) + // The dependency list scrolls; Cancel sits outside it, so it never leaves the viewport (#2159). + const body = (await screen.findByText('trek-core')).closest('.overflow-y-auto') + expect(body).not.toBeNull() + expect(body).not.toContainElement(screen.getByRole('button', { name: /^cancel$/i })) + fireEvent.click(screen.getByRole('button', { name: /^cancel$/i })) await waitFor(() => expect(screen.queryByText('Missing dependencies')).not.toBeInTheDocument()) expect(installs).toBe(0) @@ -967,6 +1001,11 @@ describe('AdminPluginsPanel — enabling a plugin', () => { // An unknown permission is still shown — as its raw code expect(screen.getByText('weird:perm')).toBeInTheDocument() expect(screen.getByText('api.acme.io')).toBeInTheDocument() + // A long permission list scrolls inside the dialog; the decision buttons sit outside + // the scroll container, so they never leave the viewport (#2159). + const body = screen.getByText('Read trips the acting user can access').closest('.overflow-y-auto') + expect(body).not.toBeNull() + expect(body).not.toContainElement(screen.getByRole('button', { name: /keep off for now/i })) fireEvent.click(screen.getByRole('button', { name: /keep off for now/i })) expect(await screen.findByText(/left off until you approve/i)).toBeInTheDocument() @@ -1439,6 +1478,18 @@ describe('AdminPluginsPanel — Discover cards and the detail modal', () => { expect(await screen.findByText('Could not load plugin details.')).toBeInTheDocument() }) + it('FE-COMP-PLUGINS-PANEL-041a: the card opts out of the global press-scale (#2158)', async () => { + // jsdom cannot replay the browser mechanics behind #2158: the :active scale on + // the card shifted the Install button out from under the pointer, so the click + // retargeted onto the card and opened the detail modal instead of installing. + // The data-no-press attribute is the pin. + discoverWith({}) + render() + await clickDiscover() + + expect((await screen.findByText('Gotify')).closest('[role="button"]')).toHaveAttribute('data-no-press') + }) + it('FE-COMP-PLUGINS-PANEL-042: the detail modal lists setup fields, metadata and a separate homepage', async () => { discoverWith( { downloadCount: 999_500, reviewedAt: '2026-01-05T00:00:00Z', homepage: 'https://gotify.example', trek: '>=3.2.0' }, @@ -1763,7 +1814,9 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { it('FE-W5PLG-011: a plugin whose runtime cannot take extra hosts says so', async () => { const user = userEvent.setup() - mockPanel([row()]) + // Declares operatorEgress (so the menu item shows), but the runtime lookup + // disagrees — the dialog's unsupported notice is the truth-teller. + mockPanel([row({ operatorEgress: true })]) server.use( http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ supported: false, hosts: [] })), ) @@ -1778,7 +1831,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { it('FE-W5PLG-012: a failing egress lookup still opens the dialog in the unsupported state', async () => { const user = userEvent.setup() - mockPanel([row()]) + mockPanel([row({ operatorEgress: true })]) server.use( http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), ) @@ -2134,3 +2187,357 @@ describe('AdminPluginsPanel — update all', () => { await waitFor(() => expect(started).toEqual(['a-widget', 'b-widget'])) }) }) + +/** + * The admin-owned `scope:'instance'` settings form. The fields are declared manifest + * data (validated at install), the values live in the plugin's instance config, and a + * save may RESTART a running plugin — the child reads config once, at init — so the + * admin must be told when that happened rather than wondering why the plugin blinked. + */ +describe('AdminPluginsPanel — instance settings', () => { + const FIELDS = [ + { key: 'apiUrl', label: 'API URL', input_type: 'text', required: true, secret: false }, + { key: 'apiKey', label: 'API key', input_type: 'text', required: false, secret: true }, + ] + + async function openRowMenu(p: Record) { + mockList(p) + render(<>) + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')) + } + + async function openSettings(config: Record = { apiUrl: 'https://gotify.mydomain.com', apiKey: '••••••••' }) { + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ fields: FIELDS, config })), + ) + await openRowMenu(plugin({ instanceSettingsCount: 2 })) + fireEvent.click(screen.getByText('Instance settings')) + await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 }) + } + + it('FE-COMP-PLUGINS-CFG-001: a plugin with instance fields offers the menu item, opening the form', async () => { + await openSettings() + // Both declared fields render, the secret as a masked password input. + expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument() + expect(screen.getByDisplayValue('••••••••')).toHaveAttribute('type', 'password') + }) + + it('FE-COMP-PLUGINS-CFG-002: a plugin with NO instance fields gets no menu item', async () => { + await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 0 })) + expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open + expect(screen.queryByText('Instance settings')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-CFG-008: a plugin with instance ACTIONS but no fields still offers the menu item', async () => { + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => + HttpResponse.json({ fields: [], config: {}, actions: [{ key: 'ping', label: 'Ping server', danger: false, scope: 'instance' }] })), + ) + await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 1 })) + expect(screen.getByText('Instance settings')).toBeInTheDocument() + fireEvent.click(screen.getByText('Instance settings')) + expect(await screen.findByRole('button', { name: 'Ping server' }, { timeout: 5000 })).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-CFG-003: saving sends the edits but never the untouched secret mask', async () => { + let body: Record | null = null + server.use( + http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => { + body = await request.json() as Record + return HttpResponse.json({ config: body, restarted: false }) + }), + ) + await openSettings() + + fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } }) + fireEvent.click(screen.getByRole('button', { name: /^save$/i })) + + expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument() + // The mask is a display artifact, not a value — sending it would overwrite the stored secret. + expect(body).toEqual({ apiUrl: 'https://new.example' }) + }) + + it('FE-COMP-PLUGINS-CFG-004: a save that restarted the running plugin says so', async () => { + server.use( + http.put('*/api/admin/plugins/trek-gotify/config', () => + HttpResponse.json({ config: { apiUrl: 'https://gotify.mydomain.com' }, restarted: true })), + ) + await openSettings() + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })) + + expect(await screen.findByText('Settings saved — plugin restarted', {}, { timeout: 5000 })).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-CFG-005: renders every declared field type and saves the edited values', async () => { + const RICH = [ + { key: 'mode', label: 'Mode', input_type: 'select', options: [{ value: 'fast', label: 'Fast' }, { value: 'slow', label: 'Slow' }], hint: 'Pick one' }, + { key: 'enabled', input_type: 'checkbox' }, // no label — the key stands in + { key: 'retries', label: 'Retries', input_type: 'number', required: true }, + ] + let body: Record | null = null + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => + HttpResponse.json({ fields: RICH, config: { mode: 'slow', enabled: true } })), + http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => { + body = await request.json() as Record + return HttpResponse.json({ config: body, restarted: false }) + }), + ) + await openRowMenu(plugin({ instanceSettingsCount: 3 })) + fireEvent.click(screen.getByText('Instance settings')) + + // Stored values land in the right controls; an unset value renders empty. + const select = await screen.findByRole('combobox', {}, { timeout: 5000 }) + expect(select).toHaveValue('slow') + expect(screen.getByRole('checkbox')).toBeChecked() + expect(screen.getByRole('spinbutton')).toHaveValue(null) + expect(screen.getByText('Pick one')).toBeInTheDocument() // hint + expect(screen.getByText('enabled')).toBeInTheDocument() // label falls back to the key + + fireEvent.change(select, { target: { value: 'fast' } }) + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3' } }) + fireEvent.click(screen.getByRole('button', { name: /^save$/i })) + + expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument() + expect(body).toEqual({ mode: 'fast', enabled: false, retries: '3' }) + }) + + const ACTIONS = [ + { key: 'ping', label: 'Ping server', danger: false, scope: 'instance' }, + { key: 'purge', label: 'Purge cache', hint: 'Drops every cached tile', danger: true, scope: 'instance' }, + ] + + async function openWithActions(p: Record = plugin({ instanceSettingsCount: 2 })) { + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => + HttpResponse.json({ fields: FIELDS, config: { apiUrl: 'https://gotify.mydomain.com' }, actions: ACTIONS })), + ) + await openRowMenu(p) + fireEvent.click(screen.getByText('Instance settings')) + await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 }) + } + + it('FE-COMP-PLUGINS-ACT-010: instance actions render below the fields and run against the admin route', async () => { + let posted = '' + server.use( + http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => { + posted = String(params.key) + return HttpResponse.json({ ok: true, message: 'pong' }) + }), + ) + await openWithActions() + expect(screen.getByText('Drops every cached tile')).toBeInTheDocument() // hint + fireEvent.click(screen.getByRole('button', { name: 'Ping server' })) + expect(await screen.findByText('pong', {}, { timeout: 5000 })).toBeInTheDocument() + expect(posted).toBe('ping') + // The dialog stays open — the admin may run another one. + expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-ACT-011: a danger action asks first; confirming runs it', async () => { + let posted = '' + server.use( + http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => { + posted = String(params.key) + return HttpResponse.json({ ok: false, message: 'cache locked' }) + }), + ) + await openWithActions() + fireEvent.click(screen.getByRole('button', { name: 'Purge cache' })) + expect(await screen.findByText('Run this action?')).toBeInTheDocument() + expect(posted).toBe('') + fireEvent.click(screen.getByRole('button', { name: /^confirm$/i })) + expect(await screen.findByText('cache locked', {}, { timeout: 5000 })).toBeInTheDocument() + expect(posted).toBe('purge') + }) + + it('FE-COMP-PLUGINS-ACT-012: an inactive plugin shows the buttons disabled with the activation hint', async () => { + await openWithActions(plugin({ instanceSettingsCount: 2, status: 'inactive', enabled: 0 })) + expect(screen.getByRole('button', { name: 'Ping server' })).toBeDisabled() + expect(screen.getByText('Activate the plugin to run its actions')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-ACT-013: a dirty form is saved before the action runs', async () => { + const calls: string[] = [] + server.use( + http.put('*/api/admin/plugins/trek-gotify/config', () => { calls.push('save'); return HttpResponse.json({ config: {}, restarted: false }) }), + http.post('*/api/admin/plugins/trek-gotify/actions/:key', () => { calls.push('run'); return HttpResponse.json({ ok: true }) }), + ) + await openWithActions() + fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } }) + fireEvent.click(screen.getByRole('button', { name: 'Ping server' })) + await waitFor(() => expect(calls).toEqual(['save', 'run']), { timeout: 5000 }) + }) + + it('FE-COMP-PLUGINS-CFG-006: a failed config fetch is a toast, not a broken dialog', async () => { + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), + ) + await openRowMenu(plugin({ instanceSettingsCount: 2 })) + fireEvent.click(screen.getByText('Instance settings')) + + expect(await screen.findByText('Error', {}, { timeout: 5000 })).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-CFG-007: a rejected save shows the server reason and keeps the dialog open', async () => { + server.use( + http.put('*/api/admin/plugins/trek-gotify/config', () => + HttpResponse.json({ error: 'config refused' }, { status: 400 })), + ) + await openSettings() + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })) + + expect(await screen.findByText('config refused', {}, { timeout: 5000 })).toBeInTheDocument() + // Still open — the admin's edits are not thrown away on a refusal. + expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-CFG-008: the dialog closes without saving', async () => { + await openSettings() + + fireEvent.click(screen.getByRole('button', { name: /^close$/i })) + + expect(screen.queryByDisplayValue('https://gotify.mydomain.com')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-CFG-009: pre-fills a declared default when no value is stored', async () => { + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ + fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: true, secret: false, default: 'https://auth.openbnb.org/authorize' }], + config: {}, + })), + ) + await openRowMenu(plugin({ instanceSettingsCount: 1 })) + fireEvent.click(screen.getByText('Instance settings')) + + expect(await screen.findByDisplayValue('https://auth.openbnb.org/authorize', {}, { timeout: 5000 })).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-CFG-010: a stored value wins over the default', async () => { + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ + fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: false, secret: false, default: 'https://auth.openbnb.org/authorize' }], + config: { oauth_authorize_url: 'https://mine.example' }, + })), + ) + await openRowMenu(plugin({ instanceSettingsCount: 1 })) + fireEvent.click(screen.getByText('Instance settings')) + + expect(await screen.findByDisplayValue('https://mine.example', {}, { timeout: 5000 })).toBeInTheDocument() + expect(screen.queryByDisplayValue('https://auth.openbnb.org/authorize')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-CFG-011: blocks Save while a required field is empty and names it', async () => { + let called = false + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ + fields: [{ key: 'client_id', label: 'Client ID', input_type: 'text', required: true, secret: false }], + config: {}, + })), + http.put('*/api/admin/plugins/trek-gotify/config', () => { called = true; return HttpResponse.json({ config: {}, restarted: false }) }), + ) + await openRowMenu(plugin({ instanceSettingsCount: 1 })) + fireEvent.click(screen.getByText('Instance settings')) + await screen.findByText('Client ID', {}, { timeout: 5000 }) + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })) + + expect(await screen.findByText('"Client ID" is required', {}, { timeout: 5000 })).toBeInTheDocument() + expect(called).toBe(false) + }) + + it('FE-COMP-PLUGINS-CFG-012: Save paints its label with the accent-text token, so a light accent stays readable', async () => { + await openSettings() + + // `--accent-text` flips per scheme and per light/dark; a hard white label vanishes on + // the near-white accent the dark theme uses by default. + const save = screen.getByRole('button', { name: /^save$/i }) + expect(save.className).toContain('text-accent-text') + 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[], entry: Record | 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() + 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() + expect(await screen.findByText(/outside its trek range \(>=3\.2\.0 <4\.0\.0\)/i)).toBeInTheDocument() + }) +}) diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index 9ba077f3f..a0abde00f 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -6,10 +6,13 @@ import { ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle, - Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette, + Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette, Bot, + Loader2, } from 'lucide-react' 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' @@ -49,6 +52,12 @@ interface PluginRow { operatorEgress?: boolean /** How many hosts the admin has added — 0 means the plugin can't reach anything yet. */ egressHostCount?: number + /** How many `scope:'instance'` settings fields the plugin declares — gates the + * "Instance settings" menu item without a per-plugin fetch. */ + instanceSettingsCount?: number + /** How many `scope:'instance'` actions the plugin declares — a plugin with actions + * but no settings fields still needs the menu item to run them. */ + instanceActionsCount?: number dependencies?: PluginDependencies dependencyStatus?: DependencyStatus dependencyIssues?: DependencyIssues @@ -56,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 @@ -123,7 +134,12 @@ interface RegistryDetail extends RegistryItem { requiredAddons?: string[] pluginDependencies?: PluginDep[] /** Display slice of the manifest's capabilities — drives the same chips as an installed row. */ - capabilities?: { widget?: { slot?: string }; tripPage?: { replaces?: string[] } } + capabilities?: { + widget?: { slot?: string } + tripPage?: { replaces?: string[] } + /** Tools the plugin will publish on the MCP server once mcp:tools is granted. */ + mcpTools?: Array<{ name: string; title?: string; description: string }> + } } | null } @@ -241,10 +257,12 @@ function deriveCaps(perms: string[], caps: { widget?: { slot?: string }; tripPag } // Replacing planner tabs is the one capability that HIDES core UI — always chip it. if (caps.tripPage?.replaces?.length) out.push({ icon: LayoutDashboard, label: t('admin.plugins.cap.replacesTabs') }) + if (perms.includes('mcp:tools')) out.push({ icon: Bot, label: t('admin.plugins.cap.mcpTools') }) if (perms.some(p => p.startsWith('ws:broadcast'))) out.push({ icon: Radio, label: t('admin.plugins.cap.realtime') }) 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') }) @@ -259,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), @@ -277,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) }) } @@ -296,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 } } @@ -384,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([]) const [loading, setLoading] = useState(true) @@ -402,6 +425,8 @@ export default function AdminPluginsPanel() { const [detailFor, setDetailFor] = useState(null) const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null) const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null) + // The admin-owned scope:'instance' settings form — shared logic with the phone shell. + const settings = useInstanceSettings() const [egressDraft, setEgressDraft] = useState('') const [egressSaving, setEgressSaving] = useState(false) const [egressError, setEgressError] = useState('') @@ -456,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(() => {}) @@ -524,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 { @@ -567,6 +594,11 @@ export default function AdminPluginsPanel() { } } + const openInstanceSettings = (p: { id: string; status: string }) => { + setMenu(null) + settings.open(p.id, p.status === 'active') + } + const openErrors = (id: string) => { setMenu(null) adminApi.pluginErrors(id) @@ -589,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. @@ -602,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 { @@ -661,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 @@ -681,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) @@ -807,11 +846,19 @@ export default function AdminPluginsPanel() {

    {t('admin.plugins.title')}

    {t('admin.plugins.subtitle')}

    - {runtimeOn && ( - - {t('admin.plugins.runtimeOn')} - - )} +
    + {runtimeOn && ignoreTrekRange && ( + + {t('admin.plugins.rangeBypass.pill')} + + )} + {runtimeOn && ( + + {t('admin.plugins.runtimeOn')} + + )} +
    @@ -921,7 +968,7 @@ export default function AdminPluginsPanel() { ) : error ? (
    {t('admin.plugins.loadError')}
    ) : !runtimeOn ? null : view === 'discover' ? ( - ) : plugins.length === 0 ? ( @@ -956,6 +1003,7 @@ export default function AdminPluginsPanel() { code: p.updateBlock!.code, detail: p.updateBlock!.detail, })} onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)} + onSettings={() => openInstanceSettings(p)} onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} /> ))} @@ -966,10 +1014,12 @@ export default function AdminPluginsPanel() { {/* Registry detail dialog */} {detailFor && ( - setDetailFor(null)} /> )} + {bypass.copy && } + {/* Error-log modal */} {errorsFor && (
    setErrorsFor(null)}> @@ -995,12 +1045,12 @@ export default function AdminPluginsPanel() { {/* Operator-supplied egress hosts */} {egressFor && (
    setEgressFor(null)}> -
    e.stopPropagation()}> -
    +
    e.stopPropagation()}> +
    {egressFor.id} — {t('admin.plugins.allowedHosts')}
    -
    +
    {!egressFor.supported ? (

    {t('admin.plugins.allowedHosts.unsupported')}

    ) : ( @@ -1042,6 +1092,110 @@ export default function AdminPluginsPanel() {
    )} + {/* Instance-wide settings (the admin-owned scope:'instance' fields) */} + {settings.form && ( +
    +
    e.stopPropagation()}> +
    + {settings.form.id} — {t('admin.plugins.instanceSettings')} + +
    +
    + {settings.form.fields.map(f => ( + + ))} + {(() => { + const form = settings.form + if (!form || form.actions.length === 0) return null + return ( +
    + + {t('admin.plugins.actions')} + + {!form.active && ( +

    {t('admin.plugins.actions.inactive')}

    + )} +
    + {form.actions.map(a => { + const res = settings.actionResult[a.key] + return ( +
    + + {a.hint && {a.hint}} + {res && ( + + {res.message || (res.ok ? t('common.success') : t('common.error'))} + + )} +
    + ) + })} +
    +
    + ) + })()} + {settings.error &&

    {settings.error}

    } +
    +
    + +
    +
    +
    + )} + + + setConfirmUninstall(null)} @@ -1179,11 +1333,11 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) { ) } -function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onUninstall }: { +function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: { p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void - onErrors: () => void; onEgress: () => void; onUninstall: () => void + onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void }) { const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t) const deps = deriveDeps(p, t) @@ -1298,8 +1452,8 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
    {deps.map((d, i) => ( - {d.label} + d.blocked || d.warn ? 'text-warning border-warning/30 bg-warning-soft' : 'text-content-secondary border-edge-secondary bg-surface-tertiary'}`}> + {d.label} ))}
    @@ -1328,8 +1482,18 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn {p.enabled === 1 && ( } label={t('admin.plugins.restart')} onClick={onRestart} /> )} + {/* A plugin gets the item if it declares scope:'instance' fields OR actions — + an action-only plugin still needs the dialog to run its buttons. */} + {((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && ( + } label={t('admin.plugins.instanceSettings')} onClick={onSettings} /> + )} } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> - } label={t('admin.plugins.allowedHosts')} onClick={onEgress} /> + {/* Only a plugin that DECLARED operatorEgress gets the item — an admin must + never be invited to widen egress for a plugin that didn't ask for it + (same rule the row's egress chip follows). */} + {p.operatorEgress && ( + } label={t('admin.plugins.allowedHosts')} onClick={onEgress} /> + )} {/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */} {isRegistrySourced(p.source_repo) && ( } label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} /> @@ -1399,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 filtered: boolean + ignoreTrekRange: boolean }) { if (!items) return
    {t('common.loading')}
    if (items.length === 0) return ( @@ -1419,9 +1584,13 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f
    {items.map(item => { const installed = installedIds.has(item.id) - const offer = installOffer(item, t) + const offer = installOffer(item, t, ignoreTrekRange) return (
    onOpenDetail(item)} + // No press-scale on the card: shrinking it mid-click slides the Install + // button out from under the pointer, so the click retargets onto the card + // and opens the detail modal instead of installing (#2158). + data-no-press onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onOpenDetail(item) } }} className="group border border-edge rounded-2xl bg-surface-card overflow-hidden flex flex-col cursor-pointer hover:-translate-y-0.5 hover:shadow-elevated hover:border-edge-faint transition-all duration-150">
    @@ -1450,7 +1619,7 @@ function RegistryGrid({ items, onInstall, onOpenDetail, busy, t, installedIds, f {formatCompactCount(item.downloadCount)} )} - + + + ) : ( + + )} +
    +
    +
    , + 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(null) const [failed, setFailed] = useState(false) @@ -1505,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 (
    @@ -1527,7 +1741,7 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local

    {item.author}{item.latest ? ` · v${item.latest}` : ''}

    -
    )} + {/* The tool text a plugin publishes goes straight into every user's + assistant context once mcp:tools is granted, and nothing else in + this dialog shows it. An admin approving the grant should read the + names and descriptions first, not discover them in a chat. */} + {manifest && (manifest.capabilities?.mcpTools?.length ?? 0) > 0 && ( +
    +

    {t('admin.plugins.mcpToolsTitle')}

    +
      + {(manifest.capabilities?.mcpTools ?? []).map(tool => ( +
    • + +
      + {tool.name} + {tool.title &&

      {tool.title}

      } + {/* The description is the assistant-facing text: it is what the + model reads to decide whether to call the tool, so it is the + line an admin most needs to see. Never hidden behind a title. */} +

      {tool.description}

      +
      +
    • + ))} +
    +

    {t('admin.plugins.mcpToolsHint')}

    +
    + )} + {manifest && (egress.length > 0 || manifest.operatorEgress) && (

    {t('admin.plugins.connectsTitle')}

    @@ -1761,15 +2001,15 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: { return (
    -
    e.stopPropagation()}> -
    +
    e.stopPropagation()}> +

    {t('admin.plugins.sig.title', { name: data.subject.name })}

    {t(bodyKey as never)}

    -
    +
    {/* Fingerprints, not full keys: these exist to be COMPARED by a human — read the new one back to the author over the phone. The full key travels in the request. */} {canRetrust && ( @@ -1783,7 +2023,7 @@ function SignatureBlockDialog({ data, entry, busy, t, onRetrust, onClose }: {

    {data.detail}

    )}
    -
    +
    @@ -1885,15 +2125,15 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl ] return (
    -
    e.stopPropagation()}> -
    +
    e.stopPropagation()}> +

    {t('admin.plugins.dep.resolveTitle')}

    {t('admin.plugins.dep.resolveBody', { name: data.plugin.name })}

    -
    +
    {rows.map(r => (
    @@ -1914,7 +2154,7 @@ function DependencyResolveDialog({ data, t, busy, installedIds, onDownload, onCl

    {t('admin.plugins.dep.resolveHint')}

    )}
    -
    +
    diff --git a/client/src/components/Admin/CategoryManager.tsx b/client/src/components/Admin/CategoryManager.tsx index b26af8629..db20979ac 100644 --- a/client/src/components/Admin/CategoryManager.tsx +++ b/client/src/components/Admin/CategoryManager.tsx @@ -192,7 +192,7 @@ export default function CategoryManager() {

    {t('categories.title')}

    -

    {t('categories.subtitle')}

    +

    {t('categories.subtitle')}

    +
    ) } diff --git a/client/src/components/Admin/PackingTemplateManager.tsx b/client/src/components/Admin/PackingTemplateManager.tsx index 546757b32..5c81cdf99 100644 --- a/client/src/components/Admin/PackingTemplateManager.tsx +++ b/client/src/components/Admin/PackingTemplateManager.tsx @@ -156,7 +156,7 @@ export default function PackingTemplateManager() {

    {t('admin.packingTemplates.title')}

    -

    {t('admin.packingTemplates.subtitle')}

    +

    {t('admin.packingTemplates.subtitle')}

    + )} +
    + setDraft(e.target.value)} onBlur={commit} + onKeyDown={e => { if (e.key === 'Enter') e.currentTarget.blur() }} + placeholder={router.placeholder} spellCheck={false} autoComplete="off" autoCapitalize="none" + aria-describedby={`${id}-hint`} + className="min-h-11 w-full min-w-0 rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50" + /> +

    {t(router.hint)}

    +
    + ) +} + +export default function RoutingInstanceFields(props: RoutingFieldsProps) { + return
    {ROUTERS.map(router => )}
    +} diff --git a/client/src/components/Admin/SchoolHolidayCatalog.test.tsx b/client/src/components/Admin/SchoolHolidayCatalog.test.tsx new file mode 100644 index 000000000..c4607cc11 --- /dev/null +++ b/client/src/components/Admin/SchoolHolidayCatalog.test.tsx @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { http, HttpResponse } from 'msw' +import { within } from '@testing-library/react' +import { render, screen, fireEvent, waitFor } from '../../../tests/helpers/render' +import { server } from '../../../tests/helpers/msw/server' +import { resetAllStores } from '../../../tests/helpers/store' +import SchoolHolidayCatalog from './SchoolHolidayCatalog' +import SchoolHolidayRegionEditor from './SchoolHolidayRegionEditor' +import { fetchSchoolHolidayRegionOptions } from '../Vacay/holidayRegions' +import { useSchoolHolidayCountries } from '../Vacay/useSchoolHolidayCountries' +import { useVacayStore } from '../../store/vacayStore' +import type { SchoolHolidayRegionDetail } from '@trek/shared' + +const region: SchoolHolidayRegionDetail = { id: 7, code: 'US-MANUAL-7', country: 'US', name: 'Seattle schools', revision: 1, holidays: [{ name: 'Winter break', startDate: '2026-12-20', endDate: '2027-01-06' }] } +const catalog = { countries: [{ code: 'US', name: 'USA' }], regions: [region] } +beforeEach(() => resetAllStores()) + +function enterDate(label: string, value: string) { + const picker = within(screen.getByRole('group', { name: label })) + fireEvent.click(picker.getByRole('button', { name: /enter.*manually/i })) + const input = picker.getByRole('textbox') + fireEvent.change(input, { target: { value } }) + fireEvent.blur(input) +} + +describe('manual school holiday administration', () => { + it('creates a country, a district and its named periods', async () => { + let created = false + const saves: unknown[] = [] + server.use( + http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: created ? catalog.countries : [], regions: [] })), + http.post('/api/school-holiday-catalog/countries', async ({ request }) => { expect(await request.json()).toEqual({ code: 'US', name: 'USA' }); created = true; return HttpResponse.json(catalog.countries[0]) }), + http.post('/api/school-holiday-catalog/countries/US/regions', async ({ request }) => { saves.push(await request.json()); return HttpResponse.json(region) }), + ) + render() + await waitFor(() => expect(screen.getByRole('button', { name: 'Add country' })).toBeEnabled()) + fireEvent.click(screen.getByRole('button', { name: 'Add country' })) + fireEvent.change(screen.getByLabelText('Country'), { target: { value: 'USA' } }) + fireEvent.change(screen.getByLabelText('Country code (e.g. US)'), { target: { value: 'us' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(screen.getByRole('button', { name: 'Add region' })).toBeEnabled()) + fireEvent.click(screen.getByRole('button', { name: 'Add region' })) + fireEvent.change(screen.getByLabelText('Region or school district'), { target: { value: 'Seattle schools' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add holiday period' })) + fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: 'Winter break' } }) + enterDate('Start date', '2026-12-20') + enterDate('End date', '2027-01-06') + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(saves).toEqual([{ name: region.name, revision: 0, holidays: region.holidays }])) + await waitFor(() => expect(screen.queryByLabelText('Holiday name')).not.toBeInTheDocument()) + }) + + it('opens existing periods and keeps edits after a failed save', async () => { + server.use( + http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)), + http.get('/api/school-holiday-catalog/regions/7', () => HttpResponse.json(region)), + http.put('/api/school-holiday-catalog/regions/7', () => HttpResponse.json({ error: 'This region changed. Reopen it before saving again.' }, { status: 409 })), + ) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Seattle schools' })) + await screen.findByDisplayValue('Winter break') + await waitFor(() => expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled()) + fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: 'New name' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + expect(await screen.findByRole('alert')).toHaveTextContent('This region changed') + expect(screen.getByLabelText('Holiday name')).toHaveValue('New name') + }) + + it('validates names and confirms discarding changes', async () => { + const save = vi.fn() + const close = vi.fn() + render() + fireEvent.change(screen.getByLabelText('Holiday name'), { target: { value: ' ' } }) + fireEvent.submit(screen.getByRole('button', { name: 'Save' }).closest('form')!) + expect(save).not.toHaveBeenCalled() + expect(screen.getByRole('alert')).toHaveTextContent('Check the names and dates') + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.getByText('Discard unsaved changes?')).toBeInTheDocument() + expect(close).not.toHaveBeenCalled() + }) + + it('removes a period and saves an existing region without changing its revision token', () => { + const save = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Delete Winter break' })) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + expect(save).toHaveBeenCalledWith({ name: region.name, revision: 1, holidays: [] }) + }) + + it('blocks offline edits but still lets the dialog close', () => { + const close = vi.fn() + render() + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(close).toHaveBeenCalledOnce() + }) + + it('keeps deletion conflicts visible and allows retrying the catalog', async () => { + server.use( + http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)), + http.delete('/api/school-holiday-catalog/regions/7', ({ request }) => { + expect(new URL(request.url).searchParams.get('revision')).toBe('1') + return HttpResponse.json({ error: 'Region is in use' }, { status: 409 }) + }), + ) + render() + fireEvent.click(await screen.findByRole('button', { name: 'Delete Seattle schools' })) + fireEvent.click(screen.getByRole('button', { name: 'Delete' })) + expect(await screen.findByRole('alert')).toHaveTextContent('Region is in use') + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + expect(screen.getByRole('button', { name: 'Seattle schools' })).toBeInTheDocument() + }) + + it('keeps national API calendars selectable alongside manual regions', async () => { + server.use(http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [{ code: 'IE', name: 'Ireland' }], regions: [{ ...region, country: 'IE', code: 'IE-MANUAL-7' }] }))) + expect(await fetchSchoolHolidayRegionOptions('IE')).toEqual([{ value: 'IE', label: 'IE' }, { value: 'IE-MANUAL-7', label: region.name }]) + }) + + it('keeps API regions available if loading the manual catalog fails', async () => { + server.use( + http.get('/api/school-holiday-catalog', () => HttpResponse.json({}, { status: 502 })), + http.get('/api/addons/vacay/school-holidays/regions/DE', () => HttpResponse.json({ subdivisions: [{ code: 'DE-BY', name: [{ language: 'EN', text: 'Bavaria' }] }] })), + ) + expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([{ value: 'DE-BY', label: 'Bavaria' }]) + await expect(fetchSchoolHolidayRegionOptions('US')).rejects.toThrow() + }) + + it('offers USA independently of provider countries and fetches only local regions', async () => { + const provider = vi.fn(() => HttpResponse.json([])) + server.use(http.get('/api/school-holiday-catalog', () => HttpResponse.json(catalog)), http.get('/api/addons/vacay/school-holidays/regions/US', provider)) + function Countries() { const { countries } = useSchoolHolidayCountries(); return <>{countries.map(country => {country.label})} } + render() + expect(await screen.findByText('USA')).toBeInTheDocument() + expect(await fetchSchoolHolidayRegionOptions('US')).toEqual([{ value: 'US-MANUAL-7', label: 'Seattle schools' }]) + expect(provider).not.toHaveBeenCalled() + }) + + it('keeps manual regions usable when a supported country provider fails', async () => { + server.use( + http.get('/api/school-holiday-catalog', () => HttpResponse.json({ countries: [{ code: 'DE', name: 'Germany' }], regions: [{ ...region, country: 'DE', code: 'DE-MANUAL-7' }] })), + http.get('/api/addons/vacay/school-holidays/regions/DE', () => HttpResponse.json({}, { status: 502 })), + ) + expect(await fetchSchoolHolidayRegionOptions('DE')).toEqual([{ value: 'DE-MANUAL-7', label: region.name }]) + }) + + it('renders manual dates with inclusive boundaries without touching leave entries', async () => { + server.use(http.get('/api/school-holiday-catalog/regions/7/holidays/2026', () => HttpResponse.json(region.holidays))) + useVacayStore.setState({ + selectedYear: 2026, + plan: { id: 1, school_holidays_enabled: true, holidays_enabled: false, holidays_region: null, block_weekends: true, company_holidays_enabled: true, carry_over_enabled: false, holiday_calendars: [{ id: 1, plan_id: 1, region: region.code, label: 'School', color: '#a5f3fc', sort_order: 0, type: 'school_holiday' }] }, + entries: [{ plan_id: 1, user_id: 1, date: '2026-12-23', fraction: 1, kind: 'vacation' }], + }) + const entries = useVacayStore.getState().entries + await useVacayStore.getState().loadHolidays(2026) + expect(useVacayStore.getState().holidays['2026-12-20']).toBeDefined() + expect(useVacayStore.getState().holidays['2026-12-31']).toBeDefined() + expect(useVacayStore.getState().entries).toBe(entries) + }) +}); diff --git a/client/src/components/Admin/SchoolHolidayCatalog.tsx b/client/src/components/Admin/SchoolHolidayCatalog.tsx new file mode 100644 index 000000000..d405dabeb --- /dev/null +++ b/client/src/components/Admin/SchoolHolidayCatalog.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react' +import { Plus, Trash2 } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { useSchoolHolidayCatalog } from './useSchoolHolidayCatalog' +import SchoolHolidayRegionEditor, { holidayButtonClass, holidayInputClass } from './SchoolHolidayRegionEditor' +import ConfirmDialog from '../shared/ConfirmDialog' +import ErrorBoundary from '../shared/ErrorBoundary' +import CustomSelect from '../shared/CustomSelect' + +export default function SchoolHolidayCatalog() { + return +} + +function Catalog() { + const { t } = useTranslation() + const state = useSchoolHolidayCatalog() + const [country, setCountry] = useState('') + const [adding, setAdding] = useState(false) + const [code, setCode] = useState('') + const [name, setName] = useState('') + const [remove, setRemove] = useState<{ label: string; action: () => Promise } | null>(null) + const selectedCountry = state.catalog.countries.find(item => item.code === country)?.code || state.catalog.countries[0]?.code || '' + const regions = state.catalog.regions.filter(region => region.country === selectedCountry) + const disabled = state.busy || state.loading || state.offline + const addRegion = () => state.setEditor({ id: 0, country: selectedCountry, name: '', code: '', revision: 0, holidays: [] }) + + return
    +
    +

    {t('schoolCatalog.title')}

    +

    {t('schoolCatalog.hint')}

    +
    + {state.offline &&

    {t('schoolCatalog.offline')}

    } + {state.loading &&

    {t('common.loading')}

    } + {state.error && !state.editor &&
    {state.error}
    } +
    + {state.catalog.countries.length > 0 &&
    +
    {t('schoolCatalog.country')} + setCountry(String(value))} options={state.catalog.countries.map(item => ({ value: item.code, label: item.name }))} placeholder={t('schoolCatalog.country')} searchable disabled={disabled} /> +
    + + +
    } + {!adding && state.catalog.countries.length === 0 && } + {adding &&
    { + event.preventDefault() + if (await state.createCountry({ code, name: name.trim() })) { setCountry(code); setAdding(false); setCode(''); setName('') } + }}> + + +
    +
    } + {selectedCountry && <> + {regions.length === 0 &&

    {t('schoolCatalog.empty')}

    } +
      {regions.map(region =>
    • + + + +
    • )}
    + {regions.length === 0 && } + } +
    + {state.editor && state.setEditor(null)} onSave={body => { if (state.editor) void state.saveRegion(state.editor.country, state.editor.id, body) }} />} + setRemove(null)} title={t('common.delete')} message={`${remove?.label || ''}. ${t('schoolCatalog.deleteHint')}`} onConfirm={() => { if (remove) void remove.action(); setRemove(null) }} /> +
    +} diff --git a/client/src/components/Admin/SchoolHolidayRegionEditor.tsx b/client/src/components/Admin/SchoolHolidayRegionEditor.tsx new file mode 100644 index 000000000..08ffeeb8a --- /dev/null +++ b/client/src/components/Admin/SchoolHolidayRegionEditor.tsx @@ -0,0 +1,75 @@ +import { useId, useState } from 'react' +import { Plus, Trash2 } from 'lucide-react' +import { schoolHolidayRegionRequestSchema, type SchoolHolidayRegionDetail, type SchoolHolidayRegionRequest } from '@trek/shared' +import { useTranslation } from '../../i18n' +import Modal from '../shared/Modal' +import ConfirmDialog from '../shared/ConfirmDialog' +import { CustomDatePicker } from '../shared/CustomDateTimePicker' + +export const holidayInputClass = 'w-full rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content' +export const holidayButtonClass = 'rounded-lg border border-edge px-3 py-2 text-body font-medium text-content hover:bg-surface-hover disabled:opacity-50' + +export default function SchoolHolidayRegionEditor({ region, busy, offline = false, error, onClose, onSave }: { + region: SchoolHolidayRegionDetail + busy: boolean + offline?: boolean + error: string + onClose: () => void + onSave: (body: SchoolHolidayRegionRequest) => void +}) { + const { t } = useTranslation() + const fieldId = useId() + const [name, setName] = useState(region.name) + const [holidays, setHolidays] = useState(region.holidays.map((holiday, index) => ({ ...holiday, key: index }))) + const [nextKey, setNextKey] = useState(holidays.length) + const [discard, setDiscard] = useState(false) + const [invalid, setInvalid] = useState(false) + const body = { name, revision: region.revision, holidays: holidays.map(({ name, startDate, endDate }) => ({ name, startDate, endDate })) } + const dirty = name !== region.name || JSON.stringify(body.holidays) !== JSON.stringify(region.holidays) + const close = () => { if (!busy) { if (dirty) setDiscard(true); else onClose() } } + + return <> + +
    { + event.preventDefault() + const parsed = schoolHolidayRegionRequestSchema.safeParse(body) + setInvalid(!parsed.success) + if (parsed.success) onSave(parsed.data) + }}> +
    + +

    {t('schoolCatalog.periodHint')}

    + {holidays.map((holiday, index) =>
    +
    + +
    + setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, name: event.target.value } : row))} /> + +
    +
    +
    +
    {t('schoolCatalog.start')} + setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, startDate } : row))} /> +
    +
    {t('schoolCatalog.end')} + setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, endDate } : row))} /> +
    +
    +
    )} + + {invalid &&

    {t('schoolCatalog.invalid')}

    } + {error &&

    {error}

    } +
    +
    + + +
    +
    +
    + setDiscard(false)} onConfirm={onClose} title={t('common.cancel')} message={t('schoolCatalog.discard')} /> + +} diff --git a/client/src/components/Admin/storage/AdminStoragePanel.test.tsx b/client/src/components/Admin/storage/AdminStoragePanel.test.tsx index 8c7141e2f..d68092a83 100644 --- a/client/src/components/Admin/storage/AdminStoragePanel.test.tsx +++ b/client/src/components/Admin/storage/AdminStoragePanel.test.tsx @@ -605,7 +605,12 @@ describe('AdminStoragePanel', () => { await screen.findByText('Storage configuration saved'); // files is default-sourced (uploads-local) in baseState() — stripping restores "no override". expect((putBody as StorageConfig).categories.files).toBeUndefined(); - expect(migrationBody).toEqual({ category: 'files', to: 'off-box' }); + // Waited for, not read straight after the toast. That toast comes from the + // PUT, while the migration POST is fired by a later effect reacting to the + // refreshed admin.state, so the two are not the same tick. Reading it + // immediately only held while that effect happened to flush first, which + // under load it does not. + await waitFor(() => expect(migrationBody).toEqual({ category: 'files', to: 'off-box' })); expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); }); @@ -676,7 +681,8 @@ describe('AdminStoragePanel', () => { await screen.findByText('Storage configuration saved'); // The POST carries the mirror's raw wire name, not 'backups-local' — // posting the bare primary would silently drop replication. - expect(migrationBody).toEqual({ category: 'files', to: 'mirror' }); + // Waited for, for the same reason as FE-ADMIN-STOR-030 above. + await waitFor(() => expect(migrationBody).toEqual({ category: 'files', to: 'mirror' })); }); it('FE-ADMIN-STOR-037: a queued migration waits out a running backfill (the server 409s on either while the other runs)', async () => { diff --git a/client/src/components/Admin/storage/AdminStoragePanel.tsx b/client/src/components/Admin/storage/AdminStoragePanel.tsx index 954ef6a2c..0feae34f7 100644 --- a/client/src/components/Admin/storage/AdminStoragePanel.tsx +++ b/client/src/components/Admin/storage/AdminStoragePanel.tsx @@ -126,8 +126,7 @@ export default function AdminStoragePanel(): React.ReactElement { useEffect(() => { if (migrationQueue.length === 0 || !admin.state) return if (migrationStartInFlight.current) return - if (admin.state.migrations.some((m) => m.status === 'running')) return - if (admin.state.backfills.some((b) => b.status === 'running')) return + if (admin.storageBusy()) return const [next, ...rest] = migrationQueue migrationStartInFlight.current = true setMigrationQueue(rest) diff --git a/client/src/components/Admin/storage/useStorageAdmin.test.tsx b/client/src/components/Admin/storage/useStorageAdmin.test.tsx new file mode 100644 index 000000000..e58889810 --- /dev/null +++ b/client/src/components/Admin/storage/useStorageAdmin.test.tsx @@ -0,0 +1,96 @@ +import React, { useEffect } from 'react'; +import { http, HttpResponse } from 'msw'; +import { describe, expect, it } from 'vitest'; +import type { StorageAdminState } from '@trek/shared'; +import { server } from '../../../../tests/helpers/msw/server'; +import { render, screen, waitFor } from '../../../../tests/helpers/render'; +import { useStorageAdmin, type StorageAdmin } from './useStorageAdmin'; + +function baseState(overrides: Partial = {}): StorageAdminState { + return { + backends: [ + { name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files'] }, + ], + categories: { files: { backend: 'uploads-local', source: 'default' } } as StorageAdminState['categories'], + health: { replicaFailures: [] }, + seedFilePresent: false, + usage: null, + backfills: [], + migrations: [], + version: 0, + ...overrides, + }; +} + +function runningState(): StorageAdminState { + return baseState({ + migrations: [ + { category: 'files', from: 'uploads-local', to: 'off-box', status: 'running', done: 1, total: 3, copied: 1, skipped: 0, failed: 0, startedAt: 1 }, + ], + }); +} + +/** + * Exposes the hook to the test without a panel in the way — what the panels' + * queue effect depends on is the hook's own timing, not any rendered output. + */ +function Probe({ onReady }: { onReady: (admin: StorageAdmin) => void }): React.ReactElement { + const admin = useStorageAdmin('generic', 'conflict'); + // After every render, not during one: the test wants whatever the latest + // render produced, and a render must stay free of side effects. + useEffect(() => { onReady(admin); }); + return
    {admin.state ? 'loaded' : 'loading'}
    ; +} + +describe('useStorageAdmin', () => { + async function mount(): Promise<() => StorageAdmin> { + let latest: StorageAdmin | null = null; + render( { latest = a; }} />); + await screen.findByText('loaded'); + return () => latest!; + } + + it('FE-ADMIN-STOR-048: startMigration resolves only once storageBusy() already reports the started migration', async () => { + let posted = false; + server.use( + http.get('/api/admin/storage', () => HttpResponse.json(posted ? runningState() : baseState())), + http.post('/api/admin/storage/migrations', () => { + posted = true; + return HttpResponse.json({ started: true }); + }), + ); + const admin = await mount(); + expect(admin().storageBusy()).toBe(false); + + // The queue effect releases its single-flight lock the moment this + // resolves, so the world it then decides on must already be post-POST. + expect(await admin().startMigration('files', 'off-box')).toBeNull(); + expect(admin().storageBusy()).toBe(true); + }); + + it('FE-ADMIN-STOR-049: storageBusy() is current at response time, before the render that shows it', async () => { + server.use(http.get('/api/admin/storage', () => HttpResponse.json(runningState()))); + const admin = await mount(); + const before = admin(); + + // No await on a render: refreshState resolving is the only synchronisation + // point, and the ref must already carry the new world at that instant — + // that is the whole point of it, since a deferred commit can run an effect + // whose captured `state` is several responses behind. + await before.refreshState(); + expect(before.storageBusy()).toBe(true); + await waitFor(() => expect(admin().state!.migrations).toHaveLength(1)); + }); + + it('FE-ADMIN-STOR-050: a failed refresh leaves storageBusy() on the last world that was actually applied', async () => { + server.use(http.get('/api/admin/storage', () => HttpResponse.json(runningState()))); + const admin = await mount(); + expect(admin().storageBusy()).toBe(true); + server.use(http.get('/api/admin/storage', () => HttpResponse.error())); + + // A transient GET failure must not read as "the server went idle" — that + // is the one answer that would let a second migration start. + await expect(admin().refreshState()).rejects.toThrow(); + expect(admin().storageBusy()).toBe(true); + }); +}); diff --git a/client/src/components/Admin/storage/useStorageAdmin.ts b/client/src/components/Admin/storage/useStorageAdmin.ts index 880122493..85a8dd58f 100644 --- a/client/src/components/Admin/storage/useStorageAdmin.ts +++ b/client/src/components/Admin/storage/useStorageAdmin.ts @@ -16,6 +16,20 @@ function isConflictError(err: unknown): boolean { export interface StorageAdmin { state: StorageAdminState | null + /** + * Whether the server is already busy with a storage job: a migration or a + * backfill, since its one-job-at-a-time rule spans both. Answered off the + * last response rather than off `state`, so it is current the instant that + * response lands rather than on the commit that renders it. + * + * That difference is the whole point. An effect re-run by a DEFERRED commit + * still closes over the `state` of the render it belongs to, which under load + * is several responses old by the time it runs, and the queue effect deciding + * on that closure POSTed a second migration on top of a running one. Anything + * deciding whether to fire a request asks this; anything rendering reads + * `state`. Never called during render. + */ + storageBusy: () => boolean /** The settings-owned document (the PUT body), with local edits layered in. Carries the version the draft was built at. */ draft: StorageConfigPut | null dirty: boolean @@ -85,6 +99,15 @@ export function useStorageAdmin(genericError: string, conflictError: string): St savingRef.current = saving }, [saving]) + // Written by every state application, at response time rather than at commit + // time, and read back through storageBusy(). See the interface doc. + const stateRef = useRef(null) + const storageBusy = useCallback((): boolean => { + const live = stateRef.current + if (!live) return false + return live.migrations.some((m) => m.status === 'running') || live.backfills.some((b) => b.status === 'running') + }, []) + // Bumped by every deliberate state application (initial load, save // success). A refreshState() call captures the seq before its GET and // drops the response if the seq moved on — i.e. a save applied its own @@ -93,6 +116,7 @@ export function useStorageAdmin(genericError: string, conflictError: string): St const applyState = useCallback((next: StorageAdminState) => { stateSeq.current += 1 + stateRef.current = next setState(next) setDraftState(settingsDocumentOf(next)) setDirty(false) @@ -145,6 +169,7 @@ export function useStorageAdmin(genericError: string, conflictError: string): St const seq = stateSeq.current const next = await adminApi.getStorage() if (stateSeq.current !== seq || savingRef.current) return + stateRef.current = next setState(next) if (!dirtyRef.current) setDraftState(settingsDocumentOf(next)) }, []) @@ -317,7 +342,14 @@ export function useStorageAdmin(genericError: string, conflictError: string): St const refreshStats = useCallback(async (): Promise => { try { const usage = await adminApi.refreshStorageStats() - setState((prev) => (prev ? { ...prev, usage } : prev)) + // Off stateRef rather than a functional updater, so the ref and the + // rendered state stay one world: every writer here sets both. + const prev = stateRef.current + if (prev) { + const next = { ...prev, usage } + stateRef.current = next + setState(next) + } return null } catch (err: unknown) { return getApiErrorMessage(err, genericError) @@ -326,6 +358,7 @@ export function useStorageAdmin(genericError: string, conflictError: string): St return { state, + storageBusy, draft, dirty, loading, diff --git a/client/src/components/Admin/useInstanceSettings.test.ts b/client/src/components/Admin/useInstanceSettings.test.ts new file mode 100644 index 000000000..230ce3daa --- /dev/null +++ b/client/src/components/Admin/useInstanceSettings.test.ts @@ -0,0 +1,152 @@ +// FE-COMP-PLUGINS-ACT-001 to -006 — the shared instance-settings hook, action side. +import { act, renderHook, waitFor } from '@testing-library/react' +import { adminApi } from '../../api/client' +import { useInstanceSettings } from './useInstanceSettings' + +vi.mock('../shared/Toast', () => ({ useToast: () => ({ success: vi.fn(), error: vi.fn() }) })) +vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) })) + +const FIELDS = [{ key: 'apiUrl', label: 'API URL', input_type: 'text', required: false, secret: false }] +const PURGE = { key: 'purge', label: 'Purge', danger: true, scope: 'instance' as const } +const PING = { key: 'ping', label: 'Ping', danger: false, scope: 'instance' as const } + +beforeEach(() => { + vi.spyOn(adminApi, 'pluginConfig').mockResolvedValue({ fields: FIELDS, config: { apiUrl: 'https://a.example' }, actions: [PING, PURGE] }) + vi.spyOn(adminApi, 'pluginSaveConfig').mockResolvedValue({ config: { apiUrl: 'https://b.example' }, restarted: false }) + vi.spyOn(adminApi, 'runPluginAction').mockResolvedValue({ ok: true, message: 'pong' }) +}) +afterEach(() => vi.restoreAllMocks()) + +async function opened(active = true) { + const hook = renderHook(() => useInstanceSettings()) + act(() => hook.result.current.open('p', active)) + await waitFor(() => expect(hook.result.current.form).not.toBeNull()) + return hook +} + +/** A promise this test controls the settling of, to pin down state mid-flight. */ +function deferred() { + let resolve!: (v: T) => void + const promise = new Promise(r => { resolve = r }) + return { promise, resolve } +} + +describe('useInstanceSettings — actions', () => { + it('FE-COMP-PLUGINS-ACT-001: open carries the instance actions, the active flag, and a clean form', async () => { + const { result } = await opened() + expect(result.current.form?.actions).toEqual([PING, PURGE]) + expect(result.current.form?.active).toBe(true) + expect(result.current.form?.dirty).toBe(false) + }) + + it('FE-COMP-PLUGINS-ACT-002: a clean form runs the action straight away and keeps the dialog open', async () => { + const { result } = await opened() + act(() => result.current.runAction(PING)) + await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' })) + expect(adminApi.pluginSaveConfig).not.toHaveBeenCalled() + expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping') + expect(result.current.form).not.toBeNull() + expect(result.current.runningAction).toBeNull() + }) + + it('FE-COMP-PLUGINS-ACT-003: a dirty form saves first, then runs, and is clean afterwards', async () => { + const { result } = await opened() + act(() => result.current.setValue('apiUrl', 'https://b.example')) + expect(result.current.form?.dirty).toBe(true) + act(() => result.current.runAction(PING)) + await waitFor(() => expect(result.current.actionResult.ping).toBeDefined()) + expect(adminApi.pluginSaveConfig).toHaveBeenCalledWith('p', { apiUrl: 'https://b.example' }) + expect(vi.mocked(adminApi.pluginSaveConfig).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(adminApi.runPluginAction).mock.invocationCallOrder[0]) + expect(result.current.form?.dirty).toBe(false) + expect(result.current.form).not.toBeNull() + }) + + it('FE-COMP-PLUGINS-ACT-004: a save failure surfaces in error and the action never runs', async () => { + vi.mocked(adminApi.pluginSaveConfig).mockRejectedValue({ response: { data: { error: 'nope' } } }) + const { result } = await opened() + act(() => result.current.setValue('apiUrl', 'x')) + act(() => result.current.runAction(PING)) + await waitFor(() => expect(result.current.error).toBe('nope')) + expect(adminApi.runPluginAction).not.toHaveBeenCalled() + }) + + it('FE-COMP-PLUGINS-ACT-005: a danger action waits for confirmation; cancel drops it, confirm runs it', async () => { + const { result } = await opened() + act(() => result.current.runAction(PURGE)) + expect(result.current.pendingAction).toEqual(PURGE) + expect(adminApi.runPluginAction).not.toHaveBeenCalled() + act(() => result.current.cancelPendingAction()) + expect(result.current.pendingAction).toBeNull() + act(() => result.current.runAction(PURGE)) + act(() => result.current.confirmPendingAction()) + await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'purge')) + expect(result.current.pendingAction).toBeNull() + }) + + it('FE-COMP-PLUGINS-ACT-006: an inactive plugin never posts, and a failed result is stored as such', async () => { + const { result } = await opened(false) + act(() => result.current.runAction(PING)) + await new Promise(r => setTimeout(r, 0)) + expect(adminApi.runPluginAction).not.toHaveBeenCalled() + + vi.mocked(adminApi.runPluginAction).mockResolvedValue({ ok: false, message: 'credentials rejected' }) + const active = await opened(true) + act(() => active.result.current.runAction(PING)) + await waitFor(() => expect(active.result.current.actionResult.ping).toEqual({ ok: false, message: 'credentials rejected' })) + }) + + it('FE-COMP-PLUGINS-ACT-007: an edit made while the pre-action save is in flight is not clobbered when dirty clears', async () => { + const savePromise = deferred<{ config: Record; restarted: boolean }>() + vi.mocked(adminApi.pluginSaveConfig).mockReturnValue(savePromise.promise) + const { result } = await opened() + act(() => result.current.setValue('apiUrl', 'https://a-edit.example')) + act(() => result.current.runAction(PING)) + await waitFor(() => expect(adminApi.pluginSaveConfig).toHaveBeenCalledWith('p', { apiUrl: 'https://a-edit.example' })) + + // A concurrent edit lands while the save is still pending. + act(() => result.current.setValue('apiUrl', 'https://b-edit.example')) + + await act(async () => { + savePromise.resolve({ config: { apiUrl: 'https://a-edit.example' }, restarted: false }) + await savePromise.promise + }) + + await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping')) + expect(result.current.form?.dirty).toBe(true) + expect(result.current.form?.values.apiUrl).toBe('https://b-edit.example') + }) + + it('FE-COMP-PLUGINS-ACT-008: an in-flight action from a closed dialog does not leak into the next one', async () => { + const runPromise = deferred<{ ok: boolean; message?: string }>() + vi.mocked(adminApi.runPluginAction).mockReturnValueOnce(runPromise.promise) + const { result } = await opened() + act(() => result.current.runAction(PING)) + await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping')) + + act(() => result.current.close()) + act(() => result.current.open('q', true)) + await waitFor(() => expect(result.current.form?.id).toBe('q')) + expect(result.current.runningAction).toBeNull() + + act(() => result.current.runAction(PING)) + await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('q', 'ping')) + await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' })) + + // The stale request from the closed dialog settles after the fact — it must not overwrite the new dialog's result. + await act(async () => { + runPromise.resolve({ ok: true, message: 'stale pong' }) + await runPromise.promise + }) + expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' }) + }) + + it('FE-COMP-PLUGINS-ACT-009: a 404 "Plugin is not active" from the server (DB active, no live child) shows the inactive message and flips active off', async () => { + vi.mocked(adminApi.runPluginAction).mockRejectedValue({ response: { status: 404, data: { error: 'Plugin is not active' } } }) + const { result } = await opened(true) + expect(result.current.form?.active).toBe(true) + act(() => result.current.runAction(PING)) + await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: false, message: 'admin.plugins.actions.inactive' })) + expect(result.current.form?.active).toBe(false) + expect(result.current.runningAction).toBeNull() + }) +}) diff --git a/client/src/components/Admin/useInstanceSettings.ts b/client/src/components/Admin/useInstanceSettings.ts new file mode 100644 index 000000000..23db2e09b --- /dev/null +++ b/client/src/components/Admin/useInstanceSettings.ts @@ -0,0 +1,147 @@ +import { useRef, useState } from 'react' +import { adminApi, type PluginAction, type PluginUserSettingField } from '../../api/client' +import type { PluginActionResult } from '@trek/shared' +import { useToast } from '../shared/Toast' +import { useTranslation } from '../../i18n' +import { seedSettingsValues, findMissingRequired, settingsPatch } from '../Plugins/settingsForm' + +export interface InstanceSettingsForm { + id: string + fields: PluginUserSettingField[] + values: Record + /** The plugin's `scope:'instance'` action buttons. */ + actions: PluginAction[] + /** Whether the plugin is active: an action needs a running child to execute. */ + active: boolean + /** An edit since open/last save. An action saves a dirty form before it runs. */ + dirty: boolean +} + +/** + * The admin-owned `scope:'instance'` settings form — the ONE logic path behind + * both admin shells (the desktop modal and the phone sheet render their own + * markup over this state). Open fetches the declared fields plus the stored + * (masked) values and the instance-scope actions; save skips an untouched secret + * mask so it never overwrites the stored ciphertext, and reports a restart when + * the server re-spawned a running plugin (the child reads its config once, at init). + * + * Actions run AS the clicking admin. A dirty form is saved first (so the action + * sees the config the admin is looking at), the dialog stays open, and the result + * shows beside the button. A `danger` action parks in `pendingAction` until the + * shell's confirm UI calls `confirmPendingAction`. + */ +export function useInstanceSettings() { + const { t } = useTranslation() + const toast = useToast() + const [form, setForm] = useState(null) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + const [actionResult, setActionResult] = useState>({}) + const [runningAction, setRunningAction] = useState(null) + const [pendingAction, setPendingAction] = useState(null) + // Bumped on every open()/close() so an in-flight action from a closed/replaced + // dialog can tell it's stale and bail without touching the new dialog's state. + const openSeq = useRef(0) + + const open = (id: string, active: boolean) => { + openSeq.current++ + setError('') + setActionResult({}) + setPendingAction(null) + setRunningAction(null) + adminApi.pluginConfig(id) + .then(d => { + setForm({ id, fields: d.fields, values: seedSettingsValues(d.fields, d.config), actions: d.actions ?? [], active, dirty: false }) + }) + .catch(() => toast.error(t('common.error'))) + } + + const setValue = (key: string, value: string | boolean) => + setForm(s => s && ({ ...s, values: { ...s.values, [key]: value }, dirty: true })) + + const close = () => { openSeq.current++; setForm(null); setPendingAction(null); setRunningAction(null) } + + /** Persist the form. Returns false when the save was refused (error already shown). */ + const persist = async (f: InstanceSettingsForm): Promise => { + const missing = findMissingRequired(f.fields, f.values) + if (missing) { + setError(t('admin.plugins.requiredMissing', { field: missing.label || missing.key })) + return false + } + setSaving(true); setError('') + try { + const d = await adminApi.pluginSaveConfig(f.id, settingsPatch(f.fields, f.values)) + toast.success(t(d.restarted ? 'admin.plugins.settingsSavedRestarted' : 'admin.plugins.settingsSaved')) + return true + } catch (e) { + const err = e as { response?: { data?: { error?: string } } } + setError(err.response?.data?.error || t('common.error')) + return false + } finally { + setSaving(false) + } + } + + const save = async () => { + if (!form) return + if (await persist(form)) setForm(null) + } + + const perform = async (a: PluginAction) => { + if (!form || !form.active || runningAction) return + const seq = openSeq.current + // Set BEFORE the dirty-save window, not just around the invoke call, so a second + // click (or the shells' Save button, see M7) is blocked for the whole run — not just + // once the pre-run save has already gone out. + setRunningAction(a.key) + try { + if (form.dirty) { + if (!(await persist(form))) return + if (openSeq.current !== seq) return + // Only clear dirty if the values are still the snapshot we just persisted — + // a reference check, since setValue always creates a new values object. A + // concurrent edit during the save produced a new object, so dirty stays true + // and the edit isn't silently dropped from the next save/action. + setForm(s => s && s.values === form.values ? { ...s, dirty: false } : s) + } + if (openSeq.current !== seq) return + const res = await adminApi.runPluginAction(form.id, a.key) + if (openSeq.current !== seq) return + setActionResult(r => ({ ...r, [a.key]: res })) + } catch (e) { + if (openSeq.current !== seq) return + const err = e as { response?: { status?: number; data?: { error?: string } } } + if (err.response?.status === 404 && err.response?.data?.error === 'Plugin is not active') { + // The DB `status` column said active when the dialog opened, but the server's + // live-process check (the supervisor's actual child map) disagrees — a stale + // row with no running child. Believe the server: show the real reason instead + // of a bare "Error", and disable the buttons instead of leaving them clickable. + setActionResult(r => ({ ...r, [a.key]: { ok: false, message: t('admin.plugins.actions.inactive') } })) + setForm(s => s && { ...s, active: false }) + } else { + setActionResult(r => ({ ...r, [a.key]: { ok: false, message: t('common.error') } })) + } + } finally { + if (openSeq.current === seq) setRunningAction(null) + } + } + + const runAction = (a: PluginAction) => { + if (!form || !form.active) return + if (a.danger) { setPendingAction(a); return } + void perform(a) + } + + const confirmPendingAction = () => { + const a = pendingAction + setPendingAction(null) + if (a) void perform(a) + } + + const cancelPendingAction = () => setPendingAction(null) + + return { + form, saving, error, open, setValue, close, save, + actionResult, runningAction, pendingAction, runAction, confirmPendingAction, cancelPendingAction, + } +} diff --git a/client/src/components/Admin/useRangeBypass.test.ts b/client/src/components/Admin/useRangeBypass.test.ts new file mode 100644 index 000000000..979ffdad5 --- /dev/null +++ b/client/src/components/Admin/useRangeBypass.test.ts @@ -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) => (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() + }) +}) diff --git a/client/src/components/Admin/useRangeBypass.ts b/client/src/components/Admin/useRangeBypass.ts new file mode 100644 index 000000000..5a55a04ca --- /dev/null +++ b/client/src/components/Admin/useRangeBypass.ts @@ -0,0 +1,90 @@ +import { useState } from 'react' +import { AlertTriangle } from 'lucide-react' +import { useTranslation } from '../../i18n' + +type T = (k: string, p?: Record) => 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(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, + } +} diff --git a/client/src/components/Admin/useSchoolHolidayCatalog.ts b/client/src/components/Admin/useSchoolHolidayCatalog.ts new file mode 100644 index 000000000..a78a2846c --- /dev/null +++ b/client/src/components/Admin/useSchoolHolidayCatalog.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { SchoolHolidayCatalog, SchoolHolidayCountryRequest, SchoolHolidayRegionDetail, SchoolHolidayRegionRequest } from '@trek/shared' +import { schoolHolidayCatalogRepo as catalogRepo } from '../../repo/schoolHolidayCatalogRepo' +import { useTranslation } from '../../i18n' +import { useNetworkMode } from '../../hooks/useNetworkMode' + +export function useSchoolHolidayCatalog() { + const { t } = useTranslation() + const { offline } = useNetworkMode() + const [catalog, setCatalog] = useState({ countries: [], regions: [] }) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [editor, setEditor] = useState(null) + const mounted = useRef(true) + const pending = useRef(false) + useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, []) + + const reload = useCallback(async () => { + catalogRepo.requireOnline() + const catalog = await catalogRepo.catalog() + if (mounted.current) setCatalog(catalog) + }, []) + + useEffect(() => { + let cancelled = false + if (offline) { setLoading(false); return } + setLoading(true) + catalogRepo.catalog().then(catalog => { + if (!cancelled) { setCatalog(catalog); setError('') } + }).catch(() => { if (!cancelled) setError(t('common.error')) }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [offline, t]) + + async function run(action: () => Promise, after?: () => void) { + if (pending.current) return false + pending.current = true + setBusy(true) + setError('') + try { + catalogRepo.requireOnline() + await action() + if (mounted.current) after?.() + try { + await reload() + } catch { + if (mounted.current) setError(t('schoolCatalog.refreshError')) + } + return true + } catch (failure) { + if (mounted.current) { + const message = failure instanceof Error ? failure.message : '' + const http = failure as { response?: { data?: { error?: string } } } + setError(http.response?.data?.error || message || t('common.error')) + } + return false + } finally { + pending.current = false + if (mounted.current) setBusy(false) + } + } + + return { + catalog, loading, busy, error, offline, editor, setEditor, + refresh: () => run(async () => {}), + createCountry: (country: SchoolHolidayCountryRequest) => run(() => catalogRepo.createCountry(country)), + deleteCountry: (code: string) => run(() => catalogRepo.deleteCountry(code)), + openRegion: (id: number) => run(async () => { + const region = await catalogRepo.region(id) + if (mounted.current) setEditor(region) + }), + saveRegion: (country: string, id: number, body: SchoolHolidayRegionRequest) => run( + () => id ? catalogRepo.updateRegion(id, body) : catalogRepo.createRegion(country, body), + () => setEditor(null), + ), + deleteRegion: (id: number, revision: number) => run(() => catalogRepo.deleteRegion(id, revision)), + } +} diff --git a/client/src/components/Atlas/useGlassGlare.ts b/client/src/components/Atlas/useGlassGlare.ts new file mode 100644 index 000000000..540401de7 --- /dev/null +++ b/client/src/components/Atlas/useGlassGlare.ts @@ -0,0 +1,48 @@ +import { useRef, type RefObject } from 'react' + +export interface GlassGlare { + /** Goes on the panel itself — the glare is measured against its box. */ + panelRef: RefObject + /** The soft light under the cursor. */ + glareRef: RefObject + /** The lit stretch of border around it. */ + borderGlareRef: RefObject + onMouseMove: (e: { clientX: number; clientY: number }) => void + onMouseLeave: () => void +} + +/** + * The Atlas panels' liquid-glass hover: a pool of light under the cursor and the + * piece of border nearest to it lighting up. + * + * Extracted so a second panel beside the first one behaves identically. Two + * hand-copied versions of a gradient are two versions that drift, and the whole + * point of the effect is that the panels look like one material. + * + * Written straight to the DOM rather than through state: this runs on every + * mousemove, and a re-render per frame would cost more than the effect is worth. + */ +export function useGlassGlare(dark: boolean): GlassGlare { + const panelRef = useRef(null) + const glareRef = useRef(null) + const borderGlareRef = useRef(null) + + const onMouseMove = (e: { clientX: number; clientY: number }): void => { + if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return + const rect = panelRef.current.getBoundingClientRect() + const x = e.clientX - rect.left + const y = e.clientY - rect.top + glareRef.current.style.background = `radial-gradient(circle 300px at ${x}px ${y}px, ${dark ? 'rgba(255,255,255,0.025)' : 'rgba(255,255,255,0.25)'} 0%, transparent 70%)` + glareRef.current.style.opacity = '1' + borderGlareRef.current.style.opacity = '1' + borderGlareRef.current.style.maskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)` + borderGlareRef.current.style.webkitMaskImage = `radial-gradient(circle 150px at ${x}px ${y}px, black 0%, transparent 100%)` + } + + const onMouseLeave = (): void => { + if (glareRef.current) glareRef.current.style.opacity = '0' + if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0' + } + + return { panelRef, glareRef, borderGlareRef, onMouseMove, onMouseLeave } +} diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx index c8ad05d7b..b48fe3221 100644 --- a/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.extra.test.tsx @@ -1,4 +1,4 @@ -// FE-W4BGT-001 to FE-W4BGT-022 +// FE-W4BGT-001 to FE-W4BGT-024 import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { screen, act, waitFor } from '@testing-library/react' import { render, fireEvent } from '../../../tests/helpers/render' @@ -276,3 +276,31 @@ describe('BackgroundTasksWidget — AI retry', () => { expect(await screen.findByText('No model configured')).toBeInTheDocument() }) }) + +/** + * The reporter's phone showed a green tick beside "No reservations could be + * extracted" and two warnings (#2477). A parse that found nothing is not a + * success, so it wears the warning mark; the tick stays for a parse that did. + */ +describe('BackgroundTasksWidget: the mark on a finished job (#2477)', () => { + it('FE-W4BGT-023: a job that found nothing shows the warning mark, not the success tick', () => { + useBackgroundTasksStore.setState({ + tasks: [task({ items: [], warnings: ['Incomplete LodgingReservation in booking.pdf[0] (no reservationFor)'] })], + }) + const { baseElement } = render() + + expect(screen.getByText('No reservations could be extracted from the uploaded files.')).toBeInTheDocument() + expect(baseElement.querySelector('svg.lucide-alert-triangle')).toHaveAttribute('stroke', 'var(--warning)') + expect(baseElement.querySelector('svg.lucide-check-circle2')).toBeNull() + expect(baseElement.querySelector('.animate-spin')).toBeNull() + }) + + it('FE-W4BGT-024: a job with items keeps the success tick, even with warnings', () => { + useBackgroundTasksStore.setState({ tasks: [task({ items: [{ id: 1 }] as never, warnings: ['could not locate "X"'] })] }) + const { baseElement } = render() + + // The theme's own success colour, so it follows light and dark. + expect(baseElement.querySelector('svg.lucide-check-circle2')).toHaveAttribute('stroke', 'var(--success)') + expect(baseElement.querySelector('svg.lucide-alert-triangle')).toBeNull() + }) +}) diff --git a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx index 55ec57249..d6de54f34 100644 --- a/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx +++ b/client/src/components/BackgroundTasks/BackgroundTasksWidget.tsx @@ -1,7 +1,7 @@ import { createPortal } from 'react-dom' import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router' -import { Loader2, CheckCircle2, AlertCircle, X } from 'lucide-react' +import { Loader2, CheckCircle2, AlertCircle, AlertTriangle, X } from 'lucide-react' import { useTranslation } from '../../i18n' import { addListener, removeListener } from '../../api/websocket' import { reservationsApi, healthApi } from '../../api/client' @@ -141,8 +141,12 @@ export default function BackgroundTasksWidget() { >
    {(task.status === 'running' || (task.status === 'done' && task.items === undefined)) && } - {task.status === 'done' && task.items !== undefined && } - {task.status === 'error' && } + {task.status === 'done' && task.items !== undefined && task.items.length > 0 && } + {/* A parse that found nothing is not a success, whatever the job + status says: the same card used to show a green tick next to + "no reservations could be extracted" (#2477). */} + {task.status === 'done' && task.items?.length === 0 && } + {task.status === 'error' && }
    diff --git a/client/src/components/Budget/CostsPanel.helpers.test.ts b/client/src/components/Budget/CostsPanel.helpers.test.ts index 31c97c102..bd655873f 100644 --- a/client/src/components/Budget/CostsPanel.helpers.test.ts +++ b/client/src/components/Budget/CostsPanel.helpers.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { calculateTicketShares, hasTicketSplit, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitCents, writeTicketItems, type TicketItem } from './CostsPanel.helpers' +import { calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, paidByUser, payerSum, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, splitCents, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' describe('splitCents', () => { it('splits evenly when it divides cleanly', () => { @@ -16,8 +16,12 @@ describe('splitCents', () => { expect(splitCents(50, 0)).toEqual([]) }) - it('floors a negative amount at zero rather than inventing debt', () => { - expect(splitCents(-10, 2)).toEqual([0, 0]) + it('splits a negative amount exactly instead of clamping it to zero (#2176)', () => { + // A refund spread across payers has to sum back too — the old Math.max(0, …) + // clamp silently swallowed the sign and broke rebalancePayers for refunds. + expect(splitCents(-10, 2)).toEqual([-5, -5]) + const parts = splitCents(-100.01, 3) + expect(parts.reduce((a, b) => a + Math.round(b * 100), 0)).toBe(-10001) }) }) @@ -75,6 +79,57 @@ describe('rebalancePayers', () => { const next = rebalancePayers({ 1: '33.33' }, new Set([1]), new Set([1, 2, 3]), 100) expect(payersBalanced(next, new Set([1, 2, 3]), 100)).toBe(true) }) + + // ── Refunds: negative totals rebalance like any other (#2176) ────────────── + + it('spreads a negative total across payers so the parts stay typeable', () => { + const next = rebalancePayers({}, new Set(), new Set([1, 2]), -90) + expect(next).toEqual({ 1: '-45.00', 2: '-45.00' }) + expect(payersBalanced(next, new Set([1, 2]), -90)).toBe(true) + }) + + it('lets a free payer absorb the rest of a negative total past a pinned amount', () => { + const next = rebalancePayers({ 1: '-70' }, new Set([1]), new Set([1, 2]), -100) + expect(next[1]).toBe('-70') + expect(next[2]).toBe('-30.00') + }) + + it('handles a pinned positive payer against a negative total', () => { + // Alice fronted 50 but the entry nets to -100 — the free payer carries -150. + const next = rebalancePayers({ 1: '50' }, new Set([1]), new Set([1, 2]), -100) + expect(next[2]).toBe('-150.00') + expect(payersBalanced(next, new Set([1, 2]), -100)).toBe(true) + }) +}) + +// ── Client/server share parity (#2176) ─────────────────────────────────────── +// +// splitEqualShares exists twice: here (previewing the split in euros) and on the +// server (netting the settlement in cents — BudgetService.splitEqualShares in +// server/src/nest/budget/budget.service.ts). The fixture below is duplicated +// verbatim in server/tests/unit/nest/budget.service.calc.test.ts; if either +// implementation drifts — sign handling included — its copy of this table fails. +const SHARE_PARITY_FIXTURE: { totalCents: number; users: number[]; itemId: number; expected: Record }[] = [ + { totalCents: 10000, users: [1, 2, 3], itemId: 0, expected: { 1: 3334, 2: 3333, 3: 3333 } }, + { totalCents: 10000, users: [1, 2, 3], itemId: 1, expected: { 1: 3333, 2: 3334, 3: 3333 } }, + { totalCents: -10000, users: [1, 2, 3], itemId: 0, expected: { 1: -3333, 2: -3333, 3: -3334 } }, + { totalCents: -10000, users: [1, 2, 3], itemId: 1, expected: { 1: -3334, 2: -3333, 3: -3333 } }, + { totalCents: -101, users: [1, 2], itemId: 0, expected: { 1: -50, 2: -51 } }, + { totalCents: -101, users: [1, 2], itemId: 1, expected: { 1: -51, 2: -50 } }, + { totalCents: -1, users: [1, 2, 3], itemId: 0, expected: { 1: 0, 2: 0, 3: -1 } }, +] + +describe('splitEqualShares — server parity (#2176)', () => { + it.each(SHARE_PARITY_FIXTURE)( + 'splits $totalCents cents across $users.length members (item $itemId) exactly like the server', + ({ totalCents, users, itemId, expected }) => { + const shares = splitEqualShares(totalCents / 100, users.map(user_id => ({ user_id })), itemId) + const inCents = Object.fromEntries(Object.entries(shares).map(([id, v]) => [id, Math.round(v * 100)])) + expect(inCents).toEqual(expected) + // The invariant behind the fixture: the shares always sum back to the total. + expect(Object.values(inCents).reduce((a, b) => a + b, 0)).toBe(totalCents) + }, + ) }) // ── Notes vs. itemized receipts (#1658) ────────────────────────────────────── @@ -133,6 +188,21 @@ describe('readUserNote', () => { }) }) +describe('settlementDate', () => { + it('prefers settled_at over created_at', () => { + expect(settlementDate({ settled_at: '2026-07-05', created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-05') + }) + + it('falls back to the day it was recorded when settled_at is unset', () => { + expect(settlementDate({ created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-01') + expect(settlementDate({ settled_at: null, created_at: '2026-07-01T09:00:00Z' })).toBe('2026-07-01') + }) + + it('is empty when neither is set', () => { + expect(settlementDate({})).toBe('') + }) +}) + // ── Receipt splits have to reconcile (#1382) ───────────────────────────────── describe('calculateTicketShares', () => { @@ -176,3 +246,67 @@ describe('calculateTicketShares', () => { expect(calculateTicketShares([])).toEqual({ shares: {}, total: 0 }) }) }) + +describe('paidByUser', () => { + it('adds up what one participant fronted and ignores the other payers', () => { + const item = { payers: [{ user_id: 1, amount: 30 }, { user_id: 2, amount: 70 }, { user_id: 1, amount: 5 }] } + expect(paidByUser(item, 1)).toBe(35) + expect(paidByUser(item, 2)).toBe(70) + expect(paidByUser(item, 3)).toBe(0) + }) + + it('is zero for an expense with no payer list', () => { + expect(paidByUser({ payers: null }, 1)).toBe(0) + expect(paidByUser({}, 1)).toBe(0) + }) +}) + +describe('finalBudgetFor', () => { + const finals = [{ + user_id: 1, username: 'alice', avatar_url: null, expenses: 100, reimbursed: 50, pending: 0, final: 50, + sources: { fronted: [{ item_id: 1, cents: 10000 }], moved: [], outstanding: [] }, + }] + + it("returns the server's row for a participant in the ledger", () => { + expect(finalBudgetFor(finals, { id: 1, username: 'alice' })).toBe(finals[0]) + }) + + it('reads a participant the ledger left out as costing nothing, with nothing behind it', () => { + expect(finalBudgetFor(finals, { id: 2, username: 'bob' })).toEqual({ + user_id: 2, username: 'bob', avatar_url: null, expenses: 0, reimbursed: 0, pending: 0, final: 0, + sources: { fronted: [], moved: [], outstanding: [] }, + }) + }) +}) + +describe('finalBudgetSources', () => { + const items = [{ id: 1, name: 'Dinner' }, { id: 2, name: 'Taxi' }] + const sources = { + fronted: [{ item_id: 1, cents: 6000 }, { item_id: 3, cents: -1000 }], + moved: [ + { settlement_id: 1, from_user_id: 2, to_user_id: 1, cents: 1500 }, + { settlement_id: 2, from_user_id: 1, to_user_id: 3, cents: -500 }, + ], + outstanding: [{ from_user_id: 3, to_user_id: 1, cents: 501 }], + } + + it("names the expenses and prints the server's cents as amounts, a refund's negative row included", () => { + const { fronted } = finalBudgetSources({ sources }, items) + // An expense the list does not know yet keeps its row; only the name is missing. + expect(fronted).toEqual([{ item_id: 1, name: 'Dinner', amount: 60 }, { item_id: 3, name: '?', amount: -10 }]) + }) + + it('keeps the transfers and open flows signed as the server sent them', () => { + const { moved, outstanding } = finalBudgetSources({ sources }, items) + expect(moved).toEqual([ + { settlement_id: 1, from_user_id: 2, to_user_id: 1, amount: 15 }, + { settlement_id: 2, from_user_id: 1, to_user_id: 3, amount: -5 }, + ]) + expect(outstanding).toEqual([{ from_user_id: 3, to_user_id: 1, amount: 5.01 }]) + }) + + it('is empty for a participant with no activity', () => { + expect(finalBudgetSources({ sources: { fronted: [], moved: [], outstanding: [] } }, items)) + .toEqual({ fronted: [], moved: [], outstanding: [] }) + }) +}) diff --git a/client/src/components/Budget/CostsPanel.helpers.ts b/client/src/components/Budget/CostsPanel.helpers.ts index 6226d289c..9aa6d401a 100644 --- a/client/src/components/Budget/CostsPanel.helpers.ts +++ b/client/src/components/Budget/CostsPanel.helpers.ts @@ -11,15 +11,91 @@ * Amounts are the raw input strings, parsed on use (same as customAmounts). */ -/** Spread `amount` across `n` payers in whole cents so the parts sum back exactly. */ +import type { BudgetParticipantFinal } from '@trek/shared' +import { currencyDecimals } from '../../utils/formatters' + +// The split and receipt fields guard their own precision on every keystroke, so the +// guard has to follow the currency: a three-decimal one (KWD, BHD, …) seeds three +// places, and the old fixed two rejected every keystroke after that, leaving the field +// unusable until a digit was deleted (#2175). Never stricter than two places, so no +// value a field accepts today can become uneditable. +export const amountPattern = (currency: string, signed: boolean) => + new RegExp(`^${signed ? '-?' : ''}\\d*\\.?\\d{0,${Math.max(2, currencyDecimals(currency))}}$`) + +/** + * Spread `amount` across `n` payers in whole cents so the parts sum back exactly. + * Floor-based, so a negative total (a refund, #2176) splits just as exactly: + * the remainder is always in [0, n) and the parts still sum to the input. + */ export function splitCents(amount: number, n: number): number[] { if (n <= 0) return [] - const cents = Math.max(0, Math.round(amount * 100)) + const cents = Math.round(amount * 100) const base = Math.floor(cents / n) const rem = cents - base * n return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100) } +/** + * The calendar day a settle-up payment counts on for grouping/filtering: its own + * `settled_at` if the user set one, else the day it was recorded (`created_at`). + * Mirrors `expense_date` falling back nowhere on a budget item — a settlement's + * `created_at` doubled as its date before `settled_at` existed, so this keeps + * every pre-existing row grouped exactly where it already was. + */ +export function settlementDate(s: { settled_at?: string | null; created_at?: string | null }): string { + return (s.settled_at || s.created_at || '').slice(0, 10) +} + +/** + * What one participant fronted for an expense, in the expense's own currency. + * + * Several payers can share one bill, and the same person can appear only once, + * but the reduce covers a row that somehow carries them twice rather than + * picking one of the two. The caller converts the result — this file never + * touches exchange rates. + */ +export function paidByUser( + item: { payers?: { user_id: number; amount: number }[] | null }, + userId: number, +): number { + return (item.payers || []).filter(p => p.user_id === userId).reduce((a, p) => a + p.amount, 0) +} + +/** The figures a final budget is made of, for someone the server left out of the ledger. */ +export function finalBudgetFor(finals: BudgetParticipantFinal[], member: { id: number; username: string }): BudgetParticipantFinal { + // Absent means they neither fronted anything nor were split into an expense: + // the trip has cost them nothing, which is worth a row of its own. + return finals.find(f => f.user_id === member.id) || { + user_id: member.id, username: member.username, avatar_url: null, + expenses: 0, reimbursed: 0, pending: 0, final: 0, + sources: { fronted: [], moved: [], outstanding: [] }, + } +} + +/** + * The rows behind one traveler's final budget, ready to print. They arrive as + * ids and display cents: the server spreads each of the three figures over its + * rows with the same largest-remainder split the figure came from, so every list + * adds up to the line it sits under, in whatever currency was asked for and + * whether or not the live rates have loaded yet. Only the expense names are + * looked up here; who "you" is in a transfer is the shell's call. + */ +export function finalBudgetSources( + row: Pick, + items: { id: number; name: string }[], +): { + fronted: { item_id: number; name: string; amount: number }[] + moved: { settlement_id: number; from_user_id: number; to_user_id: number; amount: number }[] + outstanding: { from_user_id: number; to_user_id: number; amount: number }[] +} { + const { fronted, moved, outstanding } = row.sources + return { + fronted: fronted.map(r => ({ item_id: r.item_id, name: items.find(i => i.id === r.item_id)?.name ?? '?', amount: r.cents / 100 })), + moved: moved.map(r => ({ settlement_id: r.settlement_id, from_user_id: r.from_user_id, to_user_id: r.to_user_id, amount: r.cents / 100 })), + outstanding: outstanding.map(r => ({ from_user_id: r.from_user_id, to_user_id: r.to_user_id, amount: r.cents / 100 })), + } +} + /** Sum the amounts of the selected payers. */ export function payerSum(amounts: Record, ids: Set): number { return [...ids].reduce((a, id) => a + (Number.parseFloat(amounts[id]) || 0), 0) @@ -58,6 +134,13 @@ export function rebalancePayers( * The remainder cent rotates with the item id rather than always landing on the * first member, so across several expenses the rounding evens out instead of * always favouring the same person. + * + * Must stay share-for-share identical to the server's + * BudgetService.splitEqualShares (budget.service.ts) — the settlement is netted + * there, this copy only previews it. The remainder is `totalCents - baseCents*n` + * rather than `%` so a negative total (a refund, #2176) still yields a remainder + * in [0, n) and the shares sum back to the total exactly, like the server's do. + * The parity fixture in CostsPanel.helpers.test.ts pins both sides. */ export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record { const n = members.length @@ -65,7 +148,7 @@ export function splitEqualShares(total: number, members: { user_id: number }[], const totalCents = Math.round(total * 100) const baseCents = Math.floor(totalCents / n) - const remainder = totalCents % n + const remainder = totalCents - baseCents * n const shares: Record = {} const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id) diff --git a/client/src/components/Budget/CostsPanel.test.tsx b/client/src/components/Budget/CostsPanel.test.tsx index 3a5a8cff8..0c099d0be 100644 --- a/client/src/components/Budget/CostsPanel.test.tsx +++ b/client/src/components/Budget/CostsPanel.test.tsx @@ -11,6 +11,7 @@ import { usePermissionsStore } from '../../store/permissionsStore' import { clearExchangeRateCache } from '../../hooks/useExchangeRates' import { resetAllStores, seedStore } from '../../../tests/helpers/store' import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories' +import type { BudgetParticipantFinal } from '@trek/shared' import type { BudgetItem } from '../../types' import CostsPanel, { ExpenseModal } from './CostsPanel' import { splitEqualShares, calculateTicketShares, type TicketItem } from './CostsPanel.helpers' @@ -20,6 +21,9 @@ const tripMembers = [ { id: 2, username: 'bob', avatar_url: null }, ] +/** Bob's buttons in the expense form — his final-budget row in the sidebar answers to his name too. */ +const bobInForm = () => screen.getAllByRole('button', { name: /bob/i }).filter(b => !b.hasAttribute('aria-expanded')) + beforeEach(() => { resetAllStores() seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true }) @@ -295,6 +299,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() + + 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 | null = null @@ -518,8 +545,9 @@ describe('CostsPanel — settlements in the ledger', () => { await user.click(screen.getByTitle('Edit')) // Loading used to be payers.find(...), which silently dropped the second payer. + // Seeded with two decimals since #2175, localized for EUR. const amounts = await screen.findAllByTestId('payer-amount') - expect(amounts.map(i => (i as HTMLInputElement).value)).toEqual(['45', '45']) + expect(amounts.map(i => (i as HTMLInputElement).value)).toEqual(['45,00', '45,00']) await user.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => expect(put).toBeTruthy()) @@ -586,7 +614,7 @@ describe('CostsPanel — settlements in the ledger', () => { await user.type(itemNames[1], 'chocolate cake') await user.type(itemPrices[2], '50') - const bobButtons = screen.getAllByRole('button', { name: /bob/i }) + const bobButtons = bobInForm() await user.click(bobButtons[1]) await user.type(itemNames[2], 'Milk') @@ -705,8 +733,9 @@ describe('CostsPanel — settlements in the ledger', () => { await screen.findByText('Payment') await user.click(screen.getByTitle('Edit')) - // The stored USD amount comes back as-is, not silently reread as euros. - expect((await screen.findByPlaceholderText('0.00') as HTMLInputElement).value).toBe('30') + // The stored USD amount comes back as-is (padded to two decimals, #2175), + // not silently reread as euros. + expect((await screen.findByPlaceholderText('0.00') as HTMLInputElement).value).toBe('30.00') expect(screen.getByText(/^USD/)).toBeInTheDocument() }) }) @@ -715,7 +744,7 @@ describe('CostsPanel — settlements in the ledger', () => { type Flow = { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number } type Balance = { user_id: number; username: string; avatar_url: string | null; balance: number } -type Payment = { id: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; created_at?: string } +type Payment = { id: number; from_user_id: number; to_user_id: number; amount: number; currency?: string | null; exchange_rate?: number; created_at?: string; settled_at?: string | null } // `members` here is the wire shape the panel reads; `paid` is only set by the server. type MemberFixture = { user_id: number; username?: string; amount?: number; paid?: number } @@ -724,7 +753,7 @@ const expense = (over: Partial> & { members?: Member function mount( items: BudgetItem[], - settlement: { balances?: Balance[]; flows?: Flow[]; settlements?: Payment[] } = {}, + settlement: { balances?: Balance[]; flows?: Flow[]; settlements?: Payment[]; finalBudgets?: BudgetParticipantFinal[] } = {}, entries?: string[], ) { server.use( @@ -801,8 +830,70 @@ describe('CostsPanel — overview', () => { mount([], { balances: [{ user_id: 1, username: 'alice', avatar_url: null, balance: 0 }] }) // Both travellers appear; neither has a signed amount. - await screen.findByText('Balances') - expect(screen.getAllByText('0,00 €')).toHaveLength(2) + const balances = (await screen.findByText('Balances')).parentElement as HTMLElement + expect(within(balances).getAllByText('0,00 €')).toHaveLength(2) + }) + + it('FE-W5COSTS-004b: the final budget gives one amount per traveler and its arithmetic on click', async () => { + // Dinner is 100 USD fronted by Alice, booked by the server at the rate frozen + // on entry as 92 €; taxi 30 € by Bob; both split evenly, so the trip costs each + // of them 61. Bob has sent 15 of the 31 he owed, 16 is still open. No live rate + // is loaded here, so a client converting the dinner itself would print 100 €. + const user = userEvent.setup() + mount([{ ...dinner(), currency: 'USD', total_price: 100, payers: [{ user_id: 1, amount: 100 }] }, taxi()], { + balances: [ + { user_id: 1, username: 'alice', avatar_url: null, balance: 16 }, + { user_id: 2, username: 'bob', avatar_url: null, balance: -16 }, + ], + flows: [{ from: { user_id: 2, username: 'bob' }, to: { user_id: 1, username: 'alice' }, amount: 16 }], + settlements: [{ id: 9, from_user_id: 2, to_user_id: 1, amount: 15, currency: 'EUR', created_at: '2025-06-17 10:00:00' }], + finalBudgets: [ + { + user_id: 1, username: 'alice', avatar_url: null, expenses: 92, reimbursed: 15, pending: 16, final: 61, + sources: { + fronted: [{ item_id: 101, cents: 9200 }], + moved: [{ settlement_id: 9, from_user_id: 2, to_user_id: 1, cents: 1500 }], + outstanding: [{ from_user_id: 2, to_user_id: 1, cents: 1600 }], + }, + }, + { + user_id: 2, username: 'bob', avatar_url: null, expenses: 30, reimbursed: -15, pending: -16, final: 61, + sources: { + fronted: [{ item_id: 102, cents: 3000 }], + moved: [{ settlement_id: 9, from_user_id: 2, to_user_id: 1, cents: -1500 }], + outstanding: [{ from_user_id: 2, to_user_id: 1, cents: -1600 }], + }, + }, + ], + }) + + const card = (await screen.findByText('Final budget')).parentElement as HTMLElement + await waitFor(() => expect(within(card).getAllByText('61,00 €')).toHaveLength(2)) + // The main view stays one figure per person until someone asks for more. + expect(within(card).queryByText('Expenses paid')).toBeNull() + + const alice = within(card).getByRole('button', { name: /You/ }) + await user.click(alice) + expect(alice).toHaveAttribute('aria-expanded', 'true') + // The dinner is the server's 92 €, once as the line and once as its only row; + // the raw 100 USD never shows up in the card. + expect(within(card).getAllByText('+92,00 €')).toHaveLength(2) + expect(within(card).queryByText(/100,00/)).toBeNull() + // Received and still pending both lower her cost, each line with its one row. + expect(within(card).getAllByText('−15,00 €')).toHaveLength(2) + expect(within(card).getAllByText('−16,00 €')).toHaveLength(2) + expect(within(card).getByText('Dinner')).toBeInTheDocument() + expect(within(card).queryByText('Taxi')).toBeNull() + expect(within(card).getAllByText(/^bob → /)).toHaveLength(2) + + // Opening Bob closes Alice: what he sent back and what he still owes raise his. + await user.click(within(card).getByRole('button', { name: /bob/ })) + expect(alice).toHaveAttribute('aria-expanded', 'false') + expect(within(card).getAllByText('+30,00 €')).toHaveLength(2) + expect(within(card).getAllByText('+15,00 €')).toHaveLength(2) + expect(within(card).getAllByText('+16,00 €')).toHaveLength(2) + expect(within(card).getByText('Taxi')).toBeInTheDocument() + expect(within(card).queryByText('Dinner')).toBeNull() }) it('FE-W5COSTS-005: the category breakdown ranks categories by spend', async () => { @@ -955,8 +1046,8 @@ describe('CostsPanel — settle up', () => { render() await screen.findByText('Balances') - // Settle up and Balances both hang off the one failed request. - await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(2)) + // Settle up, Balances and Final budget all hang off the one failed request. + await waitFor(() => expect(screen.getAllByText('Unknown error')).toHaveLength(3)) expect(screen.queryByText("Everyone's square")).toBeNull() }) @@ -1026,6 +1117,21 @@ describe('CostsPanel — filtering the ledger', () => { expect(screen.queryByText('Payment')).not.toBeInTheDocument() }) + it('FE-W5COSTS-076: a payment groups by its own settled day, not the day it was recorded', async () => { + // Recorded (created_at) on the 16th, but settled on the 15th — the ledger + // must follow settled_at, the same way it already follows expense_date over + // an expense's own created_at. + mount([dinner(), taxi()], { settlements: [{ ...payment, settled_at: '2025-06-15' }] }) + + await screen.findByText('Taxi') + fireEvent.click(screen.getByRole('button', { name: /All days/ })) + pickOption('Sun, Jun 15') + + expect(screen.getByText('Dinner')).toBeInTheDocument() + expect(screen.queryByText('Taxi')).not.toBeInTheDocument() + expect(screen.getByText('Payment')).toBeInTheDocument() + }) + it('FE-W5COSTS-018: expenses without a date are grouped under "No date"', async () => { mount([expense({ id: 120, name: 'Souvenirs', category: 'shopping', total_price: 12, expense_date: null })]) @@ -1079,6 +1185,26 @@ describe('CostsPanel — expense rows', () => { expect(screen.getByText(/\$20\.00 → 10,00 €/)).toBeInTheDocument() }) + it('FE-W5COSTS-078: a booked rate outlives the live one, for the expense and for the transfer', async () => { + // What a tester hit after settling up: the euro moved, and so did a bill that had + // already been paid. A cost is money that changed hands at a rate that was true that + // day, so the frozen rate wins over whatever the market says this morning. + localStorage.setItem('trek_fx_EUR', JSON.stringify({ rates: { EUR: 1, USD: 2 }, ts: Date.now() })) + mount([expense({ + id: 131, name: 'Diner', category: 'food', total_price: 120, currency: 'USD', exchange_rate: 1.2, + expense_date: '2025-06-15', + payers: [{ user_id: 1, amount: 120 }], + members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }], + })], { settlements: [{ id: 9, from_user_id: 2, to_user_id: 1, amount: 24, currency: 'USD', exchange_rate: 1.2, created_at: '2025-06-15 09:00:00' }] }) + + await screen.findByText('Diner') + // 120 USD at the booked 1.2 per euro is 100 euro. At today's 2 it would read 60,00 euro. + expect(screen.getByText(/\$120\.00 → 100,00 €/)).toBeInTheDocument() + expect(screen.queryByText(/60,00 €/)).toBeNull() + // The settled transfer is read back the same way: 24 USD booked at 1.2 is 20 euro. + expect(screen.getByText(/\$24\.00 → 20,00 €/)).toBeInTheDocument() + }) + it('FE-W5COSTS-022: deleting an expense removes it, and a failure is reported', async () => { const addToast = vi.fn() window.__addToast = addToast as unknown as typeof window.__addToast @@ -1227,6 +1353,62 @@ describe('CostsPanel — payment modal', () => { await waitFor(() => expect(addToast).toHaveBeenCalledWith('Unknown error', 'error', undefined)) delete window.__addToast }) + + it('FE-W5COSTS-074: a new payment defaults its day to today, by the local calendar', async () => { + vi.useFakeTimers({ toFake: ['Date'] }) + const behindUtc = new Date(2026, 7, 12).getTimezoneOffset() > 0 + vi.setSystemTime(new Date(2026, 7, 12, behindUtc ? 23 : 1, 30, 0)) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + let posted: Record | null = null + server.use(http.post('/api/trips/1/budget/settlements', async ({ request }) => { + posted = await request.json() as Record + return HttpResponse.json({ settlement: { id: 9 } }) + })) + mount([]) + + try { + await user.click(await screen.findByRole('button', { name: 'Add payment' })) + await user.type(await screen.findByPlaceholderText('0.00'), '10') + const submits = screen.getAllByRole('button', { name: 'Add payment' }) + await user.click(submits[submits.length - 1]) + + await waitFor(() => expect(posted).toBeTruthy()) + expect(posted!.settled_at).toBe('2026-08-12') + } finally { + vi.useRealTimers() + } + }) + + it('FE-W5COSTS-075: editing a payment keeps its own settled day, not the day it was recorded', async () => { + const user = userEvent.setup() + let put: Record | null = null + server.use(http.put('/api/trips/1/budget/settlements/7', async ({ request }) => { + put = await request.json() as Record + return HttpResponse.json({ settlement: { id: 7 } }) + })) + mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] }) + + await user.click(await screen.findByTitle('Edit')) + await user.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => expect(put).toBeTruthy()) + expect(put!.settled_at).toBe('2025-06-10') + }) + + it('FE-W5COSTS-077: a payment cannot be saved without a day', async () => { + // Cleared, the server would store NULL and the ledger would quietly move + // the payment back to the day it was recorded on. + const user = userEvent.setup() + mount([], { settlements: [{ id: 7, from_user_id: 2, to_user_id: 1, amount: 30, settled_at: '2025-06-10', created_at: '2025-06-16 10:00:00' }] }) + + await user.click(await screen.findByTitle('Edit')) + const save = screen.getByRole('button', { name: 'Save' }) + expect(save).toBeEnabled() + + await user.click(document.querySelector('button[aria-haspopup="dialog"]') as HTMLElement) + await user.click(screen.getByRole('button', { name: 'Clear date' })) + expect(save).toBeDisabled() + }) }) describe('CostsPanel — expense modal', () => { @@ -1264,7 +1446,7 @@ describe('CostsPanel — expense modal', () => { const rows = screen.getAllByPlaceholderText('Item name') await user.click(rows[1].parentElement!.parentElement!.querySelectorAll('button')[0]) expect(screen.queryByDisplayValue('Cake')).not.toBeInTheDocument() - await user.click(screen.getAllByRole('button', { name: /bob/i })[0]) + await user.click(bobInForm()[0]) await user.click(screen.getByRole('button', { name: 'Save' })) await waitFor(() => expect(put).toBeTruthy()) @@ -1300,11 +1482,13 @@ describe('CostsPanel — expense modal', () => { await screen.findByText('Dinner') await user.click(screen.getByTitle('Edit')) - expect((await screen.findByDisplayValue('70')).tagName).toBe('INPUT') - expect(screen.getByDisplayValue('30')).toBeInTheDocument() + // Custom shares reopen padded to two decimals (#2175), localized for EUR. + expect((await screen.findByDisplayValue('70,00')).tagName).toBe('INPUT') + expect(screen.getByDisplayValue('30,00')).toBeInTheDocument() // Excluding Bob drops his amount; the split no longer matches the total. - await user.click(screen.getByRole('button', { name: /bob/i })) + expect(bobInForm()).toHaveLength(1) + await user.click(bobInForm()[0]) expect(screen.getByText(/Sum of splits/)).toBeInTheDocument() expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() @@ -1430,7 +1614,8 @@ describe('CostsPanel — expense modal', () => { ) expect(screen.getByDisplayValue('Hotel Astoria')).toBeInTheDocument() - expect(screen.getByDisplayValue('240')).toBeInTheDocument() + // The prefilled amount is padded to the base currency's decimals (#2175). + expect(screen.getByDisplayValue('240,00')).toBeInTheDocument() await user.click(screen.getByRole('button', { name: 'Add expense' })) await waitFor(() => expect(posted).toBeTruthy()) @@ -1640,7 +1825,8 @@ describe('CostsPanel — remaining paths', () => { await screen.findByText('Mining rig') await user.click(screen.getByTitle('Edit')) - expect(await screen.findByDisplayValue('2')).toBeInTheDocument() + // XBT is unknown to the locale map → dot separator, two decimals (#2175). + expect(await screen.findByDisplayValue('2.00')).toBeInTheDocument() expect(screen.getAllByText('XBT').length).toBeGreaterThanOrEqual(2) }) @@ -1862,6 +2048,173 @@ describe('CostsPanel — mobile extras', () => { }) }) +// ── #2175: edit inputs are padded to the currency's decimals ──────────────── +describe('CostsPanel — decimal padding on edit (#2175)', () => { + beforeEach(() => { + seedAlice() + clearExchangeRateCache() + }) + afterEach(clearExchangeRateCache) + + it('FE-W5COSTS-066: reopens 4,90 as "4,90" and 5,00 as "5,00", not "4,9" and "5"', async () => { + const user = userEvent.setup() + mount([ + expense({ id: 301, name: 'Coffee', category: 'food', total_price: 4.9, payers: [{ user_id: 1, amount: 4.9 }], members: [{ user_id: 1, username: 'alice' }] }), + expense({ id: 302, name: 'Toll', category: 'transport', total_price: 5, payers: [{ user_id: 1, amount: 5 }], members: [{ user_id: 1, username: 'alice' }] }), + ]) + + await screen.findByText('Coffee') + await user.click(screen.getAllByTitle('Edit')[0]) + expect(await screen.findByDisplayValue('4,90')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Cancel' })) + + await user.click(screen.getAllByTitle('Edit')[1]) + expect(await screen.findByDisplayValue('5,00')).toBeInTheDocument() + }) + + it('FE-W5COSTS-071: a three-decimal currency seeds three places and the split field still takes input', async () => { + const user = userEvent.setup() + render( {}} onSaved={vi.fn()} + editing={expense({ + id: 340, name: 'Museum', category: 'activities', currency: 'KWD', total_price: 3, + payers: [{ user_id: 1, amount: 3 }], + members: [{ user_id: 1, username: 'alice', amount: 1.5 }, { user_id: 2, username: 'bob', amount: 1.5 }], + })} />) + + expect(await screen.findByDisplayValue('3.000')).toBeInTheDocument() + const shares = screen.getAllByDisplayValue('1.500') as HTMLInputElement[] + + // The seed is three decimals, so a two-decimal guard would reject every further + // keystroke, including one typed in front of the decimal point. + await user.type(shares[0], '2', { initialSelectionStart: 0, initialSelectionEnd: 0 }) + expect(shares[0].value).toBe('21.500') + + // A fourth decimal is still not a fils. + await user.clear(shares[1]) + await user.type(shares[1], '1.5005') + expect(shares[1].value).toBe('1.500') + }) +}) + +// ── #2176: negative amounts record partial reimbursements ─────────────────── +describe('CostsPanel — negative amounts (#2176)', () => { + beforeEach(() => { + seedAlice() + clearExchangeRateCache() + }) + afterEach(clearExchangeRateCache) + + it('FE-W5COSTS-067: "-100" can be typed and saves a negative expense with its payer', async () => { + const user = userEvent.setup() + let posted: Record | null = null + server.use(http.post('/api/trips/1/budget', async ({ request }) => { + posted = await request.json() as Record + return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Hotel refund' }), id: 12 } }) + })) + mount([]) + + await user.click(await screen.findByRole('button', { name: 'Add expense' })) + await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel refund') + // The reported symptom: the '-' was stripped on input, so this was untypeable. + await user.type(screen.getAllByPlaceholderText('0,00')[0], '-100') + + const submits = screen.getAllByRole('button', { name: 'Add expense' }) + expect(submits[submits.length - 1]).not.toBeDisabled() + await user.click(submits[submits.length - 1]) + + await waitFor(() => expect(posted).toBeTruthy()) + expect(posted!.total_price).toBe(-100) + expect(posted!.payers).toEqual([{ user_id: 1, amount: -100 }]) + expect(posted!.member_ids).toEqual([1, 2]) + }) + + it('FE-W5COSTS-068: a refund reopens with its negative payer intact and saves it back', async () => { + const user = userEvent.setup() + let put: Record | null = null + server.use(http.put('/api/trips/1/budget/310', async ({ request }) => { + put = await request.json() as Record + return HttpResponse.json({ item: dinner() }) + })) + mount([expense({ + id: 310, name: 'Hotel refund', category: 'lodging', total_price: -100, + payers: [{ user_id: 1, amount: -100 }], + members: [{ user_id: 1, username: 'alice' }, { user_id: 2, username: 'bob' }], + })]) + + // The row shows the amount twice — total pill and the payer chip, which used + // to be filtered to positive amounts and left the refund's recipient invisible. + const row = (await screen.findByText('Hotel refund')).closest('.exp-row') as HTMLElement + expect(within(row).getAllByText(/100,00/).length).toBeGreaterThanOrEqual(2) + // Not "Unfinished": the refund has its recipient recorded. + expect(within(row).queryByText('Unfinished')).toBeNull() + + await user.click(screen.getByTitle('Edit')) + // The negative payer reopens selected instead of resetting to "Nobody", + // and the total is seeded signed and padded. + expect(await screen.findByRole('button', { name: 'You' })).toBeInTheDocument() + expect(screen.getByDisplayValue('-100,00')).toBeInTheDocument() + + // Saving untouched must not silently drop the negative payer. + await user.click(screen.getByRole('button', { name: 'Save' })) + await waitFor(() => expect(put).toBeTruthy()) + expect(put!.total_price).toBe(-100) + expect(put!.payers).toEqual([{ user_id: 1, amount: -100 }]) + }) + + it('FE-W5COSTS-069: a payer-less refund is marked Unfinished like a payer-less bill', async () => { + mount([expense({ id: 320, name: 'Pending refund', category: 'misc', total_price: -50, payers: [], members: [{ user_id: 1, username: 'alice' }] })]) + + await screen.findByText('Pending refund') + expect(screen.getByText('Unfinished')).toBeInTheDocument() + }) + + it('FE-W5COSTS-070: the category breakdown nets refunds and keeps a net-negative category listed', async () => { + mount([ + expense({ id: 330, name: 'Dinner', category: 'food', total_price: 90, payers: [{ user_id: 1, amount: 90 }], members: [{ user_id: 1, username: 'alice' }] }), + expense({ id: 331, name: 'Meal refund', category: 'food', total_price: -30, payers: [{ user_id: 1, amount: -30 }], members: [{ user_id: 1, username: 'alice' }] }), + expense({ id: 332, name: 'Cancelled tour', category: 'activities', total_price: -20, payers: [{ user_id: 1, amount: -20 }], members: [{ user_id: 1, username: 'alice' }] }), + ]) + + await screen.findByText('Dinner') + const breakdown = screen.getByText('By category').parentElement as HTMLElement + // Food nets 90 − 30 = 60; the fully refunded tour keeps a row of its own. + expect(within(breakdown).getByText('60 €')).toBeInTheDocument() + expect(within(breakdown).getByText(/[-−]20\s*€/)).toBeInTheDocument() + }) + + it('FE-W5COSTS-072: the custom-split hint reads under and over the right way round on a refund', async () => { + const user = userEvent.setup() + render( {}} onSaved={vi.fn()} + editing={expense({ + id: 341, name: 'Hotel refund', category: 'accommodation', total_price: -100, + payers: [{ user_id: 1, amount: -100 }], + members: [{ user_id: 1, username: 'alice', amount: -70 }, { user_id: 2, username: 'bob' }], + })} />) + + // -70 of -100: 30 still to hand out, not 30 too much. + expect(await screen.findByText('Sum of splits: €-70.00 of €-100.00 (under by €30.00)')).toBeInTheDocument() + + await user.type(screen.getByPlaceholderText('-30,00'), '-40') + expect(screen.getByText('Sum of splits: €-110.00 of €-100.00 (over by €10.00)')).toBeInTheDocument() + }) + + it('FE-W5COSTS-073: the total can be turned into a refund without a minus key', async () => { + const user = userEvent.setup() + render( {}} onSaved={vi.fn()} />) + + const total = (await screen.findAllByPlaceholderText('0,00'))[0] as HTMLInputElement + await user.type(total, '100') + + // The iOS decimal pad has no minus key, so the sign has to be reachable without one. + const toggle = screen.getByRole('button', { name: 'Switch between expense and refund' }) + await user.click(toggle) + + expect(total.value).toBe('-100') + expect(toggle).toHaveAttribute('aria-pressed', 'true') + }) +}) + describe('CostsPanel — split maths', () => { it('FE-W5COSTS-039: an equal split with nobody to split with is empty', () => { expect(splitEqualShares(90, [], 1)).toEqual({}) diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index a4de9d132..95e2d5850 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -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,18 +8,21 @@ import { useCanDo } from '../../store/permissionsStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { budgetApi } from '../../api/client' -import { useExchangeRates } from '../../hooks/useExchangeRates' +import { saveWithReceipts } from './receiptUploads' +import { convertBooked, useExchangeRates } from '../../hooks/useExchangeRates' import { useIsMobile } from '../../hooks/useIsMobile' -import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, cleanAmount } from '../../utils/formatters' -import { downloadBlob } from '../../utils/fileDownload' +import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput, amountToInputString } from '../../utils/formatters' +import { downloadBlob, openFile } from '../../utils/fileDownload' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { localToday } from '../Planner/today' import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants' -import { calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers' +import { amountPattern, calculateTicketShares, finalBudgetFor, finalBudgetSources, hasTicketSplit, NOTE_MAX, paidByUser, payersBalanced, readTicketItems, readUserNote, rebalancePayers, settlementDate, 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 { BudgetParticipantFinal } from '@trek/shared' +import type { BudgetItem, BudgetItemReceipt } from '../../types' import type { TripMember } from './BudgetPanelMemberChips' import GuestBadge from '../shared/GuestBadge' import { NumericInput } from '../shared/NumericInput' @@ -38,7 +41,14 @@ interface Settlement { // The currency the transfer was entered in. Legacy rows predate it (null) and are // read as the display currency, which is what the server assumes for them too. currency?: string | null + // The rate frozen when the transfer was settled, in units of `currency` per 1 trip + // currency (#1445). Absent, or exactly 1, on rows written before the freeze existed. + exchange_rate?: number created_at?: string + // The day the transfer actually happened; editable, unlike created_at (when it + // was recorded). Null/absent on rows predating this field — settlementDate() + // falls back to created_at for those. + settled_at?: string | null from_username?: string to_username?: string } @@ -46,6 +56,9 @@ interface SettlementData { balances: { user_id: number; username: string; avatar_url: string | null; balance: number }[] flows: { from: { user_id: number; username: string }; to: { user_id: number; username: string }; amount: number }[] settlements: Settlement[] + // What the trip ends up costing each participant. Computed server-side off the + // same ledger as the balances, so the breakdown can't contradict them. + finalBudgets: BudgetParticipantFinal[] } // One row in the unified Costs ledger — either an expense or a settle-up payment, @@ -84,9 +97,13 @@ 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(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(null) + // One open final-budget breakdown at a time, for the same reason a single note + // is expanded at a time: the card is a sidebar, not a report. + const [expandedFinalId, setExpandedFinalId] = useState(null) const [editingSettlement, setEditingSettlement] = useState(null) const [addingPayment, setAddingPayment] = useState(false) @@ -121,21 +138,36 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps }, [searchParams]) // ── 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) + // Booked, not live: an expense entered in a foreign currency keeps the rate it was + // entered at, which is the same rule the server settles by (#1335). + const booked = useCallback( + (amount: number, e: BudgetItem) => convertBooked(amount, e.currency, e.exchange_rate, tripCurrency, convert), + [convert, tripCurrency], + ) + const baseTotal = (e: BudgetItem) => booked(e.total_price || 0, e) + // A transfer freezes its own rate at settle time, in its own table (#1445). + const settled = useCallback( + (s: Settlement) => convertBooked(s.amount, s.currency, s.exchange_rate, tripCurrency, convert), + [convert, tripCurrency], + ) + const myPaidOf = (e: BudgetItem) => booked(paidByUser(e, me), 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 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) { - return convert(myMember.amount, curOf(e)) + return booked(myMember.amount, e) } const shares = splitEqualShares(e.total_price || 0, e.members || [], e.id) const myShare = shares[me] || 0 - return convert(myShare, curOf(e)) + return booked(myShare, 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. - 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) @@ -171,14 +203,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps if (filter === 'owed') return [] let list = settlement?.settlements || [] if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me) - if (dayFilter) list = list.filter(s => (s.created_at || '').slice(0, 10) === dayFilter) + if (dayFilter) list = list.filter(s => settlementDate(s) === dayFilter) return list }, [settlement, filter, search, catFilter, dayFilter, me]) const dayGroups = useMemo(() => { const entries: LedgerEntry[] = [ ...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })), - ...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })), + ...filteredSettlements.map(s => ({ kind: 'payment' as const, date: settlementDate(s), s })), ] const labelOf = (date: string) => { if (!date) return t('costs.noDate') @@ -467,6 +499,12 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps {BalancesList({ balances: settlement?.balances || [] })}
    + {/* final budget */} +
    +
    {t('costs.finalBudget')}
    + {FinalBudgetList()} +
    + {/* by category */}
    {t('costs.byCategory')}
    @@ -488,6 +526,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} /> )} + {previewReceipts && ( + setPreviewReceipts(null)} + /> + )} + + {pill && <>} + + ) +} diff --git a/client/src/components/shared/markdownSanitize.tsx b/client/src/components/shared/markdownSanitize.tsx new file mode 100644 index 000000000..8d78abaa9 --- /dev/null +++ b/client/src/components/shared/markdownSanitize.tsx @@ -0,0 +1,51 @@ +import type { Components, Options } from 'react-markdown' +import rehypeSanitize, { defaultSchema } from 'rehype-sanitize' + +type HastNode = { type: string; children?: HastNode[] } + +/** + * react-markdown never builds an element out of raw HTML, it shows the markup + * verbatim instead. It does that after the rehype plugins ran, though, so the + * sanitizer gets the raw nodes first and drops them without a trace: a note + * reading "Zimmernummer " loses the placeholder on screen while the stored + * text still has it (#2177). Turning raw into text up front keeps it readable + * and still yields no element. + */ +function rehypeRawAsText() { + return (tree: HastNode) => { + const walk = (node: HastNode) => { + if (node.type === 'raw') node.type = 'text' + for (const child of node.children || []) walk(child) + } + walk(tree) + } +} + +// remark-gfm already writes footnote ids with the `user-content-` prefix, and +// the hrefs pointing at them carry it too. Clobbering them a second time only +// makes target and reference disagree. +const schema = { ...defaultSchema, clobberPrefix: '' } + +export const sanitizedMarkdownPlugins: NonNullable = [ + rehypeRawAsText, + [rehypeSanitize, schema], +] + +/** + * Links as in markdownLinkComponents (#1629): own tab plus `rel` protection. + * The exception is a footnote or heading link, which points inside the text + * that is already on screen, so opening a tab for it would only reload the trip + * somewhere else. + */ +export const sanitizedMarkdownComponents: Components = { + a: ({ children, href, ...rest }) => + href?.startsWith('#') ? ( + + {children} + + ) : ( + + {children} + + ), +} diff --git a/client/src/constants/mapDefaults.ts b/client/src/constants/mapDefaults.ts index e62c22d64..d2ed63272 100644 --- a/client/src/constants/mapDefaults.ts +++ b/client/src/constants/mapDefaults.ts @@ -3,9 +3,100 @@ export const DEFAULT_MAP_LNG = 0 export const DEFAULT_MAP_ZOOM = 2 export const DEFAULT_MAP_CENTER: [number, number] = [DEFAULT_MAP_LAT, DEFAULT_MAP_LNG] +/** + * Zoom ceiling for a Leaflet map, set on the map rather than on its base layer. + * + * Leaflet answers `getMaxZoom()` from the map options first and only then from a + * layer that carried one, and only a GridLayer ever contributes: a vector + * basemap is a GL canvas, so a map drawn by one has no ceiling from anywhere. + * `MarkerClusterGroup.onAdd` refuses an infinite ceiling by throwing, which is + * how a basemap choice could take down the whole planner. Matches the raster and + * satellite layers so nothing changes for the maps that already had one. + */ +export const MAP_MAX_ZOOM = 19 + // Tokenless satellite base layer (ESRI World Imagery) — works without an API key. export const SATELLITE_TILE_URL = 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' export const SATELLITE_TILE_ATTRIBUTION = 'Imagery © Esri, Maxar, Earthstar Geographics' export const SATELLITE_TILE_MAXZOOM = 19 + +/** + * The basemap for a browser that will not give MapLibre a WebGL context (#2288). + * + * OpenFreeMap serves vector tiles only, so the app default needs a GL canvas, and + * with WebGL off there is nothing to draw it on. Of the keyless raster sources + * TREK already points at, this is the only street map: the ESRI layer above is + * imagery, and the CARTO templates below have carried an "API KEY REQUIRED" + * watermark since 26.08.2026. It is preset one in the Map settings tab, so this + * aims no new traffic at OSM's servers, and the volume is bounded by how few + * browsers refuse a context in the first place. Attribution is required and comes + * from attributionForTile(), which already reads this host as OpenStreetMap. + */ +export const RASTER_FALLBACK_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' +export const RASTER_FALLBACK_MAX_ZOOM = 19 + +// OpenFreeMap, the default basemap since CARTO began watermarking keyless tiles +// on 26.08.2026 and moved its key behind a request by mail. No key, no +// registration, no request limits, commercial use allowed, attribution required. +// +// These are MapLibre STYLE documents, not {z}/{x}/{y} templates: OpenFreeMap +// serves vector tiles only. Leaflet draws them through VectorBasemap. Positron +// is the same design CARTO's light basemap was, so the maps look like they did. +export const OFM_POSITRON = 'https://tiles.openfreemap.org/styles/positron' +export const OFM_DARK = 'https://tiles.openfreemap.org/styles/dark' +export const OFM_ATTRIBUTION = + 'OpenFreeMap © OpenMapTiles Data from OpenStreetMap' + +const OSM_ATTRIBUTION = '© OpenStreetMap' + +/** + * Amap (高德) raster basemaps. + * + * The reason to offer these at all: OpenStreetMap and OpenFreeMap are thin + * inside mainland China, and every foreign tile CDN is slow or unreachable from + * a Chinese network. These are the tiles a Chinese user expects to see. + * + * `style=7` is the road map, `style=6` is satellite imagery. `lang=zh_cn` + * because these are the labels the tiles exist for; an install that wants + * English labels is better served by OpenFreeMap. + * + * A single host rather than the `webrd0{1..4}` shard family Amap also serves: + * `{s}` in a Leaflet template is substituted from `subdomains`, which defaults + * to a/b/c and would produce `webrd0a`. Every consumer of a stored template + * would have to pass digits instead, and over HTTP/2 the sharding buys nothing. + * + * IMPORTANT: these tiles are drawn in GCJ-02, not WGS-84. Everything else in + * TREK — every marker, route and bounding box — is WGS-84, and dropping one + * datum on top of the other puts markers a few hundred metres off the street + * they belong to. isGcj02Basemap() in utils/tileUrl.ts spots these URLs and the + * map switches to the shifted CRS in components/Map/gcj02Crs.ts. Anything added + * here that serves GCJ-02 tiles must be recognised by that predicate too. + */ +export const AMAP_ROAD = 'https://webrd01.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}' +export const AMAP_SATELLITE = 'https://webst01.is.autonavi.com/appmaptile?style=6&x={x}&y={y}&z={z}' +export const AMAP_ATTRIBUTION = '© 高德地图' + +/** + * Attribution for whatever basemap a map ended up with. OpenFreeMap asks for a + * credit of its own, and printing OpenStreetMap alone under its tiles is both + * wrong and a licence problem, so the URL decides rather than a flag at the + * call site. + */ +export function attributionForTile(url: string | null | undefined): string { + if (!url) return OSM_ATTRIBUTION + if (url.includes('openfreemap.org')) return OFM_ATTRIBUTION + if (url.includes('arcgisonline.com')) return SATELLITE_TILE_ATTRIBUTION + if (url.includes('autonavi.com')) return AMAP_ATTRIBUTION + return OSM_ATTRIBUTION +} + +// CARTO basemaps. Keyless tiles carry an "API KEY REQUIRED" watermark since +// 26.08.2026, so these are always passed through withTileApiKey() (#2054). +// Kept as an opt-in for operators who hold a key; nothing defaults to them. +export const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' +export const CARTO_DARK = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' +export const CARTO_LIGHT_NOLABELS = 'https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png' +export const CARTO_DARK_NOLABELS = 'https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png' +export const CARTO_VOYAGER = 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png' diff --git a/client/src/constants/tripTabs.ts b/client/src/constants/tripTabs.ts index a5fd8d54e..62e734fc6 100644 --- a/client/src/constants/tripTabs.ts +++ b/client/src/constants/tripTabs.ts @@ -12,6 +12,7 @@ */ export const TRIP_TAB_IDS = [ 'plan', + 'roadtrip', 'transports', 'buchungen', 'listen', @@ -30,6 +31,7 @@ export type TripTabId = (typeof TRIP_TAB_IDS)[number] */ export const TRIP_TAB_LABEL_KEYS: Record = { plan: 'trip.tabs.plan', + roadtrip: 'roadtrip.title', transports: 'trip.tabs.transports', buchungen: 'trip.tabs.reservations', listen: 'trip.tabs.lists', diff --git a/client/src/db/cacheAssignment.ts b/client/src/db/cacheAssignment.ts new file mode 100644 index 000000000..22b640cb6 --- /dev/null +++ b/client/src/db/cacheAssignment.ts @@ -0,0 +1,8 @@ +import { offlineDb } from './offlineDb' +import type { Assignment } from '../types' + +export async function cacheAssignment(assignment: Assignment): Promise { + await offlineDb.days.where('id').equals(assignment.day_id).modify(day => { + day.assignments = (day.assignments ?? []).map(a => a.id === assignment.id ? { ...a, ...assignment } : a) + }) +} diff --git a/client/src/db/offlineDb.ts b/client/src/db/offlineDb.ts index a072bf761..97f641278 100644 --- a/client/src/db/offlineDb.ts +++ b/client/src/db/offlineDb.ts @@ -1,3 +1,4 @@ +import type { RoadtripPreferences } from '@trek/shared'; import Dexie, { type Table } from 'dexie'; import type { Trip, Day, Place, PackingItem, TodoItem, BudgetItem, Reservation, TripFile, Accommodation, TripMember, Tag, Category } from '../types'; @@ -68,6 +69,14 @@ export interface SyncMeta { tilesBbox: [number, number, number, number] | null; /** Non-photo files available offline for this trip after the last sync. */ filesCachedCount: number; + /** + * The rounded bbox the cached area places were fetched for. Compared rather + * than a timestamp: adding a place inside the area the trip already covers + * should not re-download it, and moving the trip should. + * + * Optional so a row written before this landed still reads. + */ + areaPlacesKey?: string; } export interface BlobCacheEntry { @@ -86,6 +95,33 @@ export interface BlobCacheEntry { cachedAt: number; } +/** + * A place from the TREK Places index, cached for the area of one trip. + * + * NOT a trip place — those are in `places` and belong to the user. These are + * candidates: the shops, restaurants and stations that happen to be near where + * the trip goes, taken once so search still answers on a plane. They are + * disposable and are re-fetched whenever the trip's area changes. + * + * The key is the GERS id, which is stable across index rebuilds; `tripId` is + * indexed so the whole set can be dropped with the trip. + */ +export interface CachedAreaPlace { + /** GERS id, without the `gers:` prefix the server puts on `osm_id`. */ + gers: string; + tripId: number; + name: string; + /** Lowercased, diacritics folded — what an offline search actually matches on. */ + searchName: string; + address: string; + lat: number | null; + lng: number | null; + category: string | null; + website: string | null; + phone: string | null; + cachedAt: number; +} + /** An uploaded booking-import source file, kept so the review flow can attach it to the * created bookings even after a page reload during the (background) parse. Keyed by job. */ export interface ImportSourceFile { @@ -125,6 +161,7 @@ function initialDbName(): string { } class TrekOfflineDb extends Dexie { + roadtripPreferences!: Table<{ tripId: number; preferences: RoadtripPreferences }, number>; trips!: Table; days!: Table; places!: Table; @@ -141,6 +178,7 @@ class TrekOfflineDb extends Dexie { syncMeta!: Table; blobCache!: Table; importFiles!: Table; + areaPlaces!: Table; constructor(name: string = ANON_DB_NAME) { super(name); @@ -181,6 +219,33 @@ class TrekOfflineDb extends Dexie { this.version(4).stores({ importFiles: '[jobId+fileName], jobId, createdAt', }); + + // v5: places from the TREK Places index for the trip's area, so search + // answers offline. `searchName` is indexed because that is what an offline + // query filters on; `tripId` so the set drops with its trip. + this.version(6).stores({ roadtripPreferences: 'tripId' }); + this.version(5).stores({ + areaPlaces: 'gers, tripId, searchName', + }); + + // v7/v8: the same place near two trips is two rows now. + // + // Keyed on the GERS id alone, one row could only ever name one trip, and a + // bulkPut for the second trip rewrote the first trip's rows to point at it. + // Switching the second trip off then deleted the shared set by tripId, and + // the first trip's areaPlacesKey still matched its bbox, so nothing ever + // downloaded them again: a trip whose switch read "on" with no offline + // search behind it. Dexie cannot change a primary key in place, so the + // table is dropped and rebuilt, and every stored fingerprint is cleared so + // the next sync refills what the drop took. + this.version(7).stores({ areaPlaces: null }); + this.version(8).stores({ + areaPlaces: '[gers+tripId], gers, tripId, searchName', + }).upgrade(async (tx) => { + await tx.table('syncMeta').toCollection().modify((row: { areaPlacesKey?: string }) => { + delete row.areaPlacesKey; + }); + }); } } @@ -404,8 +469,11 @@ export async function clearTripData(tripId: number): Promise { offlineDb.mutationQueue, offlineDb.syncMeta, offlineDb.blobCache, + offlineDb.areaPlaces, + offlineDb.roadtripPreferences, ], async () => { + await offlineDb.roadtripPreferences.delete(tripId); await offlineDb.days.where('trip_id').equals(tripId).delete(); await offlineDb.places.where('trip_id').equals(tripId).delete(); await offlineDb.packingItems.where('trip_id').equals(tripId).delete(); @@ -419,6 +487,11 @@ export async function clearTripData(tripId: number): Promise { await offlineDb.mutationQueue.where('tripId').equals(tripId).and(m => m.status === 'failed').delete(); await offlineDb.syncMeta.where('tripId').equals(tripId).delete(); await offlineDb.blobCache.where('tripId').equals(tripId).delete(); + // The cached places around this trip's area go with it. They are searched + // across every trip, so leaving them behind kept a switched-off trip + // answering offline searches — and nothing else ever deleted them, so they + // accumulated for the life of the install. + await offlineDb.areaPlaces.where('tripId').equals(tripId).delete(); }, ); // Remove the trip row itself outside the transaction since it's a separate table diff --git a/client/src/hooks/useBlurBookingCodes.ts b/client/src/hooks/useBlurBookingCodes.ts new file mode 100644 index 000000000..46922fe6c --- /dev/null +++ b/client/src/hooks/useBlurBookingCodes.ts @@ -0,0 +1,12 @@ +import { useSettingsStore } from '../store/settingsStore' + +/** + * Whether booking codes are drawn blurred for the signed-in user (the + * "Blur booking codes" preference, blur_booking_codes). An unset preference + * reads as off. The pieces in `components/shared/BookingCode` build on it; + * reach for it directly only where a surface has to lay out a code + * differently while it is hidden. + */ +export function useBlurBookingCodes(): boolean { + return useSettingsStore(s => !!s.settings.blur_booking_codes) +} diff --git a/client/src/hooks/useDawarichConnection.test.ts b/client/src/hooks/useDawarichConnection.test.ts new file mode 100644 index 000000000..ba8ac0960 --- /dev/null +++ b/client/src/hooks/useDawarichConnection.test.ts @@ -0,0 +1,721 @@ +/** + * FE-DAWARICH-CONN-001 to FE-DAWARICH-CONN-027: the connection card's one hook. + * + * Everything the Dawarich connection card does lives here, desktop shell and + * phone shell alike, which means everything it can get wrong lives here too. + * Four things are worth pinning, and the cases below are grouped around them. + * + * 1. **The reason-code chain.** Dawarich answers in English and occasionally + * quotes the URL back, so the hook renders the server's *code* and keeps the + * upstream sentence only as the fallback for a code this build predates. A + * regression here crashes nothing: it quietly ships English, or a bare + * `dawarich.error.foo` key, onto a German install. Every branch of it is a + * sentence a real person reads, so `lastSyncError`, the probe panel, the + * private-IP warning and all four error toasts each get their own case. + * 2. **"Blank means keep the stored key".** The key field is never prefilled, + * so an untouched field must leave `apiKey` out of the payload entirely. + * Sending `apiKey: ''` instead would wipe a working connection on the next + * save of an unrelated switch. + * 3. **The flags nobody sets by hand.** `canSave` (an address alone is not a + * connection), `alreadyRunning` (a sync in flight has no result yet, so + * "0 new stays" would be a claim rather than a report), and the three + * in-flight booleans the buttons disable themselves with. + * 4. **The unmount guard** on the initial read, the reason a card closed + * mid-request does not write into a dead component. + * + * The API and the toaster are mocked, the way the neighbouring + * `useDawarichSuggestions` test does it. Translation is not: `en` is bundled + * statically, so the real provider answers synchronously, and the hook's + * fallbacks depend on `t()` returning the key untouched for a string this build + * does not have, which only the real one does. + */ +import { createElement, type ReactNode } from 'react' +import { act, renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { DawarichCapabilities, DawarichConnection, DawarichStatus } from '@trek/shared' +import { TranslationProvider } from '../i18n/TranslationContext' +import { useDawarichConnection } from './useDawarichConnection' + +const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } +vi.mock('../components/shared/Toast', () => ({ useToast: () => toast, default: () => toast })) + +const api = { + getSettings: vi.fn(), + saveSettings: vi.fn(), + test: vi.fn(), + syncNow: vi.fn(), + disconnect: vi.fn(), +} +vi.mock('../api/dawarich', () => ({ + dawarichApi: { + getSettings: (...args: unknown[]) => api.getSettings(...args), + saveSettings: (...args: unknown[]) => api.saveSettings(...args), + test: (...args: unknown[]) => api.test(...args), + syncNow: (...args: unknown[]) => api.syncNow(...args), + disconnect: (...args: unknown[]) => api.disconnect(...args), + }, +})) + +const CAPS: DawarichCapabilities = { + visits: true, + tracks: true, + points: false, + locations: true, + visitedCities: false, + visitUpdatedAt: false, + visitCountryCode: false, + serverVersion: '1.14.4', + probedAt: '2026-09-01T08:00:00.000Z', +} + +/** + * A connected instance with a clean history. Written out in full rather than + * cast from a fragment so the day the contract grows a field, this file is one + * of the places that has to answer for it. + */ +function connection(over: Partial = {}): DawarichConnection { + return { + url: 'https://dawarich.example', + apiKeyMasked: '****abcd', + allowInsecureTls: false, + syncEnabled: true, + connected: true, + lastSyncAt: '2026-09-01T07:00:00.000Z', + lastSyncState: 'ok', + lastSyncError: null, + capabilities: CAPS, + ...over, + } +} + +/** A promise this test controls the settling of, to pin down state mid-flight. */ +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children) + +function mount() { + return renderHook(() => useDawarichConnection(), { wrapper }) +} + +async function mounted(data: DawarichConnection = connection()) { + api.getSettings.mockResolvedValue(data) + const hook = mount() + await waitFor(() => expect(hook.result.current.loading).toBe(false)) + return hook +} + +// The shipped English for the keys this hook reaches for. Written out rather +// than read back from the bundle so a reader of this file sees the sentence that +// ends up on screen, and so a reworded string is a decision somebody makes here +// too. The private-IP warning is the one exception: it is long enough that +// transcribing it would test the transcription, so it is asserted by its parts. +const EN = { + probeOk: (count: number) => `Connected. ${count} stays found in the last 30 days.`, + probeFailed: 'Could not reach Dawarich.', + saved: 'Dawarich connection saved', + saveError: 'Could not save the connection', + disconnected: 'Dawarich disconnected', + synced: (count: number) => `${count} new stays found`, + syncError: 'Could not read Dawarich', + syncRunning: 'A check is already running', + unreachable: 'TREK could not reach that address.', + unauthorized: 'Dawarich rejected the API key.', + unknown: 'Something went wrong talking to Dawarich.', +} + +/** The last thing a toaster was handed, for the assertions that read a sentence apart. */ +function lastToast(fn: typeof toast.warning): string { + const calls = fn.mock.calls + return calls[calls.length - 1]?.[0] as string +} + +beforeEach(() => { + toast.success.mockReset() + toast.error.mockReset() + toast.info.mockReset() + toast.warning.mockReset() + api.getSettings.mockReset().mockResolvedValue(connection()) + api.saveSettings.mockReset().mockResolvedValue({ success: true }) + api.test.mockReset().mockResolvedValue({ connected: true, visitCount: 4, capabilities: CAPS }) + api.syncNow.mockReset().mockResolvedValue({ state: 'ok', created: 2, updated: 1, missing: 0 }) + api.disconnect.mockReset().mockResolvedValue({ success: true }) +}) + +describe('useDawarichConnection: the initial read', () => { + it('FE-DAWARICH-CONN-001: starts loading and then hydrates every field from the stored connection', async () => { + const settings = deferred() + api.getSettings.mockReturnValue(settings.promise) + const { result } = mount() + + expect(result.current.loading).toBe(true) + expect(result.current.url).toBe('') + expect(result.current.connected).toBe(false) + + await act(async () => { + settings.resolve(connection({ lastSyncState: 'partial', lastSyncError: 'unauthorized' })) + await settings.promise + }) + + expect(result.current.loading).toBe(false) + expect(result.current.url).toBe('https://dawarich.example') + expect(result.current.connected).toBe(true) + expect(result.current.syncEnabled).toBe(true) + expect(result.current.allowInsecureTls).toBe(false) + expect(result.current.lastSyncAt).toBe('2026-09-01T07:00:00.000Z') + expect(result.current.lastSyncState).toBe('partial') + expect(result.current.lastSyncError).toBe(EN.unauthorized) + expect(result.current.capabilities).toEqual(CAPS) + // The stored key never leaves the server, so the field is blank even though + // the connection is live. Everything below about `canSave` follows from it. + expect(result.current.apiKey).toBe('') + }) + + it('FE-DAWARICH-CONN-002: an instance that was never connected leaves the card empty and keeps sync switched off', async () => { + const { result } = await mounted(connection({ + url: '', + allowInsecureTls: true, + syncEnabled: false, + connected: false, + lastSyncAt: null, + lastSyncState: 'never', + capabilities: null, + })) + + expect(result.current.url).toBe('') + expect(result.current.allowInsecureTls).toBe(true) + // `syncEnabled` defaults to on, so an explicit false is the one value that + // has to survive the round trip intact. + expect(result.current.syncEnabled).toBe(false) + expect(result.current.connected).toBe(false) + expect(result.current.capabilities).toBeNull() + expect(result.current.lastSyncAt).toBeNull() + expect(result.current.lastSyncError).toBeNull() + expect(result.current.canSave).toBe(false) + }) + + it('FE-DAWARICH-CONN-003: a failed read of our own settings clears loading without blaming the instance', async () => { + api.getSettings.mockRejectedValue(new Error('500')) + const { result } = mount() + + await waitFor(() => expect(result.current.loading).toBe(false)) + expect(result.current.url).toBe('') + expect(result.current.connected).toBe(false) + expect(result.current.lastSyncState).toBe('never') + // Our own endpoint failing is a TREK problem. An error toast here would send + // the reader off editing a URL that was never at fault. + expect(toast.error).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-CONN-004: settings that arrive after the card is gone are dropped, not written into a dead component', async () => { + const settings = deferred() + api.getSettings.mockReturnValue(settings.promise) + const { result, unmount } = mount() + + unmount() + await act(async () => { + settings.resolve(connection()) + await settings.promise + }) + + // The last rendered values are the pre-flight ones: nothing was set after + // the unmount, not even the loading flag in the `finally`. + expect(result.current.loading).toBe(true) + expect(result.current.url).toBe('') + }) + + it('FE-DAWARICH-CONN-005: the last sync error is rendered from its code, and an unrecognised code still gets a sentence', async () => { + const known = await mounted(connection({ lastSyncState: 'failed', lastSyncError: 'unreachable' })) + expect(known.result.current.lastSyncError).toBe(EN.unreachable) + + // A code from a server newer than this build. It must not reach the screen + // as the bare key, which is what happens the day the fallback goes. + const ahead = await mounted(connection({ lastSyncState: 'failed', lastSyncError: 'quota_exhausted' })) + expect(ahead.result.current.lastSyncError).toBe(EN.unknown) + expect(ahead.result.current.lastSyncError).not.toBe('dawarich.error.quota_exhausted') + + const clean = await mounted(connection({ lastSyncError: null })) + expect(clean.result.current.lastSyncError).toBeNull() + }) +}) + +describe('useDawarichConnection: the save gate', () => { + it('FE-DAWARICH-CONN-006: saving needs an address plus either a live connection or a freshly typed key', async () => { + const { result } = await mounted(connection({ + url: '', + connected: false, + capabilities: null, + lastSyncAt: null, + lastSyncState: 'never', + })) + expect(result.current.canSave).toBe(false) + + act(() => result.current.setUrl(' ')) + expect(result.current.canSave).toBe(false) + + // An address on its own: nothing stored, nothing typed, so there is no key + // to save it with. + act(() => result.current.setUrl('https://dawarich.example')) + expect(result.current.canSave).toBe(false) + + act(() => result.current.setApiKey(' ')) + expect(result.current.canSave).toBe(false) + + act(() => result.current.setApiKey('k-123')) + expect(result.current.canSave).toBe(true) + }) + + it('FE-DAWARICH-CONN-007: an already connected instance stays saveable with the key field left blank', async () => { + const { result } = await mounted() + expect(result.current.apiKey).toBe('') + expect(result.current.canSave).toBe(true) + }) + + it('FE-DAWARICH-CONN-008: both switches flip, and the save they trigger carries no key at all', async () => { + const { result } = await mounted() + + act(() => result.current.toggleInsecureTls()) + act(() => result.current.toggleSync()) + expect(result.current.allowInsecureTls).toBe(true) + expect(result.current.syncEnabled).toBe(false) + + await act(async () => { await result.current.save() }) + + // No `apiKey` property whatsoever: an empty string would clear the stored + // key, and this save was about two switches. + expect(api.saveSettings).toHaveBeenCalledWith({ + url: 'https://dawarich.example', + allowInsecureTls: true, + syncEnabled: false, + }) + + act(() => result.current.toggleInsecureTls()) + expect(result.current.allowInsecureTls).toBe(false) + }) +}) + +describe('useDawarichConnection: save', () => { + it('FE-DAWARICH-CONN-009: save trims the address, sends the typed key once, re-reads the connection and clears the field', async () => { + const { result } = await mounted(connection({ + connected: false, + capabilities: null, + lastSyncState: 'failed', + lastSyncError: 'unreachable', + })) + act(() => { + result.current.setUrl(' https://new.example ') + result.current.setApiKey(' k-123 ') + }) + + const pending = deferred<{ success: boolean }>() + api.saveSettings.mockReturnValue(pending.promise) + api.getSettings.mockResolvedValue(connection({ + url: 'https://new.example', + connected: true, + lastSyncState: 'ok', + lastSyncError: null, + })) + + let done!: Promise + act(() => { done = result.current.save() }) + expect(result.current.saving).toBe(true) + + await act(async () => { + pending.resolve({ success: true }) + await done + }) + + expect(api.saveSettings).toHaveBeenCalledWith({ + url: 'https://new.example', + allowInsecureTls: false, + syncEnabled: true, + apiKey: 'k-123', + }) + // Cleared, so the next save of an unrelated switch does not resend it. + expect(result.current.apiKey).toBe('') + expect(result.current.connected).toBe(true) + expect(result.current.capabilities).toEqual(CAPS) + expect(result.current.lastSyncState).toBe('ok') + expect(result.current.lastSyncError).toBeNull() + expect(result.current.saving).toBe(false) + expect(toast.success).toHaveBeenCalledWith(EN.saved) + }) + + it('FE-DAWARICH-CONN-010: a save the server accepts but cannot connect with flips the card back to disconnected', async () => { + const { result } = await mounted() + expect(result.current.connected).toBe(true) + + api.getSettings.mockResolvedValue(connection({ + connected: false, + capabilities: null, + lastSyncState: 'failed', + lastSyncError: 'unauthorized', + })) + await act(async () => { await result.current.save() }) + + // A saved record and a working connection are two different things, and the + // badge follows the second one. + expect(result.current.connected).toBe(false) + expect(result.current.capabilities).toBeNull() + expect(result.current.lastSyncError).toBe(EN.unauthorized) + }) + + it('FE-DAWARICH-CONN-011: a save that answers with an empty body still counts as saved', async () => { + const { result } = await mounted() + // A 204 arrives as no body at all; the warning branch must not trip on it. + api.saveSettings.mockResolvedValue(undefined) + + await act(async () => { await result.current.save() }) + + expect(toast.success).toHaveBeenCalledWith(EN.saved) + expect(toast.warning).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-CONN-012: a save whose follow-up read fails keeps the card as it was and still reports success', async () => { + const { result } = await mounted() + api.getSettings.mockRejectedValue(new Error('boom')) + + await act(async () => { await result.current.save() }) + + // The write landed. Losing the refresh must not blank the badge, the + // capabilities or the sync history that are still true. + expect(result.current.connected).toBe(true) + expect(result.current.capabilities).toEqual(CAPS) + expect(result.current.lastSyncState).toBe('ok') + expect(toast.success).toHaveBeenCalledWith(EN.saved) + }) + + it('FE-DAWARICH-CONN-013: the private-IP warning is rendered from its code, with the address the server resolved', async () => { + const serverSentence = 'Dawarich resolved to 192.168.1.4, which is a private address.' + const { result } = await mounted() + api.saveSettings.mockResolvedValue({ + success: true, + warning: serverSentence, + warningCode: 'private_ip', + warningIp: '192.168.1.4', + }) + + await act(async () => { await result.current.save() }) + + const warned = lastToast(toast.warning) + expect(warned).toContain('private IP (192.168.1.4)') + // The fix belongs in the sentence, and it is the half a self-hoster needs. + expect(warned).toContain('ALLOW_INTERNAL_NETWORK') + // The server's own English never reaches the reader while the code is known. + expect(warned).not.toBe(serverSentence) + expect(toast.success).not.toHaveBeenCalled() + + // A coded warning with no address attached still translates; the slot is + // filled with nothing rather than left as the literal "{ip}". + api.saveSettings.mockResolvedValue({ success: true, warning: serverSentence, warningCode: 'private_ip' }) + await act(async () => { await result.current.save() }) + expect(lastToast(toast.warning)).toContain('private IP ()') + }) + + it('FE-DAWARICH-CONN-014: a warning code this build has no string for falls back to the server sentence', async () => { + const { result } = await mounted() + api.saveSettings.mockResolvedValue({ + success: true, + warning: 'Heads up: that host answers very slowly.', + warningCode: 'slow_host', + warningIp: '10.0.0.9', + }) + + await act(async () => { await result.current.save() }) + // English prose beats a raw `dawarich.warning.slow_host` on the screen. + expect(toast.warning).toHaveBeenCalledWith('Heads up: that host answers very slowly.') + + // An older server that attaches prose without a code at all. + api.saveSettings.mockResolvedValue({ success: true, warning: 'Saved, but that address looks odd.' }) + await act(async () => { await result.current.save() }) + expect(toast.warning).toHaveBeenLastCalledWith('Saved, but that address looks odd.') + + // A warning is not a success: the card must not say both. + expect(toast.success).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-CONN-015: a rejected save is reported by code first, then by the server sentence, then generically', async () => { + const { result } = await mounted() + + api.saveSettings.mockRejectedValue({ + response: { data: { code: 'unauthorized', error: 'Dawarich answered 401 for https://dawarich.example' } }, + }) + await act(async () => { await result.current.save() }) + // The code wins over the prose, which is English and quotes the URL back. + expect(toast.error).toHaveBeenLastCalledWith(EN.unauthorized) + + api.saveSettings.mockRejectedValue({ + response: { data: { code: 'quota_exhausted', error: 'Quota exhausted until 14:00' } }, + }) + await act(async () => { await result.current.save() }) + expect(toast.error).toHaveBeenLastCalledWith('Quota exhausted until 14:00') + + // Not our envelope at all: a numeric code is ignored, the prose still reads. + api.saveSettings.mockRejectedValue({ response: { data: { code: 409, error: 'conflict' } } }) + await act(async () => { await result.current.save() }) + expect(toast.error).toHaveBeenLastCalledWith('conflict') + + // Nothing usable anywhere in the error (a transport failure, typically). + api.saveSettings.mockRejectedValue(new Error('Network Error')) + await act(async () => { await result.current.save() }) + expect(toast.error).toHaveBeenLastCalledWith(EN.saveError) + + expect(result.current.saving).toBe(false) + expect(toast.success).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-CONN-027: a rejection carrying no error object at all still reaches the reader as a sentence', async () => { + const { result } = await mounted() + // Not a shape axios produces on its own, but the optional chain at the top + // of errorText() is there for it: an aborted or torn-down call rejects with + // nothing, and reading .response off that would throw inside the catch, + // which loses the toast entirely and rejects save() into whoever pressed + // the button. The generic sentence is the floor, and it has to hold. + api.saveSettings.mockRejectedValue(undefined) + + await act(async () => { await result.current.save() }) + + expect(toast.error).toHaveBeenCalledWith(EN.saveError) + expect(result.current.saving).toBe(false) + }) +}) + +describe('useDawarichConnection: the connection probe', () => { + it('FE-DAWARICH-CONN-016: a successful probe reports the count in the panel and the toast, and adopts what it found', async () => { + const { result } = await mounted(connection({ connected: false, capabilities: null })) + act(() => { + result.current.setUrl(' https://probe.example ') + result.current.setApiKey('k-123') + }) + + const probe = deferred() + api.test.mockReturnValue(probe.promise) + let done!: Promise + act(() => { done = result.current.test() }) + expect(result.current.testing).toBe(true) + + await act(async () => { + probe.resolve({ connected: true, visitCount: 12, capabilities: CAPS }) + await done + }) + + // The probe runs against what is typed, not against what is stored. That is + // the whole point of testing before saving. + expect(api.test).toHaveBeenCalledWith({ + url: 'https://probe.example', + allowInsecureTls: false, + apiKey: 'k-123', + }) + expect(result.current.connected).toBe(true) + expect(result.current.capabilities).toEqual(CAPS) + expect(result.current.probeMessage).toBe(EN.probeOk(12)) + expect(toast.success).toHaveBeenCalledWith(EN.probeOk(12)) + expect(result.current.testing).toBe(false) + }) + + it('FE-DAWARICH-CONN-017: a probe that answers without a count or capabilities says zero and keeps what was known', async () => { + const { result } = await mounted() + api.test.mockResolvedValue({ connected: true }) + + await act(async () => { await result.current.test() }) + + // "Connected" with no number reads the same as a reachable instance holding + // nothing at all, which is a genuinely different situation. + expect(result.current.probeMessage).toBe(EN.probeOk(0)) + // A probe that reported no capabilities must not erase the stored ones. + expect(result.current.capabilities).toEqual(CAPS) + }) + + it('FE-DAWARICH-CONN-018: a refused probe puts the reason in the panel and keeps the toast to one line', async () => { + const { result } = await mounted() + api.test.mockResolvedValue({ + connected: false, + error: 'unreachable', + errorDetail: 'self-signed certificate', + }) + + await act(async () => { await result.current.test() }) + + expect(result.current.connected).toBe(false) + // The detail is what separates "the host is down" from "we rejected the + // certificate", and the self-hoster needs it to know which knob to turn. + expect(result.current.probeMessage).toBe(`${EN.unreachable} (self-signed certificate)`) + expect(toast.error).toHaveBeenCalledWith(EN.unreachable) + }) + + it('FE-DAWARICH-CONN-019: a refused probe with no reason at all still says something, and an unrecognised code does too', async () => { + const { result } = await mounted() + + api.test.mockResolvedValue({ connected: false }) + await act(async () => { await result.current.test() }) + expect(result.current.probeMessage).toBe(EN.probeFailed) + + api.test.mockResolvedValue({ connected: false, error: 'quota_exhausted' }) + await act(async () => { await result.current.test() }) + expect(result.current.probeMessage).toBe(EN.unknown) + expect(toast.success).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-CONN-020: a thrown probe clears the stale panel line first, then reports the coded reason or a generic one', async () => { + const { result } = await mounted() + api.test.mockResolvedValue({ connected: true, visitCount: 3 }) + await act(async () => { await result.current.test() }) + expect(result.current.probeMessage).toBe(EN.probeOk(3)) + + const thrown = deferred() + api.test.mockReturnValue(thrown.promise) + let done!: Promise + act(() => { done = result.current.test() }) + // A success belonging to the previous address must not sit under a probe of + // the one being typed now. + expect(result.current.probeMessage).toBeNull() + + await act(async () => { + thrown.reject({ response: { data: { code: 'unauthorized' } } }) + await done + }) + expect(result.current.probeMessage).toBe(EN.unauthorized) + expect(toast.error).toHaveBeenLastCalledWith(EN.unauthorized) + + api.test.mockRejectedValue(new Error('Network Error')) + await act(async () => { await result.current.test() }) + expect(result.current.probeMessage).toBe(EN.probeFailed) + expect(result.current.testing).toBe(false) + }) +}) + +describe('useDawarichConnection: sync now', () => { + it('FE-DAWARICH-CONN-021: a sync that is already running says so and invents no result', async () => { + const { result } = await mounted(connection({ lastSyncState: 'partial', lastSyncError: 'unreachable' })) + api.syncNow.mockResolvedValue({ state: 'running', created: 0, updated: 0, missing: 0, alreadyRunning: true }) + + await act(async () => { await result.current.syncNow() }) + + expect(toast.info).toHaveBeenCalledWith(EN.syncRunning) + // Nothing finished, so nothing about the last run may move, least of all a + // "0 new stays" that would read as this run's answer. + expect(result.current.lastSyncAt).toBe('2026-09-01T07:00:00.000Z') + expect(result.current.lastSyncState).toBe('partial') + expect(result.current.lastSyncError).toBe(EN.unreachable) + expect(toast.success).not.toHaveBeenCalled() + expect(result.current.syncing).toBe(false) + }) + + it('FE-DAWARICH-CONN-022: a finished sync stamps the time, takes the server state and clears the previous error', async () => { + const { result } = await mounted(connection({ + lastSyncAt: '2026-08-01T00:00:00.000Z', + lastSyncState: 'failed', + lastSyncError: 'unreachable', + })) + + const run = deferred<{ state: string; created: number; updated: number; missing: number }>() + api.syncNow.mockReturnValue(run.promise) + const before = Date.now() + let done!: Promise + act(() => { done = result.current.syncNow() }) + expect(result.current.syncing).toBe(true) + + await act(async () => { + run.resolve({ state: 'partial', created: 3, updated: 1, missing: 2 }) + await done + }) + + // A partial run is not a clean one, so the server's word is kept rather than + // flattened to "ok", but the stale failure underneath it has to go. + expect(result.current.lastSyncState).toBe('partial') + expect(result.current.lastSyncError).toBeNull() + expect(Date.parse(result.current.lastSyncAt ?? '')).toBeGreaterThanOrEqual(before) + expect(toast.success).toHaveBeenCalledWith(EN.synced(3)) + expect(result.current.syncing).toBe(false) + }) + + it('FE-DAWARICH-CONN-023: a sync answer without a state or a count is treated as a clean run that found nothing', async () => { + const { result } = await mounted(connection({ lastSyncAt: null, lastSyncState: 'never' })) + api.syncNow.mockResolvedValue({}) + + await act(async () => { await result.current.syncNow() }) + + expect(result.current.lastSyncState).toBe('ok') + expect(result.current.lastSyncAt).not.toBeNull() + expect(toast.success).toHaveBeenCalledWith(EN.synced(0)) + }) + + it('FE-DAWARICH-CONN-024: a sync that throws marks the state failed and reports the coded reason, or a generic one', async () => { + const { result } = await mounted() + + api.syncNow.mockRejectedValue({ response: { data: { code: 'unauthorized' } } }) + await act(async () => { await result.current.syncNow() }) + // The card has to stop claiming the last run was fine. + expect(result.current.lastSyncState).toBe('failed') + expect(toast.error).toHaveBeenCalledWith(EN.unauthorized) + + // An envelope whose `error` is an object rather than a sentence: unusable, + // so the generic line stands instead of "[object Object]". + api.syncNow.mockRejectedValue({ response: { data: { error: { reason: 'locked' } } } }) + await act(async () => { await result.current.syncNow() }) + expect(toast.error).toHaveBeenLastCalledWith(EN.syncError) + expect(result.current.syncing).toBe(false) + }) +}) + +describe('useDawarichConnection: disconnect', () => { + it('FE-DAWARICH-CONN-025: disconnect empties the card down to the probe message', async () => { + const { result } = await mounted(connection({ lastSyncState: 'failed', lastSyncError: 'unreachable' })) + act(() => result.current.setApiKey('k-123')) + api.test.mockResolvedValue({ connected: true, visitCount: 1 }) + await act(async () => { await result.current.test() }) + expect(result.current.probeMessage).not.toBeNull() + + const drop = deferred<{ success: boolean }>() + api.disconnect.mockReturnValue(drop.promise) + let done!: Promise + act(() => { done = result.current.disconnect() }) + // Disconnect borrows the saving flag: both writes touch the same record, and + // the card disables the same buttons for either. + expect(result.current.saving).toBe(true) + + await act(async () => { + drop.resolve({ success: true }) + await done + }) + + // Every leftover matters here: a stale probe line or sync history under an + // empty form reads as a connection that is still half there. + expect(result.current.url).toBe('') + expect(result.current.apiKey).toBe('') + expect(result.current.connected).toBe(false) + expect(result.current.capabilities).toBeNull() + expect(result.current.lastSyncAt).toBeNull() + expect(result.current.lastSyncState).toBe('never') + expect(result.current.lastSyncError).toBeNull() + expect(result.current.probeMessage).toBeNull() + expect(result.current.canSave).toBe(false) + expect(result.current.saving).toBe(false) + expect(toast.success).toHaveBeenCalledWith(EN.disconnected) + }) + + it('FE-DAWARICH-CONN-026: a failed disconnect leaves the connection on screen rather than pretending it is gone', async () => { + const { result } = await mounted() + + api.disconnect.mockRejectedValue({ response: { data: { error: 'Database is locked' } } }) + await act(async () => { await result.current.disconnect() }) + + expect(result.current.url).toBe('https://dawarich.example') + expect(result.current.connected).toBe(true) + expect(result.current.capabilities).toEqual(CAPS) + expect(toast.error).toHaveBeenCalledWith('Database is locked') + expect(result.current.saving).toBe(false) + + api.disconnect.mockRejectedValue(new Error('offline')) + await act(async () => { await result.current.disconnect() }) + expect(toast.error).toHaveBeenLastCalledWith(EN.saveError) + expect(result.current.connected).toBe(true) + }) +}) diff --git a/client/src/hooks/useDawarichConnection.ts b/client/src/hooks/useDawarichConnection.ts new file mode 100644 index 000000000..a965ca473 --- /dev/null +++ b/client/src/hooks/useDawarichConnection.ts @@ -0,0 +1,272 @@ +import { useCallback, useEffect, useState } from 'react' +import type { DawarichCapabilities, DawarichErrorCode, DawarichSyncState } from '@trek/shared' +import { dawarichApi } from '../api/dawarich' +import { useTranslation } from '../i18n' +import { useToast } from '../components/shared/Toast' + +/** + * Everything the Dawarich connection card does, in one hook. + * + * Shared by the desktop section and its phone twin on purpose. The two shells + * render very different markup and identical logic, and SonarCloud's 3% + * duplication budget on new code is not big enough for a second copy of this — + * `useInstanceSettings` under `components/Admin/` is the same pattern for the + * same reason. + */ +export interface DawarichConnectionState { + url: string + setUrl: (value: string) => void + apiKey: string + setApiKey: (value: string) => void + allowInsecureTls: boolean + toggleInsecureTls: () => void + syncEnabled: boolean + toggleSync: () => void + + connected: boolean + loading: boolean + saving: boolean + testing: boolean + syncing: boolean + + lastSyncAt: string | null + lastSyncState: DawarichSyncState + /** Already translated; null when the last sync was clean. */ + lastSyncError: string | null + capabilities: DawarichCapabilities | null + /** Set after a test run, so the card can show what the probe found. */ + probeMessage: string | null + + canSave: boolean + save: () => Promise + test: () => Promise + syncNow: () => Promise + disconnect: () => Promise +} + +export function useDawarichConnection(): DawarichConnectionState { + const { t } = useTranslation() + const toast = useToast() + + const [url, setUrl] = useState('') + const [apiKey, setApiKey] = useState('') + const [allowInsecureTls, setAllowInsecureTls] = useState(false) + const [syncEnabled, setSyncEnabled] = useState(true) + const [connected, setConnected] = useState(false) + const [lastSyncAt, setLastSyncAt] = useState(null) + const [lastSyncState, setLastSyncState] = useState('never') + const [lastSyncErrorCode, setLastSyncErrorCode] = useState(null) + const [capabilities, setCapabilities] = useState(null) + const [probeMessage, setProbeMessage] = useState(null) + + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [testing, setTesting] = useState(false) + const [syncing, setSyncing] = useState(false) + + /** + * Reason codes rather than the upstream sentence: Dawarich answers in English + * and occasionally quotes the URL back, and neither belongs on a German + * install. An unknown code still gets a sentence rather than nothing. + */ + const translateError = useCallback( + (code: string | null): string | null => { + if (!code) return null + const key = `dawarich.error.${code}` + const text = t(key) + return text === key ? t('dawarich.error.unknown') : text + }, + [t], + ) + + useEffect(() => { + let cancelled = false + dawarichApi + .getSettings() + .then(data => { + if (cancelled) return + setUrl(data.url || '') + setAllowInsecureTls(!!data.allowInsecureTls) + setSyncEnabled(data.syncEnabled !== false) + setConnected(!!data.connected) + setLastSyncAt(data.lastSyncAt) + setLastSyncState(data.lastSyncState) + setLastSyncErrorCode(data.lastSyncError) + setCapabilities(data.capabilities) + }) + .catch(() => { + // A failed read of our own settings is a TREK problem, not a Dawarich + // one — the card stays in its empty state rather than claiming the + // instance is unreachable. + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + /** The key field is never prefilled, so blank means "keep the stored one". */ + const keyPayload = useCallback((): { apiKey?: string } => { + const typed = apiKey.trim() + return typed ? { apiKey: typed } : {} + }, [apiKey]) + + const save = useCallback(async () => { + setSaving(true) + try { + const result = await dawarichApi.saveSettings({ + url: url.trim(), + allowInsecureTls, + syncEnabled, + ...keyPayload(), + }) + const fresh = await dawarichApi.getSettings().catch(() => null) + if (fresh) { + setConnected(!!fresh.connected) + setCapabilities(fresh.capabilities) + setLastSyncState(fresh.lastSyncState) + setLastSyncErrorCode(fresh.lastSyncError) + } + setApiKey('') + // The warning the server attaches when the address resolves to a private + // IP. Rendered from its code so it arrives in the reader's language; the + // server's own sentence is the fallback for a code this build predates. + if (result?.warning) { + const key = result.warningCode ? `dawarich.warning.${result.warningCode}` : '' + const translated = key ? t(key, { ip: result.warningIp ?? '' }) : '' + toast.warning(translated && translated !== key ? translated : result.warning) + } + else toast.success(t('dawarich.toast.saved')) + } catch (err) { + toast.error(errorText(err, t) || t('dawarich.toast.saveError')) + } finally { + setSaving(false) + } + }, [url, allowInsecureTls, syncEnabled, keyPayload, toast, t]) + + const test = useCallback(async () => { + setTesting(true) + setProbeMessage(null) + try { + const result = await dawarichApi.test({ url: url.trim(), allowInsecureTls, ...keyPayload() }) + setConnected(!!result.connected) + if (result.capabilities) setCapabilities(result.capabilities) + if (result.connected) { + setProbeMessage(t('dawarich.test.success', { count: result.visitCount ?? 0 })) + toast.success(t('dawarich.test.success', { count: result.visitCount ?? 0 })) + } else { + const message = translateError(result.error ?? null) ?? t('dawarich.test.failed') + // The reason underneath, where the server managed to name one. The codes + // above are deliberately vague so an English sentence from Dawarich does + // not land on a German install, but "TREK could not reach that address" + // alone leaves a self-hoster with nothing to act on: it reads the same + // whether the host is down, the certificate was rejected, or TREK refused + // a private address on purpose and would need ALLOW_INTERNAL_NETWORK to + // permit it. The panel gets both; the toast stays one line. + setProbeMessage(result.errorDetail ? `${message} (${result.errorDetail})` : message) + toast.error(message) + } + } catch (err) { + const message = errorText(err, t) || t('dawarich.test.failed') + setProbeMessage(message) + toast.error(message) + } finally { + setTesting(false) + } + }, [url, allowInsecureTls, keyPayload, toast, t, translateError]) + + const syncNow = useCallback(async () => { + setSyncing(true) + try { + const result = await dawarichApi.syncNow() + if (result.alreadyRunning) { + // Somebody (or the cron) is mid-sync. Saying "0 new stays" here would be + // a result, and there is no result yet. + toast.info(t('dawarich.toast.syncRunning')) + return + } + setLastSyncAt(new Date().toISOString()) + setLastSyncState((result.state as DawarichSyncState) ?? 'ok') + setLastSyncErrorCode(null) + // Said as a count rather than "done": a sync that found nothing and one + // that failed silently look identical otherwise. + toast.success(t('dawarich.toast.synced', { count: result.created ?? 0 })) + } catch (err) { + setLastSyncState('failed') + toast.error(errorText(err, t) || t('dawarich.toast.syncError')) + } finally { + setSyncing(false) + } + }, [toast, t]) + + const disconnect = useCallback(async () => { + setSaving(true) + try { + await dawarichApi.disconnect() + setUrl('') + setApiKey('') + setConnected(false) + setCapabilities(null) + setLastSyncAt(null) + setLastSyncState('never') + setLastSyncErrorCode(null) + setProbeMessage(null) + toast.success(t('dawarich.toast.disconnected')) + } catch (err) { + toast.error(errorText(err, t) || t('dawarich.toast.saveError')) + } finally { + setSaving(false) + } + }, [toast, t]) + + return { + url, + setUrl, + apiKey, + setApiKey, + allowInsecureTls, + toggleInsecureTls: () => setAllowInsecureTls(v => !v), + syncEnabled, + toggleSync: () => setSyncEnabled(v => !v), + connected, + loading, + saving, + testing, + syncing, + lastSyncAt, + lastSyncState, + lastSyncError: translateError(lastSyncErrorCode), + capabilities, + probeMessage, + // An address alone is not a connection, and the key field is blank once a + // key is stored — so saving needs either an existing connection or a typed key. + canSave: !!url.trim() && (connected || !!apiKey.trim()), + save, + test, + syncNow, + disconnect, + } +} + +/** + * The server's `{ code, error }` envelope, as a sentence the reader can read. + * + * The code comes first and the prose is the fallback: the server's own sentence + * is written in English, and a German install should not be told what went + * wrong in a language it does not speak. A code this version has no string for + * falls back to the sentence rather than to nothing. + */ +function errorText(err: unknown, t?: (key: string) => string): string | null { + const data = (err as { response?: { data?: { error?: unknown; code?: unknown } } })?.response?.data + if (t && typeof data?.code === 'string') { + const key = `dawarich.error.${data.code}` + const text = t(key) + if (text !== key) return text + } + return typeof data?.error === 'string' ? data.error : null +} + +/** Exported for the error-code type so consumers do not re-declare it. */ +export type { DawarichErrorCode } diff --git a/client/src/hooks/useDawarichJournalTrail.test.ts b/client/src/hooks/useDawarichJournalTrail.test.ts new file mode 100644 index 000000000..508849582 --- /dev/null +++ b/client/src/hooks/useDawarichJournalTrail.test.ts @@ -0,0 +1,396 @@ +/** + * FE-DAWARICH-JTRAIL-001 to FE-DAWARICH-JTRAIL-013: the recorded route a + * journal draws over its own dates (#2279). + * + * This hook is the one place a journal reaches across the network to somebody's + * self-hosted location history, and almost everything worth pinning about it is + * a thing that must NOT happen. + * + * The gate first. Three separate switches have to agree before a single request + * leaves the browser (the journal's own "show trip tracks" preference, the + * Dawarich addon being enabled on the instance, and the journal actually + * covering some dates), and each of them is a promise to the user: a journal + * whose tracks are switched off must not go and fetch a GPS archive anyway. + * They are `&&`-chained, so a regression in any one of them looks identical on + * screen (nothing is drawn) while the request goes out regardless. Only the + * absence of the call catches that, which is why the first three cases assert + * on `windowTrack` never having been called rather than on what was rendered. + * + * Then the window. A `Set` has no order and a journal's dates are accumulated + * from several trips, so the earliest and latest day are found by sorting, not + * by trusting insertion order. Get that wrong and the fetched span is some + * arbitrary pair of days out of the middle of the journey. + * + * Then the shape. The overlay rides the journey map's existing `JourneyTrack[]` + * layer, which keys its polylines on `place_id`. The synthetic ids are negative + * precisely so they can never collide with a real place's. A collision would + * make the map drop or duplicate a line rather than fail loudly. + * + * Finally the four endings, because the journal map has no other way to say + * what happened: a route, nothing recorded, the instance not answering, and the + * device being offline. The last one is checked twice, before the request and + * again when it fails, so a connection that dies mid-flight is reported as + * "offline" rather than blamed on the user's Dawarich instance. + * + * The late-answer cases exist because this hook lives on a page the user leaves + * and a switch they flip: an in-flight request that lands after the layer has + * been switched off must not paint a line onto a map that no longer wants one. + * + * That switch going off after a route is already drawn is its own case. The + * line has to leave the map with it rather than linger until something else + * happens to clear it, and turning it back on has to ask again rather than + * replay a recording that may have grown in the meantime. This hook keeps no + * cache, deliberately, because a cached copy of somebody's location history is + * the thing the integration exists not to keep. + */ +import { act, renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { DawarichTrack, DawarichTrackDay } from '@trek/shared' + +const { windowTrack, offline } = vi.hoisted(() => ({ windowTrack: vi.fn(), offline: vi.fn() })) +vi.mock('../repo/dawarichRepo', () => ({ dawarichRepo: { windowTrack } })) +// Only `isEffectivelyOffline` is swapped out. `addonStore` reaches `api/client`, +// which imports from this module as well, and a factory that named a single +// export would hand every other importer in the graph `undefined`. +vi.mock('../sync/networkMode', async importOriginal => ({ + ...(await importOriginal()), + isEffectivelyOffline: offline, +})) + +import { useAddonStore } from '../store/addonStore' +import { DAWARICH_DAY_COLORS } from '../components/Map/dawarichTrail' +import { useDawarichJournalTrail } from './useDawarichJournalTrail' + +const DAY_ONE = '2026-03-03' +const DAY_TWO = '2026-03-04' +const DAY_THREE = '2026-03-05' + +const FETCHED_AT = '2026-03-06T09:12:00.000Z' + +const journalDates = (...days: string[]) => new Set(days) + +function segment(points: Array<[number, number]>) { + return { + points, + mode: 'walking', + startedAt: `${DAY_ONE}T08:00:00Z`, + endedAt: `${DAY_ONE}T09:00:00Z`, + distanceMeters: 1200, + } +} + +/** One recorded day with a single two-point line: the smallest thing that draws. */ +const day = (date: string, points: Array<[number, number]> = [[53.5, 10.0], [53.6, 10.1]]): DawarichTrackDay => + ({ date, segments: [segment(points)] }) + +function track(over: Partial = {}): DawarichTrack { + return { days: [], source: 'tracks', fetchedAt: FETCHED_AT, pointCount: 0, truncated: false, ...over } +} + +/** + * A request left hanging, so the world can change underneath it: the dates + * edited, the layer switched off, the network lost. None of that is observable + * against a promise that has already resolved. + */ +function held() { + let settle!: (value: DawarichTrack) => void + let fail!: (reason: Error) => void + const promise = new Promise((resolve, reject) => { settle = resolve; fail = reject }) + return { promise, settle, fail } +} + +/** + * Land a held request and let the hook's whole `.then`/`.catch` chain run + * before anything is asserted. Two ticks, because a rejection travels through + * the `.then` that has no rejection handler before it reaches the `.catch`. + * + * Worth being exact about: the late-answer cases assert that nothing changed, + * so a chain that had not run yet would pass them for the wrong reason and + * never reach the `cancelled` guard they exist to pin. + */ +const settleAndFlush = (fire: () => void) => + act(async () => { + fire() + await Promise.resolve() + await Promise.resolve() + }) + +const spanOf = (call: unknown[]) => ({ from: call[0] as string, to: call[1] as string }) +const signalOf = (call: unknown[]) => call[2] as AbortSignal + +const render = (initialProps: { dates: Set; enabled: boolean }) => + renderHook(({ dates, enabled }) => useDawarichJournalTrail(dates, enabled), { initialProps }) + +beforeEach(() => { + windowTrack.mockReset().mockResolvedValue(track()) + offline.mockReset().mockReturnValue(false) + useAddonStore.setState({ + addons: [{ id: 'dawarich', name: 'Dawarich', type: 'integration', icon: 'Route', enabled: true }], + loaded: true, + }) +}) + +describe('useDawarichJournalTrail', () => { + it('FE-DAWARICH-JTRAIL-001: asks nothing on an instance where the addon is switched off', () => { + useAddonStore.setState({ addons: [] }) + + const set = journalDates(DAY_ONE, DAY_TWO) + const { result, rerender } = render({ dates: set, enabled: true }) + + // The addon being off is an instance-wide "do not talk to Dawarich". A + // journal that fetched anyway would keep hitting a server the admin has + // disconnected, and nothing on screen would show it. + expect(windowTrack).not.toHaveBeenCalled() + expect(result.current.status).toBe('idle') + expect(result.current.tracks).toHaveLength(0) + expect(result.current.fetchedAt).toBeNull() + + // The empty result is one shared array, not a fresh literal per pass: the + // journey map memoises on `tracks`, so a new identity every render would + // rebuild every polyline on the map for nothing. + const first = result.current.tracks + rerender({ dates: set, enabled: false }) + expect(result.current.tracks).toBe(first) + }) + + it('FE-DAWARICH-JTRAIL-002: asks nothing while the journal has its track layer switched off', async () => { + const set = journalDates(DAY_ONE, DAY_TWO) + const { result, rerender } = render({ dates: set, enabled: false }) + + // `show_trip_tracks` gates the REQUEST, not just the drawing. Fetching and + // then hiding would send the journal's whole date range to Dawarich on + // behalf of somebody who asked for no tracks at all. + expect(windowTrack).not.toHaveBeenCalled() + expect(result.current.status).toBe('idle') + + rerender({ dates: set, enabled: true }) + await waitFor(() => expect(windowTrack).toHaveBeenCalledTimes(1)) + }) + + it('FE-DAWARICH-JTRAIL-003: a journal with no dated trips has no window to ask about', () => { + // Entries whose trips carry no start/end date contribute nothing to the + // set. With an empty set there are no bounds to build a window from, and + // sending one anyway would mean sending `undefined` as a timestamp. + const { result } = render({ dates: journalDates(), enabled: true }) + + expect(windowTrack).not.toHaveBeenCalled() + expect(result.current.status).toBe('idle') + expect(result.current.tracks).toHaveLength(0) + expect(result.current.fetchedAt).toBeNull() + }) + + it('FE-DAWARICH-JTRAIL-004: spans earliest to latest day whatever order the dates arrived in', async () => { + // The dates come out of several trips in whatever order those trips are + // listed, so the set is deliberately built back to front here. Trusting + // insertion order would fetch the 5th to the 3rd: a backwards window, which + // comes back empty rather than failing. + const { result } = render({ dates: journalDates(DAY_THREE, DAY_ONE, DAY_TWO), enabled: true }) + + expect(result.current.status).toBe('loading') + await waitFor(() => expect(result.current.status).toBe('ready')) + + // Whole-day bounds in UTC: a window that started at the moment of the fetch + // would silently drop the morning of the first day. + expect(spanOf(windowTrack.mock.calls[0])).toEqual({ + from: `${DAY_ONE}T00:00:00Z`, + to: `${DAY_THREE}T23:59:59Z`, + }) + expect(signalOf(windowTrack.mock.calls[0])).toBeInstanceOf(AbortSignal) + expect(signalOf(windowTrack.mock.calls[0]).aborted).toBe(false) + }) + + it('FE-DAWARICH-JTRAIL-005: turns each drawable segment into one journey track the map cannot confuse with a place', async () => { + windowTrack.mockResolvedValue(track({ + days: [ + { + date: DAY_ONE, + segments: [ + segment([[53.5, 10.0], [53.6, 10.1]]), + // A single fix is not a line. It has to disappear here rather than + // reach the renderer, which would draw a zero-length polyline. + segment([[53.7, 10.2]]), + ], + }, + day(DAY_TWO, [[48.1, 11.5], [48.2, 11.6], [48.3, 11.7]]), + ], + pointCount: 6, + })) + + const { result } = render({ dates: journalDates(DAY_ONE, DAY_TWO), enabled: true }) + await waitFor(() => expect(result.current.status).toBe('ready')) + + expect(result.current.tracks).toEqual([ + { + place_id: -1, + trip_id: 0, + name: DAY_ONE, + color: DAWARICH_DAY_COLORS[0], + points: [[53.5, 10.0], [53.6, 10.1]], + }, + { + place_id: -2, + trip_id: 0, + name: DAY_TWO, + // Per-day hues, so "we were here on Tuesday" is readable; a second day + // in the first day's colour would read as one continuous recording. + color: DAWARICH_DAY_COLORS[1], + points: [[48.1, 11.5], [48.2, 11.6], [48.3, 11.7]], + }, + ]) + // The journey map keys its polylines on place_id. Real places are positive, + // so negative-and-distinct is what keeps a recorded line from evicting the + // GPX track of an actual place. + const ids = result.current.tracks.map(t => t.place_id) + expect(ids.every(id => id < 0)).toBe(true) + expect(new Set(ids).size).toBe(ids.length) + + expect(result.current.fetchedAt).toBe(FETCHED_AT) + }) + + it('FE-DAWARICH-JTRAIL-006: a window with nothing recorded is an answer, not a failure', async () => { + // Somebody who was not carrying a phone that week gets an empty track back, + // and that is a perfectly good reply. Treating it as unavailable would + // accuse their Dawarich instance of being broken. + windowTrack.mockResolvedValue(track({ days: [] })) + + const { result } = render({ dates: journalDates(DAY_ONE), enabled: true }) + await waitFor(() => expect(result.current.status).toBe('ready')) + + expect(result.current.tracks).toHaveLength(0) + expect(result.current.fetchedAt).toBe(FETCHED_AT) + }) + + it('FE-DAWARICH-JTRAIL-007: an instance that does not answer leaves the journal without a line and says so', async () => { + // Anything the repo throws lands here: a 502 from the proxy, a body that + // does not parse against the shared schema, a missing API key. None of them + // is the journal's fault and none may leave a half-drawn route. + windowTrack.mockRejectedValue(new Error('502 from the Dawarich instance')) + + const { result } = render({ dates: journalDates(DAY_ONE, DAY_TWO), enabled: true }) + await waitFor(() => expect(result.current.status).toBe('unavailable')) + + expect(result.current.tracks).toHaveLength(0) + }) + + it('FE-DAWARICH-JTRAIL-008: a connection that dies mid-flight is reported as offline, not as a broken instance', async () => { + // The device is online when the request leaves and gone by the time it + // fails. The flag is flipped while the request is held in the air, rather + // than queued per call, so the case says what it means and does not depend + // on how many times the hook happens to consult it. Blaming the instance + // here would send the user reading server logs over a dropped wifi. + const inFlight = held() + windowTrack.mockImplementationOnce(() => inFlight.promise) + + const { result } = render({ dates: journalDates(DAY_ONE), enabled: true }) + expect(result.current.status).toBe('loading') + + offline.mockReturnValue(true) + await settleAndFlush(() => inFlight.fail(new Error('network error'))) + + expect(result.current.status).toBe('offline') + expect(windowTrack).toHaveBeenCalledTimes(1) + expect(result.current.tracks).toHaveLength(0) + }) + + it('FE-DAWARICH-JTRAIL-009: a device already offline is not made to wait for a timeout', () => { + // `isEffectivelyOffline()` includes the manual switch, so this is also the + // person who turned offline mode on before a flight. Firing the request + // anyway would block the journal map until the request timed out, for an + // answer that cannot arrive. + offline.mockReturnValue(true) + + const { result } = render({ dates: journalDates(DAY_ONE, DAY_THREE), enabled: true }) + + expect(windowTrack).not.toHaveBeenCalled() + expect(result.current.status).toBe('offline') + expect(result.current.tracks).toHaveLength(0) + }) + + it('FE-DAWARICH-JTRAIL-010: a journal whose dates change abandons the answer to the old span', async () => { + const first = held() + windowTrack.mockImplementationOnce(() => first.promise) + + const { result, rerender } = render({ dates: journalDates(DAY_ONE), enabled: true }) + expect(result.current.status).toBe('loading') + + windowTrack.mockResolvedValueOnce(track({ days: [day(DAY_THREE)] })) + rerender({ dates: journalDates(DAY_THREE), enabled: true }) + await waitFor(() => expect(result.current.status).toBe('ready')) + + expect(spanOf(windowTrack.mock.calls[1]).from).toBe(`${DAY_THREE}T00:00:00Z`) + // The first request is cancelled at the wire, not merely ignored: a journal + // being edited would otherwise pile up requests against somebody's own + // server, each one a full location-history query. + expect(signalOf(windowTrack.mock.calls[0]).aborted).toBe(true) + + // The abandoned request answers late, the way a slow one does. Landing it + // now would draw the old span's route under the new dates. + await settleAndFlush(() => first.settle(track({ days: [day(DAY_ONE)] }))) + + expect(result.current.tracks.map(t => t.name)).toEqual([DAY_THREE]) + }) + + it('FE-DAWARICH-JTRAIL-011: an answer that arrives after the layer was switched off is dropped', async () => { + const pending = held() + windowTrack.mockImplementationOnce(() => pending.promise) + + const set = journalDates(DAY_ONE) + const { result, rerender } = render({ dates: set, enabled: true }) + expect(result.current.status).toBe('loading') + + rerender({ dates: set, enabled: false }) + expect(result.current.status).toBe('idle') + + // Switching the layer off is an instruction about the map, and a request + // that was already in the air must not undo it a second later. + await settleAndFlush(() => pending.settle(track({ days: [day(DAY_ONE)] }))) + + expect(result.current.status).toBe('idle') + expect(result.current.tracks).toHaveLength(0) + }) + + it('FE-DAWARICH-JTRAIL-012: a failure that arrives after the layer was switched off stays silent', async () => { + const pending = held() + windowTrack.mockImplementationOnce(() => pending.promise) + + const set = journalDates(DAY_ONE, DAY_TWO) + const { result, rerender } = render({ dates: set, enabled: true }) + expect(result.current.status).toBe('loading') + + rerender({ dates: set, enabled: false }) + + // The mirror of the case above: a late failure would put the layer into + // `unavailable` while it is switched off, which is a complaint about a + // request nobody is waiting for any more. + await settleAndFlush(() => pending.fail(new Error('too late to matter'))) + + expect(result.current.status).toBe('idle') + expect(result.current.tracks).toHaveLength(0) + }) + + it('FE-DAWARICH-JTRAIL-013: a route already drawn leaves the map with the switch, and is asked for again when it comes back', async () => { + windowTrack.mockResolvedValue(track({ days: [day(DAY_ONE)], pointCount: 2 })) + + const set = journalDates(DAY_ONE, DAY_TWO) + const { result, rerender } = render({ dates: set, enabled: true }) + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(result.current.tracks).toHaveLength(1) + + // Switching off has to withdraw the line that is already on screen, not + // merely stop the next fetch: a hook that kept handing out the last answer + // would leave somebody's recorded route drawn over a journal that asked for + // it to be hidden, and nothing would clear it until the page was reloaded. + rerender({ dates: set, enabled: false }) + expect(result.current.status).toBe('idle') + expect(result.current.tracks).toHaveLength(0) + + rerender({ dates: set, enabled: true }) + expect(result.current.status).toBe('loading') + await waitFor(() => expect(result.current.status).toBe('ready')) + + // Asked again rather than replayed. Nothing here is cached by design, and + // the recording may well have grown while the layer was off. + expect(windowTrack).toHaveBeenCalledTimes(2) + expect(result.current.tracks).toHaveLength(1) + }) +}) diff --git a/client/src/hooks/useDawarichJournalTrail.ts b/client/src/hooks/useDawarichJournalTrail.ts new file mode 100644 index 000000000..a16068c24 --- /dev/null +++ b/client/src/hooks/useDawarichJournalTrail.ts @@ -0,0 +1,95 @@ +import { useEffect, useMemo, useState } from 'react' +import type { JourneyTrack } from '@trek/shared' +import { dawarichRepo } from '../repo/dawarichRepo' +import { useAddonStore } from '../store/addonStore' +import { trailSegments } from '../components/Map/dawarichTrail' +import { isEffectivelyOffline } from '../sync/networkMode' + +/** + * The recorded route for a journal, shaped as the journey map's own tracks. + * + * The journey map already draws `JourneyTrack[]` — several separate polylines, + * each with its own colour and a white casing, described in that file as + * reading "as a recorded route rather than as the dashed line that merely + * connects entries in time order". That is exactly what this is, so it rides + * the existing layer instead of adding a third one. + * + * It is deliberately NOT the map's `trail` prop: that draws a single polyline, + * and a day's recording is several disconnected pieces — joining them would + * paint straight lines across gaps the traveller never travelled. + * + * The synthetic `place_id` is negative so it can never collide with a real + * place's, which is what the map keys its lines on. + */ +export function useDawarichJournalTrail( + /** Dates the journal covers, from its trips. */ + dates: Set, + enabled: boolean, +): { tracks: JourneyTrack[]; fetchedAt: string | null; status: 'idle' | 'loading' | 'ready' | 'unavailable' | 'offline' } { + const addonEnabled = useAddonStore(s => s.isEnabled) + const active = enabled && addonEnabled('dawarich') && dates.size > 0 + + const [tracks, setTracks] = useState(EMPTY) + const [fetchedAt, setFetchedAt] = useState(null) + const [status, setStatus] = useState<'idle' | 'loading' | 'ready' | 'unavailable' | 'offline'>('idle') + + // The window is the journal's own span. A Set has no order, so it is sorted + // here rather than trusting insertion order. + const window = useMemo(() => { + if (dates.size === 0) return null + // ISO dates: lexicographic order is chronological, and staying locale-independent + // keeps the window identical on every runtime. + const sorted = [...dates].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)) + return { from: `${sorted[0]}T00:00:00Z`, to: `${sorted[sorted.length - 1]}T23:59:59Z` } + }, [dates]) + + useEffect(() => { + if (!active || !window) { + setTracks(EMPTY) + setStatus('idle') + return + } + if (isEffectivelyOffline()) { + setTracks(EMPTY) + setStatus('offline') + return + } + + const controller = new AbortController() + let cancelled = false + setStatus('loading') + + dawarichRepo + .windowTrack(window.from, window.to, controller.signal) + .then(track => { + if (cancelled) return + const segments = trailSegments(track) + setTracks( + segments.map((segment, index): JourneyTrack => ({ + place_id: -(index + 1), + trip_id: 0, + name: segment.date, + color: segment.color, + points: segment.points, + })), + ) + setFetchedAt(track.fetchedAt) + setStatus('ready') + }) + .catch(() => { + if (cancelled) return + setTracks(EMPTY) + setStatus(isEffectivelyOffline() ? 'offline' : 'unavailable') + }) + + return () => { + cancelled = true + controller.abort() + } + }, [active, window]) + + return { tracks: active ? tracks : EMPTY, fetchedAt, status } +} + +/** Stable empty identity: a fresh literal would re-render the map on every pass. */ +const EMPTY: JourneyTrack[] = [] diff --git a/client/src/hooks/useDawarichSuggestions.test.ts b/client/src/hooks/useDawarichSuggestions.test.ts new file mode 100644 index 000000000..84b9384e4 --- /dev/null +++ b/client/src/hooks/useDawarichSuggestions.test.ts @@ -0,0 +1,513 @@ +/** + * FE-DAWARICH-SUGG-001 to FE-DAWARICH-SUGG-019: the review list both Dawarich + * shells read from. + * + * This hook is the only place the integration's review logic lives: the planner + * panel and the journal sheet both call it, so a regression here is a + * regression in both at once, and the two shells disagreeing about what is + * still pending is precisely what it was written to prevent. + * + * Four things are worth pinning, and the cases below are grouped around them. + * + * - **The status ladder.** `idle` / `loading` / `ready` / `disconnected` / + * `offline` / `unavailable` are not decoration: the panel renders nothing at + * all on `idle`, so an instance without the addon has to produce `idle` and + * no request, and a read that failed must not collapse into the same value + * as "connected and quiet". + * - **The stale-response guard.** Every read carries a `cancelled` flag. Lose + * it and the answer to the trip the reader just left overwrites the list for + * the trip they are looking at now, which reads as rows appearing from + * nowhere. + * - **In-place updates.** Accepting or dismissing replaces the one row and + * never refetches, deliberately: a refetch reorders under the cursor, and + * somebody working down a backlog loses their place on every press. + * - **The message chain.** The server ships a reason code beside its English + * prose so a non-English install never gets English. The code wins when this + * build has a string for it, the prose is the fallback for a code it does + * not know, and a generic sentence is the last resort. Each step is a branch, + * and each of them is a sentence a real person reads. + * + * The repo and the API are mocked, the way the neighbouring + * `DawarichAtlasDialog` test does it. Connectivity is not: `setForcedOffline` + * drives the real `networkMode`, which is the single source of truth the hook + * gates its writes on, and translation is the real provider, because the two + * helpers at the bottom of the source depend on `t()` returning the key + * untouched for a string this build does not have. + */ +import { createElement, type ReactNode } from 'react' +import { act, renderHook, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { DawarichAccept, DawarichAcceptResult, DawarichSuggestion, DawarichSuggestionList } from '@trek/shared' +import { TranslationProvider } from '../i18n/TranslationContext' +import { useAddonStore } from '../store/addonStore' +import { setForcedOffline } from '../sync/networkMode' +import { DawarichOfflineError } from '../repo/dawarichRepo' +import { useDawarichSuggestions } from './useDawarichSuggestions' + +const toast = { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } +vi.mock('../components/shared/Toast', () => ({ useToast: () => toast, default: () => toast })) + +const repo = { suggestions: vi.fn() } +vi.mock('../repo/dawarichRepo', () => { + // The hook branches on `err instanceof DawarichOfflineError`, so the class the + // test throws has to be the very one the hook imported. Defined inside the + // factory and read back through the mocked module, it is. + class DawarichOfflineError extends Error {} + return { + dawarichRepo: { suggestions: (...args: unknown[]) => repo.suggestions(...args) }, + DawarichOfflineError, + } +}) + +const api = { accept: vi.fn(), setState: vi.fn() } +vi.mock('../api/dawarich', () => ({ + dawarichApi: { + accept: (...args: unknown[]) => api.accept(...args), + setState: (...args: unknown[]) => api.setState(...args), + }, +})) + +const ADDON = { id: 'dawarich', name: 'Dawarich', type: 'integration', icon: 'route', enabled: true } + +/** + * One recorded stay. Only a handful of these fields matter to this hook (id, + * state and target), but the contract demands all of them, and a partial cast + * would hide the day a required field is added. + */ +function stay(over: Partial & { id: number }): DawarichSuggestion { + return { + id: over.id, + sourceVisitId: String(over.id), + tripId: null, + tripTitle: null, + name: `Stay ${over.id}`, + lat: 50.94, + lng: 6.96, + startedAt: '2026-09-10T10:15:00+02:00', + endedAt: '2026-09-10T12:40:00+02:00', + durationMinutes: 145, + localDate: '2026-09-10', + sourceStatus: 'suggested', + confidence: null, + confidenceBand: null, + state: 'new', + target: null, + acceptedPlaceId: null, + acceptedJournalEntryId: null, + acceptedBucketListItemId: null, + sourceChanged: false, + sourceMissing: false, + matchedBucketListItemId: null, + matchedBucketListName: null, + countryCode: null, + firstSeenAt: '2026-09-10T06:00:00Z', + lastSeenAt: '2026-09-10T06:00:00Z', + ...over, + } +} + +/** Two rows, because "replaced in place" is only provable against a neighbour. */ +function list(over: Partial = {}): DawarichSuggestionList { + return { + suggestions: [stay({ id: 1 }), stay({ id: 2 })], + connected: true, + lastSyncAt: '2026-09-12T06:00:00Z', + lastSyncState: 'ok', + lastSyncError: null, + ...over, + } +} + +function acceptResult(suggestion: DawarichSuggestion): DawarichAcceptResult { + return { + suggestion, + createdPlaceId: suggestion.acceptedPlaceId, + createdJournalEntryId: suggestion.acceptedJournalEntryId, + bucketListItemId: null, + } +} + +/** A promise this test controls the settling of, to pin down state mid-flight. */ +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +type Filter = Parameters[0] + +const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children) + +function mount(filter?: Filter) { + return renderHook((props: Filter) => useDawarichSuggestions(props), { wrapper, initialProps: filter }) +} + +const PLACE_BODY: DawarichAccept = { target: 'place', tripId: 7, dayId: 3 } + +// The shipped English for the keys the hook reaches for. Written out rather than +// read back from the bundle so a reader of this file sees the sentence that ends +// up on screen, and so a reworded string is a decision somebody makes here too. +const EN = { + offline: 'This needs a connection — TREK is offline right now.', + unauthorized: 'Dawarich rejected the API key.', + unknown: 'Something went wrong talking to Dawarich.', + acceptError: 'Could not add this', + updateError: 'Could not update this suggestion', + acceptedPlace: 'Added to the trip', + acceptedJournal: 'Added to the journal', +} + +beforeEach(() => { + useAddonStore.setState({ addons: [ADDON], loaded: true }) + setForcedOffline(false) + repo.suggestions.mockReset().mockResolvedValue(list()) + api.accept.mockReset() + api.setState.mockReset() + toast.success.mockReset() + toast.error.mockReset() +}) + +// The force-offline flag is module state, not component state: left on, it would +// leak into the next file's writes. +afterEach(() => setForcedOffline(false)) + +describe('useDawarichSuggestions', () => { + it('FE-DAWARICH-SUGG-001: an instance without the addon asks nothing and reports idle', () => { + useAddonStore.setState({ addons: [{ ...ADDON, enabled: false }] }) + + const { result } = mount() + + // `idle` is what the panel keys "render nothing at all" off, so the empty + // defaults underneath it matter as much as the status itself. + expect(result.current.status).toBe('idle') + expect(repo.suggestions).not.toHaveBeenCalled() + expect(result.current.suggestions).toEqual([]) + expect(result.current.lastSyncState).toBe('never') + expect(result.current.lastSyncError).toBeNull() + }) + + it('FE-DAWARICH-SUGG-002: the enabled addon reads with the filter it was handed and reports the sync', async () => { + const read = deferred() + repo.suggestions.mockReturnValueOnce(read.promise) + + const { result } = mount({ tripId: 7, state: 'new' }) + + expect(result.current.status).toBe('loading') + await act(async () => read.resolve(list())) + + await waitFor(() => expect(result.current.status).toBe('ready')) + expect(repo.suggestions).toHaveBeenCalledWith({ tripId: 7, state: 'new' }) + expect(result.current.suggestions.map(s => s.id)).toEqual([1, 2]) + expect(result.current.lastSyncAt).toBe('2026-09-12T06:00:00Z') + expect(result.current.lastSyncState).toBe('ok') + expect(result.current.lastSyncError).toBeNull() + }) + + it('FE-DAWARICH-SUGG-003: a list from an instance nobody is connected to still shows its rows', async () => { + // The suggestions survive a disconnect: they are TREK's own rows by then, + // and hiding them would lose a backlog somebody still has to work through. + repo.suggestions.mockResolvedValue(list({ connected: false, lastSyncState: 'never', lastSyncAt: null })) + + const { result } = mount() + + await waitFor(() => expect(result.current.status).toBe('disconnected')) + expect(result.current.suggestions).toHaveLength(2) + expect(result.current.lastSyncAt).toBeNull() + }) + + it('FE-DAWARICH-SUGG-004: a read refused for being offline says offline, and says it quietly', async () => { + repo.suggestions.mockRejectedValue(new DawarichOfflineError()) + + const { result } = mount() + + await waitFor(() => expect(result.current.status).toBe('offline')) + // No toast: a read that could not run is a state the panel renders, not an + // incident to interrupt somebody with. + expect(toast.error).not.toHaveBeenCalled() + }) + + it('FE-DAWARICH-SUGG-005: any other failure is unavailable, kept apart from offline', async () => { + repo.suggestions.mockRejectedValue({ response: { data: { code: 'unreachable' } } }) + + const { result } = mount() + + await waitFor(() => expect(result.current.status).toBe('unavailable')) + expect(result.current.suggestions).toEqual([]) + }) + + it('FE-DAWARICH-SUGG-006: reload re-reads with the same (absent) filter and takes the new answer', async () => { + repo.suggestions + .mockResolvedValueOnce(list()) + .mockResolvedValueOnce(list({ suggestions: [stay({ id: 3 })] })) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + act(() => result.current.reload()) + + await waitFor(() => expect(result.current.suggestions.map(s => s.id)).toEqual([3])) + expect(repo.suggestions).toHaveBeenCalledTimes(2) + expect(repo.suggestions).toHaveBeenLastCalledWith({ tripId: undefined, state: undefined }) + }) + + it('FE-DAWARICH-SUGG-007: a changed filter re-reads, an equal one does not', async () => { + const { result, rerender } = mount({ tripId: 7 }) + await waitFor(() => expect(result.current.status).toBe('ready')) + + rerender({ tripId: 9, state: 'dismissed' }) + await waitFor(() => expect(repo.suggestions).toHaveBeenLastCalledWith({ tripId: 9, state: 'dismissed' })) + // The request is fired synchronously by the effect, so the assertion above + // can pass while the answer is still in the air. Let it land first: what the + // next render must not do is start a third read, and that is only a clean + // statement once the second one is finished. + await waitFor(() => expect(result.current.status).toBe('ready')) + + // A fresh object carrying the same two values. The effect depends on the + // unpacked primitives precisely so this does not fire a second request. + // Every caller builds its filter object inline on every render. + rerender({ tripId: 9, state: 'dismissed' }) + expect(repo.suggestions).toHaveBeenCalledTimes(2) + }) + + it('FE-DAWARICH-SUGG-008: the answer to the previous filter never overwrites the current list', async () => { + const first = deferred() + const second = deferred() + repo.suggestions.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise) + + const { result, rerender } = mount({ tripId: 7 }) + rerender({ tripId: 8 }) + + await act(async () => second.resolve(list({ suggestions: [stay({ id: 8 })] }))) + await waitFor(() => expect(result.current.suggestions.map(s => s.id)).toEqual([8])) + + // The slow trip-7 read lands last. Without the cancelled flag the reader + // would now be looking at another trip's stays under trip 8's heading. + await act(async () => first.resolve(list({ suggestions: [stay({ id: 7 })] }))) + expect(result.current.suggestions.map(s => s.id)).toEqual([8]) + expect(result.current.status).toBe('ready') + }) + + it('FE-DAWARICH-SUGG-009: a read that fails after the panel closed changes nothing', async () => { + const read = deferred() + repo.suggestions.mockReturnValueOnce(read.promise) + + const { result, unmount } = mount() + expect(result.current.status).toBe('loading') + unmount() + + await act(async () => read.reject(new DawarichOfflineError())) + + // The last value the hook ever rendered, untouched by the late rejection. + expect(result.current.status).toBe('loading') + }) + + it('FE-DAWARICH-SUGG-010: accepting while offline is refused before the request, not after a timeout', async () => { + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + // The reader flips the offline switch while the list is on screen. Reads go + // through the repo, which knows; this write does not, so the hook says so. + setForcedOffline(true) + let ok: boolean | undefined + await act(async () => { + ok = await result.current.accept(1, PLACE_BODY) + }) + + expect(ok).toBe(false) + expect(api.accept).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith(EN.offline) + expect(result.current.busyId).toBeNull() + }) + + it('FE-DAWARICH-SUGG-011: accepting marks the row busy, replaces only that row, and never refetches', async () => { + const settled = deferred() + api.accept.mockReturnValueOnce(settled.promise) + const accepted = stay({ id: 1, state: 'accepted', target: 'place', acceptedPlaceId: 41 }) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + let pending!: Promise + act(() => { + pending = result.current.accept(1, PLACE_BODY) + }) + // The row is disabled while its write is in the air; only that row. + expect(result.current.busyId).toBe(1) + + await act(async () => settled.resolve(acceptResult(accepted))) + + await expect(pending).resolves.toBe(true) + expect(api.accept).toHaveBeenCalledWith(1, PLACE_BODY) + expect(result.current.suggestions[0]).toEqual(accepted) + expect(result.current.suggestions[1]).toEqual(stay({ id: 2 })) + expect(result.current.busyId).toBeNull() + expect(toast.success).toHaveBeenCalledWith(EN.acceptedPlace) + // One read, for the mount. A refetch here would reorder the list under the + // cursor of somebody working down a backlog. + expect(repo.suggestions).toHaveBeenCalledTimes(1) + + // The target picks the sentence: a stay written into a journal must not be + // reported as added to the trip. + const journalled = stay({ id: 2, state: 'accepted', target: 'journal', acceptedJournalEntryId: 88 }) + api.accept.mockResolvedValueOnce(acceptResult(journalled)) + await act(async () => { + await result.current.accept(2, { target: 'journal', journalId: 5 }) + }) + expect(toast.success).toHaveBeenLastCalledWith(EN.acceptedJournal) + }) + + it('FE-DAWARICH-SUGG-012: a failure carrying a known code is said in the language of the install', async () => { + // The server sends both. The code wins, so a German install never gets the + // English sentence sitting right beside it in the envelope. + api.accept.mockRejectedValue({ response: { data: { code: 'unauthorized', error: 'Dawarich rejected the API key' } } }) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + let ok: boolean | undefined + await act(async () => { + ok = await result.current.accept(1, PLACE_BODY) + }) + + expect(ok).toBe(false) + expect(toast.error).toHaveBeenCalledWith(EN.unauthorized) + // Nothing was written, so nothing in the list may look as if it had been. + expect(result.current.suggestions[0].state).toBe('new') + expect(result.current.busyId).toBeNull() + }) + + it('FE-DAWARICH-SUGG-013: an unknown code falls back to the server prose, and no envelope to the generic sentence', async () => { + // A code from a server newer than this build: the prose beside it is the + // only thing left that describes what actually happened. + api.accept.mockRejectedValueOnce({ response: { data: { code: 'meteor_strike', error: 'Dawarich fell over' } } }) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + await act(async () => { + await result.current.accept(1, PLACE_BODY) + }) + expect(toast.error).toHaveBeenCalledWith('Dawarich fell over') + + // A transport-level failure carries no envelope at all. + api.accept.mockRejectedValueOnce(new Error('Network Error')) + await act(async () => { + await result.current.accept(1, PLACE_BODY) + }) + expect(toast.error).toHaveBeenLastCalledWith(EN.acceptError) + }) + + it('FE-DAWARICH-SUGG-014: dismiss and restore move the one row between the two states', async () => { + api.setState.mockImplementation(async (id: number, next: 'new' | 'dismissed') => stay({ id, state: next })) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + await act(async () => { + await result.current.dismiss(2) + }) + expect(api.setState).toHaveBeenCalledWith(2, 'dismissed') + expect(result.current.suggestions.map(s => s.state)).toEqual(['new', 'dismissed']) + + // Restoring is the same call with the other value. The panel offers it on + // the handled rows, and it has to land on the same row it left. + await act(async () => { + await result.current.restore(2) + }) + expect(api.setState).toHaveBeenLastCalledWith(2, 'new') + expect(result.current.suggestions.map(s => s.state)).toEqual(['new', 'new']) + expect(result.current.busyId).toBeNull() + }) + + it('FE-DAWARICH-SUGG-015: dismissing while offline is refused before the request too', async () => { + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + setForcedOffline(true) + await act(async () => { + await result.current.dismiss(1) + }) + + expect(api.setState).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith(EN.offline) + expect(result.current.busyId).toBeNull() + }) + + it('FE-DAWARICH-SUGG-016: a state change that fails leaves the row alone and clears the busy flag', async () => { + // An envelope with neither a code nor prose: a 500 through a proxy that + // rewrote the body. The generic sentence is all that is left to say. + api.setState.mockRejectedValue({ response: { data: {} } }) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + await act(async () => { + await result.current.dismiss(1) + }) + + expect(toast.error).toHaveBeenCalledWith(EN.updateError) + expect(result.current.suggestions[0].state).toBe('new') + // Cleared in `finally`: a row left busy after a failure can never be pressed + // again, and the only way out would be a reload. + expect(result.current.busyId).toBeNull() + }) + + it('FE-DAWARICH-SUGG-017: the last sync reason is translated, with a sentence for a code this build does not know', async () => { + repo.suggestions.mockResolvedValue(list({ lastSyncState: 'failed', lastSyncError: 'unauthorized' })) + const known = mount() + await waitFor(() => expect(known.result.current.lastSyncError).toBe(EN.unauthorized)) + expect(known.result.current.lastSyncState).toBe('failed') + known.unmount() + + // A newer server reporting a reason this build has no string for. Showing + // the bare code would put `dawarich.error.meteor_strike` on screen. + repo.suggestions.mockResolvedValue(list({ lastSyncState: 'partial', lastSyncError: 'meteor_strike' })) + const unfamiliar = mount() + await waitFor(() => expect(unfamiliar.result.current.lastSyncError).toBe(EN.unknown)) + }) + + it('FE-DAWARICH-SUGG-018: a refused state change is said in the language of the install too', async () => { + // Dismissing has its own error chain, and the code path through it is the + // one somebody hits when the API key was revoked while the list sat open. + // Only the generic fallback is exercised above, so without this the panel + // could start answering half its failures in English and nothing would say so. + api.setState.mockRejectedValue({ + response: { data: { code: 'unauthorized', error: 'Dawarich rejected the API key' } }, + }) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + await act(async () => { + await result.current.restore(1) + }) + + expect(toast.error).toHaveBeenCalledWith(EN.unauthorized) + expect(result.current.suggestions[0].state).toBe('new') + expect(result.current.busyId).toBeNull() + }) + + it('FE-DAWARICH-SUGG-019: a rejection carrying no reason at all still ends in a sentence and a false', async () => { + // `Promise.reject()` with nothing in it is what an aborted request hands the + // catch block. Reading `.response` off that unguarded throws inside the error + // handler itself, and the dialog waiting on `accept` would then get a + // rejected promise where it expects a plain `false` and stay open forever. + api.accept.mockRejectedValueOnce(undefined) + + const { result } = mount() + await waitFor(() => expect(result.current.status).toBe('ready')) + + let ok: boolean | undefined + await act(async () => { + ok = await result.current.accept(1, PLACE_BODY) + }) + + expect(ok).toBe(false) + expect(toast.error).toHaveBeenCalledWith(EN.acceptError) + expect(result.current.busyId).toBeNull() + }) +}) diff --git a/client/src/hooks/useDawarichSuggestions.ts b/client/src/hooks/useDawarichSuggestions.ts new file mode 100644 index 000000000..7c6a5fac8 --- /dev/null +++ b/client/src/hooks/useDawarichSuggestions.ts @@ -0,0 +1,175 @@ +import { useCallback, useEffect, useState } from 'react' +import type { DawarichAccept, DawarichSuggestion, DawarichSyncState } from '@trek/shared' +import { dawarichApi } from '../api/dawarich' +import { dawarichRepo, DawarichOfflineError } from '../repo/dawarichRepo' +import { isEffectivelyOffline } from '../sync/networkMode' +import { useAddonStore } from '../store/addonStore' +import { useTranslation } from '../i18n' +import { useToast } from '../components/shared/Toast' + +export type DawarichSuggestionsStatus = + | 'idle' + | 'loading' + | 'ready' + | 'disconnected' + | 'offline' + | 'unavailable' + +export interface DawarichSuggestionsState { + suggestions: DawarichSuggestion[] + status: DawarichSuggestionsStatus + lastSyncAt: string | null + lastSyncState: DawarichSyncState + /** Already translated; null when the last sync was clean. */ + lastSyncError: string | null + busyId: number | null + reload: () => void + accept: (id: number, body: DawarichAccept) => Promise + dismiss: (id: number) => Promise + restore: (id: number) => Promise +} + +/** + * The stays waiting for review, and what a user can do with them. + * + * Shared by the journal and the trip planner so the two never disagree about + * what is still pending — and so the review logic exists once rather than in + * each shell. + * + * Every mutation updates the row in place rather than refetching the list. A + * refetch would reorder under the cursor, and someone working down a backlog + * would lose their place every time they pressed something. + */ +export function useDawarichSuggestions(filter?: { + tripId?: number + state?: 'new' | 'accepted' | 'dismissed' +}): DawarichSuggestionsState { + const { t } = useTranslation() + const toast = useToast() + const addonEnabled = useAddonStore(s => s.isEnabled) + const active = addonEnabled('dawarich') + + const [suggestions, setSuggestions] = useState([]) + const [status, setStatus] = useState('idle') + const [lastSyncAt, setLastSyncAt] = useState(null) + const [lastSyncState, setLastSyncState] = useState('never') + const [lastSyncErrorCode, setLastSyncErrorCode] = useState(null) + const [busyId, setBusyId] = useState(null) + const [reloadToken, setReloadToken] = useState(0) + + const tripId = filter?.tripId + const stateFilter = filter?.state + + const reload = useCallback(() => setReloadToken(token => token + 1), []) + + useEffect(() => { + if (!active) { + setStatus('idle') + return + } + let cancelled = false + setStatus('loading') + dawarichRepo + .suggestions({ tripId, state: stateFilter }) + .then(result => { + if (cancelled) return + setSuggestions(result.suggestions) + setLastSyncAt(result.lastSyncAt) + setLastSyncState(result.lastSyncState) + setLastSyncErrorCode(result.lastSyncError) + setStatus(result.connected ? 'ready' : 'disconnected') + }) + .catch((err: unknown) => { + if (cancelled) return + setStatus(err instanceof DawarichOfflineError ? 'offline' : 'unavailable') + }) + return () => { + cancelled = true + } + }, [active, tripId, stateFilter, reloadToken]) + + const replace = useCallback((updated: DawarichSuggestion) => { + setSuggestions(prev => prev.map(item => (item.id === updated.id ? updated : item))) + }, []) + + const accept = useCallback( + async (id: number, body: DawarichAccept): Promise => { + // Accepting writes into a trip or a journal, which needs the server. The + // reads go through the repo, which is offline-aware; this one says so + // rather than hanging until the request times out. + if (isEffectivelyOffline()) { + toast.error(t('dawarich.error.offline')) + return false + } + setBusyId(id) + try { + const result = await dawarichApi.accept(id, body) + replace(result.suggestion) + toast.success(t(`dawarich.toast.accepted.${body.target}`)) + return true + } catch (err) { + toast.error(serverError(err, t) ?? t('dawarich.toast.acceptError')) + return false + } finally { + setBusyId(null) + } + }, + [replace, toast, t], + ) + + const setRowState = useCallback( + async (id: number, next: 'new' | 'dismissed') => { + if (isEffectivelyOffline()) { + toast.error(t('dawarich.error.offline')) + return + } + setBusyId(id) + try { + replace(await dawarichApi.setState(id, next)) + } catch (err) { + toast.error(serverError(err, t) ?? t('dawarich.toast.updateError')) + } finally { + setBusyId(null) + } + }, + [replace, toast, t], + ) + + return { + suggestions, + status, + lastSyncAt, + lastSyncState, + lastSyncError: translateCode(lastSyncErrorCode, t), + busyId, + reload, + accept, + dismiss: (id: number) => setRowState(id, 'dismissed'), + restore: (id: number) => setRowState(id, 'new'), + } +} + +/** + * The server's `{ code, error }` envelope, as a sentence the reader can read. + * + * The controller ships a code beside every message precisely so nothing reaches + * a non-English install in English; the prose is the fallback for a code this + * build has no string for. + */ +function serverError(err: unknown, t: (key: string) => string): string | null { + const data = (err as { response?: { data?: { error?: unknown; code?: unknown } } })?.response?.data + if (typeof data?.code === 'string') { + const key = `dawarich.error.${data.code}` + const text = t(key) + if (text !== key) return text + } + return typeof data?.error === 'string' ? data.error : null +} + +/** A reason code turned into the reader's language, with a fallback for new codes. */ +function translateCode(code: string | null, t: (key: string) => string): string | null { + if (!code) return null + const key = `dawarich.error.${code}` + const text = t(key) + return text === key ? t('dawarich.error.unknown') : text +} diff --git a/client/src/hooks/useExchangeRates.test.ts b/client/src/hooks/useExchangeRates.test.ts index de6d3904c..45eaac209 100644 --- a/client/src/hooks/useExchangeRates.test.ts +++ b/client/src/hooks/useExchangeRates.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest' import { http, HttpResponse } from 'msw' import { server } from '../../tests/helpers/msw/server' -import { fetchExchangeRates, clearExchangeRateCache } from './useExchangeRates' +import { convertBooked, fetchExchangeRates, clearExchangeRateCache } from './useExchangeRates' const FX_URL = 'https://api.frankfurter.dev/v2/rates' @@ -45,3 +45,51 @@ describe('fetchExchangeRates (#1561)', () => { expect(await fetchExchangeRates('NOK')).toEqual({ NOK: 1, USD: 0.1 }) }) }) + +// A cost entered in a foreign currency is money that was actually paid, at a rate that +// was true that day. Reading it back at today's rate quietly rewrites history, which is +// what a tester reported after settling up: the figure moved under him. +describe('convertBooked', () => { + // Display currency is EUR, so rates are units per 1 EUR. The trip is booked in EUR too + // unless a test says otherwise. + const live = (amount: number, from: string | null | undefined): number => { + const rates: Record = { EUR: 1, USD: 2, SEK: 10 } + const r = rates[(from || 'EUR').toUpperCase()] + return r && r > 0 ? amount / r : amount + } + + it('leaves an amount already in the trip currency to the live step alone', () => { + // Nothing was ever frozen here, so this is just trip currency to display currency. + expect(convertBooked(100, null, 1, 'EUR', live)).toBe(100) + expect(convertBooked(100, 'EUR', 1, 'EUR', live)).toBe(100) + }) + + it('reads a foreign amount at the rate it was booked at, not at today s', () => { + // 120 USD booked when a euro bought 1.2 dollars is 100 EUR, and stays 100 EUR even + // though the live table above says a euro now buys 2. + expect(convertBooked(120, 'USD', 1.2, 'EUR', live)).toBe(100) + }) + + it('falls back to live rates for a row that never froze one', () => { + // Rate 1 is the column default, not a booked rate: rows predating the freeze carry it. + expect(convertBooked(120, 'USD', 1, 'EUR', live)).toBe(60) + expect(convertBooked(120, 'USD', undefined, 'EUR', live)).toBe(60) + expect(convertBooked(120, 'USD', null, 'EUR', live)).toBe(60) + // A rate that cannot divide is no rate at all. + expect(convertBooked(120, 'USD', 0, 'EUR', live)).toBe(60) + expect(convertBooked(120, 'USD', -2, 'EUR', live)).toBe(60) + }) + + it('goes through the trip currency when the reader displays a third one', () => { + // 200 SEK booked at 10 SEK per euro is 20 EUR of trip money, which the live step then + // takes to the display currency. The frozen rate is against the trip, never the display. + expect(convertBooked(200, 'SEK', 10, 'EUR', live)).toBe(20) + // Trip in USD, display in EUR: 200 SEK at 5 SEK per dollar is 40 USD, then 20 EUR. + expect(convertBooked(200, 'SEK', 5, 'USD', live)).toBe(20) + }) + + it('treats currency case as noise', () => { + expect(convertBooked(120, 'usd', 1.2, 'eur', live)).toBe(100) + expect(convertBooked(100, 'eur', 1, 'EUR', live)).toBe(100) + }) +}) diff --git a/client/src/hooks/useExchangeRates.ts b/client/src/hooks/useExchangeRates.ts index 46a40ed93..2652312fa 100644 --- a/client/src/hooks/useExchangeRates.ts +++ b/client/src/hooks/useExchangeRates.ts @@ -52,6 +52,33 @@ export async function fetchExchangeRates(base: string): Promise number, +): number { + const trip = (tripCurrency || 'EUR').toUpperCase() + // A NULL currency means the trip's own, and then there was never anything to freeze. + const cur = (rowCurrency || trip).toUpperCase() + if (cur === trip) return convertLive(amount, trip) + // A rate of exactly 1 is the column default, not a booked rate: rows written before the + // freeze existed carry it, and those still convert live, as they always did. + if (frozenRate != null && frozenRate > 0 && frozenRate !== 1) return convertLive(amount / frozenRate, trip) + return convertLive(amount, cur) +} + /** Test-only: the module-level cache outlives a vitest file's individual tests. */ export function clearExchangeRateCache(): void { mem.clear() diff --git a/client/src/hooks/useGeolocation.test.ts b/client/src/hooks/useGeolocation.test.ts index ba536744c..9ad8d2c76 100644 --- a/client/src/hooks/useGeolocation.test.ts +++ b/client/src/hooks/useGeolocation.test.ts @@ -1,4 +1,4 @@ -// FE-HOOK-GEO-001 to FE-HOOK-GEO-025 +// FE-HOOK-GEO-001 to FE-HOOK-GEO-031 import { StrictMode } from 'react'; import { act, renderHook } from '@testing-library/react'; import { GeoOnceError, getCurrentPositionOnce, useGeolocation } from './useGeolocation'; @@ -54,6 +54,7 @@ describe('useGeolocation', () => { expect(result.current.mode).toBe('off'); expect(result.current.position).toBeNull(); expect(result.current.error).toBeNull(); + expect(result.current.errorCode).toBeNull(); expect(watchPosition).not.toHaveBeenCalled(); }); @@ -305,6 +306,78 @@ describe('useGeolocation', () => { expect(result.current.mode).toBe('show'); }); + it.each([ + [1, 'permission-denied'], + [2, 'unavailable'], + [3, 'timeout'], + ])('FE-HOOK-GEO-026: maps watch error code %i to errorCode "%s"', async (code, expected) => { + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + + act(() => errorCb!(geoError(code))); + + expect(result.current.errorCode).toBe(expected); + expect(result.current.error).toBe('Location unavailable'); + }); + + it('FE-HOOK-GEO-027: a denial still stops the watch and keeps the code for the UI', async () => { + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + + act(() => errorCb!(geoError(1, 'User denied Geolocation'))); + + expect(result.current.mode).toBe('off'); + expect(clearWatch).toHaveBeenCalledWith(7); + expect(result.current.errorCode).toBe('permission-denied'); + }); + + it('FE-HOOK-GEO-028: a missing geolocation API reports the unsupported code', async () => { + delete (navigator as { geolocation?: Geolocation }).geolocation; + const { result } = renderHook(() => useGeolocation()); + + await act(() => result.current.cycleMode()); + + expect(result.current.errorCode).toBe('unsupported'); + }); + + it('FE-HOOK-GEO-029: a successful fix clears both error and errorCode', async () => { + const { result } = renderHook(() => useGeolocation()); + await act(() => result.current.cycleMode()); + + act(() => errorCb!(geoError(3, 'Timeout expired'))); + expect(result.current.error).toBe('Timeout expired'); + expect(result.current.errorCode).toBe('timeout'); + + act(() => successCb!(fix())); + expect(result.current.error).toBeNull(); + expect(result.current.errorCode).toBeNull(); + expect(result.current.position).not.toBeNull(); + }); + + it('FE-HOOK-GEO-030: an insecure origin is named as such, not as a blocked permission', async () => { + // Plain-HTTP self-hosting: the browser would answer PERMISSION_DENIED and the + // user would be sent into device settings that are already correct. + vi.stubGlobal('isSecureContext', false); + const { result } = renderHook(() => useGeolocation()); + + await act(() => result.current.cycleMode()); + + expect(watchPosition).not.toHaveBeenCalled(); + expect(result.current.mode).toBe('off'); + expect(result.current.errorCode).toBe('insecure-context'); + }); + + it('FE-HOOK-GEO-031: a secure origin is not mistaken for an insecure one', async () => { + vi.stubGlobal('isSecureContext', true); + const { result } = renderHook(() => useGeolocation()); + + await act(() => result.current.cycleMode()); + + expect(result.current.mode).toBe('show'); + expect(result.current.errorCode).toBeNull(); + expect(watchPosition).toHaveBeenCalledTimes(1); + }); + it('FE-HOOK-GEO-019: stops the watch when the component unmounts', async () => { const { result, unmount } = renderHook(() => useGeolocation()); await act(() => result.current.cycleMode()); diff --git a/client/src/hooks/useGeolocation.ts b/client/src/hooks/useGeolocation.ts index 54ee184ea..ce4bdd03b 100644 --- a/client/src/hooks/useGeolocation.ts +++ b/client/src/hooks/useGeolocation.ts @@ -17,10 +17,16 @@ export interface GeoPosition { export type TrackingMode = 'off' | 'show' | 'follow' +// Typed counterpart to the raw error string. The string is whatever the +// browser produced (raw WebKit English on iOS), so the UI keys off this +// code to show a localized, actionable message instead. +export type GeoWatchErrorCode = 'permission-denied' | 'unavailable' | 'timeout' | 'unsupported' | 'insecure-context' + export interface UseGeolocationReturn { position: GeoPosition | null mode: TrackingMode error: string | null + errorCode: GeoWatchErrorCode | null /** Toggle through off → show → follow → off. Also triggers iOS orientation permission on first call. */ cycleMode: () => Promise /** Force-set mode. Accepts a function for derived updates like `prev => prev === 'follow' ? 'show' : prev`. */ @@ -101,6 +107,7 @@ export function useGeolocation(): UseGeolocationReturn { const modeRef = useRef(mode) modeRef.current = mode const [error, setError] = useState(null) + const [errorCode, setErrorCode] = useState(null) const watchIdRef = useRef(null) // True between the start of startWatch and the watchPosition call it awaits. const startingRef = useRef(false) @@ -129,6 +136,15 @@ export function useGeolocation(): UseGeolocationReturn { const startWatch = useCallback(async () => { if (!('geolocation' in navigator)) { setError('Geolocation is not supported in this browser') + setErrorCode('unsupported') + return false + } + // Same block getCurrentPositionOnce guards against: on a plain-HTTP origin + // the browser answers PERMISSION_DENIED, which would otherwise read as a + // revoked device permission and send the user into their OS settings. + if (window.isSecureContext === false) { + setError('Location requires a secure (HTTPS) connection') + setErrorCode('insecure-context') return false } // Already watching, or still waiting on the iOS prompt below: a second @@ -138,6 +154,7 @@ export function useGeolocation(): UseGeolocationReturn { startingRef.current = true const run = ++startRunRef.current setError(null) + setErrorCode(null) // iOS: ask for orientation permission up front; on Android and desktop // no prompt is needed and the method is undefined. @@ -184,6 +201,10 @@ export function useGeolocation(): UseGeolocationReturn { startingRef.current = false watchIdRef.current = navigator.geolocation.watchPosition( (pos) => { + // A fix arriving means any earlier failure (timeout, no signal) + // has recovered, so clear the error state alongside it. + setError(null) + setErrorCode(null) setPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude, @@ -197,6 +218,12 @@ export function useGeolocation(): UseGeolocationReturn { }, (err) => { setError(err.message || 'Location unavailable') + setErrorCode( + err.code === err.PERMISSION_DENIED ? 'permission-denied' + : err.code === err.POSITION_UNAVAILABLE ? 'unavailable' + : err.code === err.TIMEOUT ? 'timeout' + : null, + ) // Stay subscribed so a later fix can still recover (e.g. GPS // lock takes a while indoors). Only fully stop on permission denial. if (err.code === err.PERMISSION_DENIED) { @@ -243,5 +270,5 @@ export function useGeolocation(): UseGeolocationReturn { useEffect(() => stopWatch, [stopWatch]) - return { position, mode, error, cycleMode, setMode } + return { position, mode, error, errorCode, cycleMode, setMode } } diff --git a/client/src/hooks/useIsDark.ts b/client/src/hooks/useIsDark.ts new file mode 100644 index 000000000..0e3854aac --- /dev/null +++ b/client/src/hooks/useIsDark.ts @@ -0,0 +1,29 @@ +import { useState, useEffect } from 'react' + +/** + * Returns true while the dark palette is active, read off the `.dark` class on + * `` — the single source of truth that `applyAppearance()` writes. + * + * Deriving it from the settings store instead (`dark_mode === 'auto' && + * matchMedia(...)`) duplicates theme state into React and misses an OS-level + * switch under `auto`, because nothing re-renders when the media query flips. + * The observer sees every path, including the pre-paint replay in + * `public/theme-boot.js`. + * + * Only for the handful of places that need the flag in JS — an `` that + * differs per theme. Colours belong on tokens, not on this hook. + */ +export function useIsDark(): boolean { + const [dark, setDark] = useState( + () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'), + ) + + useEffect(() => { + if (typeof document === 'undefined') return + const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark'))) + mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }) + return () => mo.disconnect() + }, []) + + return dark +} diff --git a/client/src/hooks/useLocationBias.test.ts b/client/src/hooks/useLocationBias.test.ts new file mode 100644 index 000000000..de8229357 --- /dev/null +++ b/client/src/hooks/useLocationBias.test.ts @@ -0,0 +1,83 @@ +// FE-HOOK-LOCBIAS-001 to FE-HOOK-LOCBIAS-008 +import { renderHook } from '@testing-library/react'; +import { useTripStore } from '../store/tripStore'; +import { boxFromCoords, pointFromBox, radiusKm, useLocationBias } from './useLocationBias'; + +// Coordinates of real places, because the whole point of the hint is distance +// and a made-up grid would not show when a box stops being useful. +const KYOTO_A = { id: 1, lat: 35.0116, lng: 135.7681 }; // Kyoto Bahnhof +const KYOTO_B = { id: 2, lat: 34.9949, lng: 135.7850 }; // Kiyomizu-dera, ~2 km +const TOKIO = { id: 3, lat: 35.6762, lng: 139.6503 }; // ~370 km entfernt + +function seed(state: Record) { + useTripStore.setState({ places: [], assignments: {}, selectedDayId: null, ...state } as never); +} + +afterEach(() => seed({})); + +describe('boxFromCoords', () => { + it('ignoriert Eintraege ohne brauchbare Koordinaten', () => { + const box = boxFromCoords([KYOTO_A, { lat: null, lng: null }, { lat: 'x', lng: 'y' }]); + expect(box).toEqual({ low: { lat: KYOTO_A.lat, lng: KYOTO_A.lng }, high: { lat: KYOTO_A.lat, lng: KYOTO_A.lng } }); + }); + + it('gibt nichts zurueck, wenn keine Koordinate brauchbar ist', () => { + expect(boxFromCoords([{ lat: null, lng: null }])).toBeUndefined(); + expect(boxFromCoords([])).toBeUndefined(); + }); +}); + +describe('pointFromBox', () => { + it('legt den Punkt in die Mitte und deckt die Box mit dem Radius ab', () => { + const punkt = pointFromBox(boxFromCoords([KYOTO_A, KYOTO_B])); + expect(punkt!.lat).toBeCloseTo((KYOTO_A.lat + KYOTO_B.lat) / 2, 6); + expect(punkt!.lng).toBeCloseTo((KYOTO_A.lng + KYOTO_B.lng) / 2, 6); + // gut 2 km Diagonale, also rund 1 km Radius — angehoben auf die Untergrenze + expect(punkt!.radius).toBe(5000); + }); + + it('haelt eine Untergrenze ein, damit ein einzelner Ort die Nachbarschaft meint', () => { + // Ein Punkt ohne Ausdehnung wuerde sonst mit Radius 0 auf die Tuerschwelle zeigen. + expect(pointFromBox(boxFromCoords([KYOTO_A]))!.radius).toBe(5000); + }); +}); + +describe('useLocationBias', () => { + it('gibt ohne Orte keinen Hinweis', () => { + seed({}); + expect(renderHook(() => useLocationBias()).result.current).toEqual({}); + }); + + it('nimmt die Orte des geoeffneten Tages, nicht die der ganzen Reise', () => { + // Die Reise reicht von Kyoto bis Tokio; der offene Tag spielt in Kyoto. + seed({ + places: [KYOTO_A, KYOTO_B, TOKIO], + assignments: { '7': [{ place_id: 1 }, { place_id: 2 }] }, + selectedDayId: 7, + }); + const { box } = renderHook(() => useLocationBias()).result.current; + expect(box).toBeDefined(); + // Tokio darf nicht in der Box liegen, sonst zeigt der Hinweis ins Leere. + expect(box!.high.lng).toBeLessThan(TOKIO.lng); + expect(radiusKm(box!)).toBeLessThan(5); + }); + + it('faellt auf die Reise zurueck, solange sie in eine Region passt', () => { + seed({ places: [KYOTO_A, KYOTO_B] }); + const { box, point } = renderHook(() => useLocationBias()).result.current; + expect(box).toBeDefined(); + expect(point!.lat).toBeCloseTo((KYOTO_A.lat + KYOTO_B.lat) / 2, 6); + }); + + it('verwirft einen Hinweis, der ueber eine ganze Rundreise spannt', () => { + // Gemessen: eine Box ueber Kyoto UND Tokio kostet mehr Treffer auf Platz 1 + // als sie einbringt. Kein Hinweis ist dann die bessere Antwort. + seed({ places: [KYOTO_A, TOKIO] }); + expect(renderHook(() => useLocationBias()).result.current).toEqual({}); + }); + + it('verwirft die zu weite Reise-Box auch dann, wenn ein Tag gewaehlt aber leer ist', () => { + seed({ places: [KYOTO_A, TOKIO], assignments: { '7': [] }, selectedDayId: 7 }); + expect(renderHook(() => useLocationBias()).result.current).toEqual({}); + }); +}); diff --git a/client/src/hooks/useLocationBias.ts b/client/src/hooks/useLocationBias.ts new file mode 100644 index 000000000..292b9503c --- /dev/null +++ b/client/src/hooks/useLocationBias.ts @@ -0,0 +1,129 @@ +import { useMemo } from 'react' +import { useTripStore } from '../store/tripStore' + +/** + * Where the user is planning right now, as a hint for the place search. + * + * Without it the search cannot tell which of a thousand identically named + * places is meant: "Hase-dera" returns the temple in Nara rather than the one + * in Kamakura, and a common single word is refused by the index as too + * expensive to answer, which drops the search back to Nominatim alone. + * + * The hint has to be NARROW, and that is the part that is easy to get wrong. + * Measured over 126 places of a real trip through Japan, searched against a + * live instance: + * + * no hint at all 54.8% found, 38.1% at rank 1 + * box around the WHOLE trip 57.1% found, 17.5% at rank 1 + * box around the nearest few places 70.6% found, 34.9% at rank 1 + * + * A box spanning a whole round trip has its centre somewhere between the + * cities, points at nothing, and costs more rank-1 hits than it gains. It is + * worse than no hint. So the day being planned comes first, and a hint that + * ends up wider than a metropolitan area is dropped rather than sent. + */ + +export interface LocationBiasBox { + low: { lat: number; lng: number } + high: { lat: number; lng: number } +} + +export interface LocationBiasPoint { + lat: number + lng: number + radius?: number +} + +/** + * Widest hint still worth sending, as a radius. Roughly a metropolitan area: + * far enough to cover a city and its surroundings, narrow enough that its + * centre still means something. The old value here was 500km of diagonal, + * which is how the whole-trip box slipped through. + */ +const MAX_RADIUS_KM = 60 + +const KM_PER_DEGREE = 111 + +interface Coord { lat?: number | string | null; lng?: number | string | null } + +export function boxFromCoords(coords: Coord[]): LocationBiasBox | undefined { + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity + for (const p of coords) { + // Number(null) is 0, and 0/0 is a real coordinate in the Gulf of Guinea. + // A place without coordinates would otherwise stretch the box across half + // the planet and the hint would be dropped for being too wide. + if (p.lat == null || p.lng == null || p.lat === '' || p.lng === '') continue + const lat = Number(p.lat), lng = Number(p.lng) + if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue + if (lat < minLat) minLat = lat + if (lat > maxLat) maxLat = lat + if (lng < minLng) minLng = lng + if (lng > maxLng) maxLng = lng + } + if (!Number.isFinite(minLat)) return undefined + return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } } +} + +/** Half the diagonal of a box, in kilometres — how far the hint reaches. */ +export function radiusKm(box: LocationBiasBox): number { + const lat = (box.low.lat + box.high.lat) / 2 + const dLat = box.high.lat - box.low.lat + const dLng = box.high.lng - box.low.lng + return Math.sqrt( + (dLat * KM_PER_DEGREE) ** 2 + (dLng * KM_PER_DEGREE * Math.cos(lat * (Math.PI / 180))) ** 2, + ) / 2 +} + +/** + * The centre of a box, with the radius that covers it. + * + * Autocomplete takes a box, search takes a point — same hint, two shapes, + * because the two upstream APIs were built years apart. The radius has a floor + * so a day whose places sit on one street still biases toward the + * neighbourhood rather than the doorstep. + */ +export function pointFromBox(box: LocationBiasBox | undefined): LocationBiasPoint | undefined { + if (!box) return undefined + return { + lat: (box.low.lat + box.high.lat) / 2, + lng: (box.low.lng + box.high.lng) / 2, + // Google caps a Text Search bias circle at 50 km and rejects a wider one + // outright, so the box for a trip spread over more than that is clamped + // rather than sent and refused. A bias is a hint about where to look; the + // hint is no worse for being drawn at the largest radius the API accepts. + radius: Math.min(50_000, Math.round(Math.max(radiusKm(box), 5) * 1000)), + } +} + +/** + * The hint for the place search, in both shapes the two endpoints want. + * + * Preference order, narrowest first: + * 1. the places of the day currently open — this is the area being planned + * 2. the trip's places, but only if they still fit inside one region + * Anything wider is dropped: no hint beats a hint pointing at open country. + */ +export function useLocationBias(): { box?: LocationBiasBox; point?: LocationBiasPoint } { + const places = useTripStore((s) => s.places) + const assignments = useTripStore((s) => s.assignments) + const selectedDayId = useTripStore((s) => s.selectedDayId) + + return useMemo(() => { + const alle = places || [] + + // The day being planned. Places reach a day through assignments, so the + // ids come from there and the coordinates from the place pool. + let quelle: Coord[] = [] + if (selectedDayId != null) { + const desTages = new Set( + (assignments?.[String(selectedDayId)] || []).map((a) => a.place_id), + ) + if (desTages.size > 0) quelle = alle.filter((p) => desTages.has(Number(p.id))) + } + if (quelle.length === 0) quelle = alle + + const box = boxFromCoords(quelle) + if (!box || radiusKm(box) > MAX_RADIUS_KM) return {} + return { box, point: pointFromBox(box) } + }, [places, assignments, selectedDayId]) +} diff --git a/client/src/hooks/usePlannerHistory.ts b/client/src/hooks/usePlannerHistory.ts index fb8e79b70..050cd3c0a 100644 --- a/client/src/hooks/usePlannerHistory.ts +++ b/client/src/hooks/usePlannerHistory.ts @@ -1,29 +1,52 @@ -import { useRef, useReducer } from 'react' +import { useCallback, useRef, useReducer } from 'react' export interface UndoEntry { label: string undo: () => Promise | void + /** The days the step acts on. Deleting one of them drops the step. */ + dayIds?: number[] } export function usePlannerHistory(maxEntries = 30) { const historyRef = useRef([]) const [, forceUpdate] = useReducer((x: number) => x + 1, 0) - const pushUndo = (label: string, undoFn: () => Promise | void) => { - historyRef.current = [{ label, undo: undoFn }, ...historyRef.current].slice(0, maxEntries) + const pushUndo = (label: string, undoFn: () => Promise | void, dayIds?: number[]) => { + historyRef.current = [{ label, undo: undoFn, dayIds }, ...historyRef.current].slice(0, maxEntries) forceUpdate() } - const undo = async () => { - if (historyRef.current.length === 0) return + /** + * Take the last step back. Resolves true when it went through, false when it + * failed, and null when there was nothing to take back. + */ + const undo = async (): Promise => { + if (historyRef.current.length === 0) return null const [first, ...rest] = historyRef.current historyRef.current = rest forceUpdate() - try { await first.undo() } catch (e) { console.error('Undo failed:', e) } + try { + await first.undo() + return true + } catch (e) { + console.error('Undo failed:', e) + return false + } } + /** + * Drop the steps that act on a day that is gone. They could only fail now, + * and the undo button would point at one of them. + */ + const forgetDay = useCallback((dayId: number) => { + const kept = historyRef.current.filter(entry => !entry.dayIds?.includes(dayId)) + if (kept.length === historyRef.current.length) return + historyRef.current = kept + forceUpdate() + }, []) + const canUndo = historyRef.current.length > 0 const lastActionLabel = historyRef.current[0]?.label ?? null - return { pushUndo, undo, canUndo, lastActionLabel } + return { pushUndo, undo, forgetDay, canUndo, lastActionLabel } } diff --git a/client/src/hooks/useResizablePanels.ts b/client/src/hooks/useResizablePanels.ts index ba53a2b6c..ad0060b1b 100644 --- a/client/src/hooks/useResizablePanels.ts +++ b/client/src/hooks/useResizablePanels.ts @@ -1,7 +1,16 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' const MIN_SIDEBAR = 200 const MAX_SIDEBAR = 520 +/** + * The band where the desktop planner renders but two panels leave the map a + * useless strip. Below 768 the phone shell takes over, so the squeeze lives + * entirely between there and Tailwind's `lg`: a Pixel 10 Pro Fold unfolded is + * ~860 CSS px, where 340 + 300 of panel left the map 200 px (#2247). + */ +const NARROW_QUERY = '(min-width: 768px) and (max-width: 1023px)' +/** However wide a panel was dragged on a big screen, the map keeps this much. */ +const MIN_MAP = 360 export function useResizablePanels() { const [leftWidth, setLeftWidth] = useState(() => Number.parseInt(localStorage.getItem('sidebarLeftWidth') || '') || 340) @@ -11,6 +20,34 @@ export function useResizablePanels() { const isResizingLeft = useRef(false) const isResizingRight = useRef(false) + const [narrow, setNarrow] = useState( + () => typeof window !== 'undefined' && window.matchMedia(NARROW_QUERY).matches, + ) + // Which panel owns the screen while only one of them fits. The day plan is what + // the Plan tab is about, so it starts open and the places list is one tap away; + // null is map-only. Deliberately separate from the two collapse flags, which stay + // the wide-layout state — folding back to a wide window restores what was there. + const [narrowPanel, setNarrowPanel] = useState<'left' | 'right' | null>('left') + const [maxPanel, setMaxPanel] = useState(Number.POSITIVE_INFINITY) + + useEffect(() => { + const mq = window.matchMedia(NARROW_QUERY) + const handler = (e: MediaQueryListEvent) => setNarrow(e.matches) + setNarrow(mq.matches) + mq.addEventListener('change', handler) + return () => mq.removeEventListener('change', handler) + }, []) + + // Only measured inside the narrow band: that is the only place the clamp can + // bite, and a resize listener on every planner mount is not worth the rest. + useEffect(() => { + if (!narrow) { setMaxPanel(Number.POSITIVE_INFINITY); return } + const measure = () => setMaxPanel(Math.max(MIN_SIDEBAR, window.innerWidth - MIN_MAP - 20)) + measure() + window.addEventListener('resize', measure) + return () => window.removeEventListener('resize', measure) + }, [narrow]) + useEffect(() => { const onMove = (e: MouseEvent) => { if (isResizingLeft.current) { @@ -41,5 +78,27 @@ export function useResizablePanels() { const startResizeLeft = () => { isResizingLeft.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' } const startResizeRight = () => { isResizingRight.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' } - return { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } + // What the layout actually shows. The collapse flags stay the caller's intent — + // handlePlaceClick reopening "both" must not re-crowd a narrow screen. + const leftHidden = narrow ? narrowPanel !== 'left' : leftCollapsed + const rightHidden = narrow ? narrowPanel !== 'right' : rightCollapsed + + const toggleLeft = useCallback(() => { + if (narrow) setNarrowPanel(p => (p === 'left' ? null : 'left')) + else setLeftCollapsed(c => !c) + }, [narrow]) + const toggleRight = useCallback(() => { + if (narrow) setNarrowPanel(p => (p === 'right' ? null : 'right')) + else setRightCollapsed(c => !c) + }, [narrow]) + + return { + // Clamped for the layout; the stored width is left alone, so a 520 px panel + // dragged on a desktop comes back untouched once there is room for it again. + leftWidth: Math.min(leftWidth, maxPanel), + rightWidth: Math.min(rightWidth, maxPanel), + leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, + leftHidden, rightHidden, toggleLeft, toggleRight, narrow, + startResizeLeft, startResizeRight, + } } diff --git a/client/src/hooks/useRoadtripPreferencesSync.test.ts b/client/src/hooks/useRoadtripPreferencesSync.test.ts new file mode 100644 index 000000000..aede850d5 --- /dev/null +++ b/client/src/hooks/useRoadtripPreferencesSync.test.ts @@ -0,0 +1,33 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { useRoadtripPreferencesSync } from './useRoadtripPreferencesSync' +import { useRoadtripSettings } from './useRoadtripSettings' +import { useRoadtripPreferencesStore } from '../store/roadtripPreferencesStore' +import { useAuthStore } from '../store/authStore' +import { addListener, removeListener } from '../api/websocket' + +vi.mock('../api/websocket', () => ({ addListener: vi.fn(), removeListener: vi.fn() })) +vi.mock('../repo/roadtripPreferencesRepo', () => ({ cachedRoadtripPreferences: vi.fn(async () => ({})) })) +vi.mock('../db/offlineDb', () => ({ offlineDb: { roadtripPreferences: { put: vi.fn(async () => {}) } } })) + +describe('shared Roadtrip settings', () => { + it('scopes events and reads by trip and signed-in account', () => { + useAuthStore.setState({ user: { id: 1 } as never }) + useRoadtripPreferencesStore.setState({ byTrip: {} }) + const hook = renderHook(() => { + useRoadtripPreferencesSync() + return useRoadtripSettings(s => s.roadtrip_day_start, 10) + }) + const listener = vi.mocked(addListener).mock.calls[vi.mocked(addListener).mock.calls.length - 1][0] + act(() => listener({ type: 'roadtripPreferences:changed', tripId: '10', preferences: { roadtrip_day_start: '07:00' } })) + expect(hook.result.current).toBe('07:00') + act(() => listener({ type: 'roadtripPreferences:changed', tripId: '11', preferences: { roadtrip_day_start: '09:00' } })) + expect(hook.result.current).toBe('07:00') + act(() => listener({ type: 'roadtripPreferences:changed', tripId: '10', preferences: { roadtrip_day_start: '99:00' } })) + expect(hook.result.current).toBe('07:00') + act(() => useAuthStore.setState({ user: { id: 2 } as never })) + expect(hook.result.current).toBeUndefined() + hook.unmount() + expect(removeListener).toHaveBeenCalledWith(listener) + }) +}) diff --git a/client/src/hooks/useRoadtripPreferencesSync.ts b/client/src/hooks/useRoadtripPreferencesSync.ts new file mode 100644 index 000000000..f84ad2d57 --- /dev/null +++ b/client/src/hooks/useRoadtripPreferencesSync.ts @@ -0,0 +1,27 @@ +import { useEffect } from 'react' +import { roadtripPreferencesSchema } from '@trek/shared' +import { addListener, removeListener } from '../api/websocket' +import { useAuthStore } from '../store/authStore' +import { publishRoadtripPreferences } from '../store/roadtripPreferencesStore' +import { offlineDb } from '../db/offlineDb' +import { cachedRoadtripPreferences } from '../repo/roadtripPreferencesRepo' + +export function useRoadtripPreferencesSync() { + const userId = useAuthStore(s => s.user?.id) + useEffect(() => { + if (!userId) return + let disposed = false + const listener = (event: Record) => { + if (event.type !== 'roadtripPreferences:changed') return + const tripId = Number(event.tripId) + const parsed = roadtripPreferencesSchema.safeParse(event.preferences) + if (!parsed.success || !Number.isSafeInteger(tripId) || tripId < 1) return + publishRoadtripPreferences(userId, tripId, parsed.data) + void offlineDb.roadtripPreferences.put({ tripId, preferences: parsed.data }).then(() => cachedRoadtripPreferences(tripId)).then(preferences => { + if (!disposed) publishRoadtripPreferences(userId, tripId, preferences) + }).catch(() => {}) + } + addListener(listener) + return () => { disposed = true; removeListener(listener) } + }, [userId]) +} diff --git a/client/src/hooks/useRoadtripSettings.ts b/client/src/hooks/useRoadtripSettings.ts new file mode 100644 index 000000000..999ff0062 --- /dev/null +++ b/client/src/hooks/useRoadtripSettings.ts @@ -0,0 +1,42 @@ +import { liveQuery } from 'dexie' +import { onNetworkModeChange } from '../sync/networkMode' +import { useEffect, useState } from 'react' +import type { RoadtripPreferences } from '@trek/shared' +import { useAuthStore } from '../store/authStore' +import { useTripStore } from '../store/tripStore' +import { EMPTY_ROADTRIP_PREFERENCES, publishRoadtripPreferences, useRoadtripPreferencesStore } from '../store/roadtripPreferencesStore' +import { cachedRoadtripPreferences, roadtripPreferencesRepo } from '../repo/roadtripPreferencesRepo' + +export function useRoadtripSettings(select: (settings: RoadtripPreferences) => T, requestedTripId?: number | string | null): T { + const activeTripId = useTripStore(s => s.trip?.id) + const userId = useAuthStore(s => s.user?.id) + const key = `${userId}:${requestedTripId ?? activeTripId}` + return useRoadtripPreferencesStore(s => select(s.byTrip[key] ?? EMPTY_ROADTRIP_PREFERENCES)) +} + +export function useLoadRoadtripSettings(tripId: number, enabled: boolean) { + const userId = useAuthStore(s => s.user?.id) + const [loadedKey, setLoadedKey] = useState('') + const [failedKey, setFailedKey] = useState('') + const key = `${userId}:${tripId}` + useEffect(() => { + if (!enabled || !userId || !Number.isFinite(tripId)) return + let cancelled = false + const load = () => { + roadtripPreferencesRepo.read(tripId).then(preferences => { + if (cancelled) return + publishRoadtripPreferences(userId, tripId, preferences) + setLoadedKey(key) + setFailedKey('') + }).catch(() => { if (!cancelled) setFailedKey(key) }) + } + const cacheSubscription = liveQuery(() => cachedRoadtripPreferences(tripId).catch(() => null)).subscribe(preferences => { + if (!cancelled && preferences) publishRoadtripPreferences(userId, tripId, preferences) + }) + load() + const unsubscribe = onNetworkModeChange(load) + window.addEventListener('online', load) + return () => { cancelled = true; cacheSubscription.unsubscribe(); unsubscribe(); window.removeEventListener('online', load) } + }, [tripId, userId, enabled, key]) + return { ready: loadedKey === key, failed: failedKey === key } +} diff --git a/client/src/hooks/useRouteCalculation.ts b/client/src/hooks/useRouteCalculation.ts index f66df2349..06e0a358f 100644 --- a/client/src/hooks/useRouteCalculation.ts +++ b/client/src/hooks/useRouteCalculation.ts @@ -1,15 +1,14 @@ +import { useRoadtripSettings } from './useRoadtripSettings' +import { isServiceStopType } from '../components/Roadtrip/roadtripModel' import { useState, useCallback, useRef, useEffect, useMemo } from 'react' import { useTripStore } from '../store/tripStore' import { useSettingsStore } from '../store/settingsStore' -import { calculateRouteWithLegs, withHotelBookends, type RouteProfileKey } from '../components/Map/RouteCalculator' -import { getTransportRouteEndpoints } from '../utils/dayMerge' -import { getDayBookendHotels, shouldDrawMorningLeg, shouldDrawEveningLeg } from '../utils/dayOrder' +import { calculateRouteWithLegs, type RouteProfileKey } from '../components/Map/RouteCalculator' +import { buildDayRouteRuns, TRANSPORT_TYPES } from '../components/Map/dayRoutePlan' import { resolveLegMode } from '../components/Planner/legMode' import type { TripStoreState } from '../store/tripStore' import type { RouteSegment, RouteResult, RouteVia, Accommodation } from '../types' -const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'] - const NO_ACCOMMODATIONS: Accommodation[] = [] /** @@ -23,6 +22,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu const [routeSegments, setRouteSegments] = useState([]) // Charging stops / rest areas a plugin route places on the drawn line. const [routeVias, setRouteVias] = useState([]) + const mirrorServiceStops = useRoadtripSettings(s => s.roadtrip_service_stops_in_days !== false) const routeAbortRef = useRef(null) const reservationsForSignature = useTripStore((s) => s.reservations) // Recompute when the selected day's whole-day default mode changes (#1281) — @@ -43,135 +43,16 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu if (!dayId || !enabled) { setRoute(null); setRouteSegments([]); setRouteVias([]); return } // Read directly from store (not a render-phase ref) so callers after optimistic // updates or non-optimistic deletes always see the latest assignments. - const currentAssignments = useTripStore.getState().assignments || {} - const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) - const allReservations = useTripStore.getState().reservations || [] - const allDays = useTripStore.getState().days || [] - const dayOrder = (id: number | null | undefined): number | null => { - if (id == null) return null - const d = allDays.find(x => x.id === id) - return d ? ((d as any).day_number ?? allDays.indexOf(d)) : null - } - const thisOrder = dayOrder(dayId) - - // Transport reservations for this day with a known position — mirrors getTransportForDay semantics - const dayTransports = thisOrder == null ? [] : allReservations.filter(r => { - if (!TRANSPORT_TYPES.includes(r.type)) return false - const startId = r.day_id - if (startId == null) return false - const endId = r.end_day_id ?? startId - if (startId === endId) { - if (startId !== dayId) return false - } else { - const startOrder = dayOrder(startId) - const endOrder = dayOrder(endId) - if (startOrder == null || endOrder == null) return false - if (thisOrder < startOrder || thisOrder > endOrder) return false - } - const pos = r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position - return pos != null + const state = useTripStore.getState() + const allDays = state.days || [] + const runsWithHotel = buildDayRouteRuns(dayId, { + days: allDays, + assignments: Object.fromEntries(Object.entries(state.assignments || {}).map(([id, entries]) => [id, entries.filter(a => mirrorServiceStops || !isServiceStopType(a.place?.stop_type))])), + reservations: state.reservations || [], + accommodations, + optimizeFromAccommodation, }) - - // Build a unified list of places + transports sorted by effective position. - 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 } - const entries: Entry[] = [ - ...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({ - kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index, 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, - })), - ...dayTransports.map(r => { - const { from, to } = getTransportRouteEndpoints(r, dayId) - return { - kind: 'transport' as const, - from, - to, - pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number, - } - }), - ].sort((a, b) => a.pos - b.pos) - - // Group located places into driving runs. - // - A transport WITH a location anchors the route to its departure point (you - // travel there), then breaks the run (you don't drive the flight/train); its - // arrival point starts the next run. - // - A transport WITHOUT a location is ignored entirely — the places around it - // connect directly, as if the booking weren't there. - // A run is only a real drive when it contains at least one actual place. Two - // back-to-back transports (e.g. two flights on one day) would otherwise pair the - // first's arrival point with the second's departure point into a phantom - // [airport → airport] road route — that is the flight itself, not a drive (#1394). - type RunPoint = { lat: number; lng: number; isPlace: boolean; leg_transport_mode?: string | null; incoming_leg_transport_mode?: string | null } - const runs: RunPoint[][] = [] - let currentRun: RunPoint[] = [] - let runHasPlace = false - for (const entry of entries) { - if (entry.kind === 'place') { - 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) { - if (entry.from) currentRun.push({ ...entry.from, isPlace: false }) - if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun) - currentRun = [] - runHasPlace = false - if (entry.to) currentRun.push({ ...entry.to, isPlace: false }) - } - } - if (currentRun.length >= 2 && runHasPlace) runs.push(currentRun) - - // Bookend the route with the day's accommodation: a hotel → first-stop run and - // a last-stop → hotel run, so the drawn line matches the sidebar's hotel legs. - // getDayBookendHotels returns the morning/evening hotel (they differ only on a - // transfer day) and already filters to accommodations that have coordinates. const day = allDays.find(d => d.id === dayId) - const bookends = day && optimizeFromAccommodation !== false - ? getDayBookendHotels(day, allDays, accommodations) - : null - const flatPts: RunPoint[] = [] - 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): RunPoint | 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. You start/end the day at a hotel - // when you slept there / sleep there tonight; on the hotel's own check-in or check-out day - // the leg holds only when the edge stop is a PLACE timed after check-in / before check-out - // (you dropped bags first, or swung back before checking out). A place before check-in (an - // airport you reach first, #1465), a later "home" stop on the checkout day (#1465), or a - // transport endpoint on an arrival/departure day (#1321, S7) 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) => - e ? { isPlace: e.kind === 'place', time: e.kind === 'place' ? e.time : null } : undefined - const drawMorning = !!bookends && !!day && shouldDrawMorningLeg(bookends, day, edgeInfo(firstStop)) - const drawEvening = !!bookends && !!day && shouldDrawEveningLeg(bookends, day, edgeInfo(lastStop)) - const runsWithHotel: RunPoint[][] = withHotelBookends( - runs, - flatPts[0], - flatPts[flatPts.length - 1], - drawMorning ? hotelPt(bookends?.morning) : null, - drawEvening ? hotelPt(bookends?.evening) : 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. - // Draw the hotel → hotel transfer directly. Gated on both bookends being real - // (drawMorning/drawEvening already exclude the #1321 arrival fallback) and the two - // hotels being distinct, so an ordinary same-hotel rest day still draws nothing. - 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]) - } const straightLines = (): [number, number][][] => runsWithHotel.map(r => r.map(p => [p.lat, p.lng] as [number, number])) @@ -235,7 +116,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu // Aborted (day changed) — newer call owns the state. Anything else: keep straight lines. if (!(err instanceof Error) || err.name !== 'AbortError') { setRouteSegments([]); setRouteVias([]) } } - }, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode]) + }, [enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops]) // Stable signature for transport reservations on the selected day — changes when a transport // is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders. @@ -259,7 +140,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu if (!selectedDayId) { setRoute(null); setRouteSegments([]); setRouteVias([]); return } updateRouteForDay(selectedDayId) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode]) + }, [selectedDayId, selectedDayAssignments, transportSignature, enabled, profile, accommodations, optimizeFromAccommodation, distanceUnit, selectedDayDefaultMode, mirrorServiceStops]) return { route, routeSegments, routeVias, routeInfo, setRoute, setRouteInfo, updateRouteForDay } } diff --git a/client/src/hooks/useTileUrl.test.ts b/client/src/hooks/useTileUrl.test.ts new file mode 100644 index 000000000..28d1c8003 --- /dev/null +++ b/client/src/hooks/useTileUrl.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useTileUrl, useCartoApiKey } from './useTileUrl' +import { useSettingsStore } from '../store/settingsStore' +import { CARTO_LIGHT, CARTO_DARK } from '../constants/mapDefaults' +import type { Settings } from '../types' + +const OSM = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' +const SELF_HOSTED = 'https://tiles.example.com/{z}/{x}/{y}.png' + +const initialState = useSettingsStore.getState() + +function setSettings(patch: Partial) { + useSettingsStore.setState((s) => ({ settings: { ...s.settings, ...patch } })) +} + +describe('useTileUrl', () => { + beforeEach(() => { + useSettingsStore.setState(initialState, true) + }) + + it('FE-TILEURL-001: falls back to the caller default while nothing is configured', () => { + const { result } = renderHook(() => useTileUrl(OSM)) + expect(result.current).toBe(OSM) + }) + + it('FE-TILEURL-002: the user template wins over the fallback', () => { + setSettings({ map_tile_url: SELF_HOSTED }) + const { result } = renderHook(() => useTileUrl(CARTO_LIGHT)) + expect(result.current).toBe(SELF_HOSTED) + }) + + it('FE-TILEURL-003: appends the CARTO key to a CARTO fallback', () => { + setSettings({ carto_api_key: 'abc123' }) + const { result } = renderHook(() => useTileUrl(CARTO_LIGHT)) + expect(result.current).toBe(`${CARTO_LIGHT}?key=abc123`) + }) + + it('FE-TILEURL-004: keeps the key away from a self-hosted template', () => { + setSettings({ map_tile_url: SELF_HOSTED, carto_api_key: 'abc123' }) + const { result } = renderHook(() => useTileUrl(CARTO_LIGHT)) + expect(result.current).toBe(SELF_HOSTED) + }) + + it('FE-TILEURL-005: ignoreUserTemplate pins the fallback but still gets the key', () => { + // Atlas and collections pin a basemap of their own; they must not lose the + // key just because they ignore map_tile_url. + setSettings({ map_tile_url: SELF_HOSTED, carto_api_key: 'abc123' }) + const { result } = renderHook(() => useTileUrl(CARTO_DARK, true)) + expect(result.current).toBe(`${CARTO_DARK}?key=abc123`) + }) + + it('FE-TILEURL-006: normalizes a legacy sharded OSM template', () => { + setSettings({ map_tile_url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' }) + const { result } = renderHook(() => useTileUrl(CARTO_LIGHT)) + expect(result.current).toBe(OSM) + }) + + it('FE-TILEURL-007: follows a key change without a remount', () => { + setSettings({ carto_api_key: 'first' }) + const { result } = renderHook(() => useTileUrl(CARTO_LIGHT)) + expect(result.current).toBe(`${CARTO_LIGHT}?key=first`) + + act(() => setSettings({ carto_api_key: 'second' })) + expect(result.current).toBe(`${CARTO_LIGHT}?key=second`) + + act(() => setSettings({ carto_api_key: '' })) + expect(result.current).toBe(CARTO_LIGHT) + }) + + it('FE-TILEURL-008: follows a template change without a remount', () => { + const { result } = renderHook(() => useTileUrl(CARTO_LIGHT)) + act(() => setSettings({ map_tile_url: SELF_HOSTED })) + expect(result.current).toBe(SELF_HOSTED) + }) +}) + +describe('useCartoApiKey', () => { + beforeEach(() => { + useSettingsStore.setState(initialState, true) + }) + + it('FE-TILEURL-009: reports an empty string while no key is stored', () => { + setSettings({ carto_api_key: undefined }) + const { result } = renderHook(() => useCartoApiKey()) + expect(result.current).toBe('') + }) + + it('FE-TILEURL-010: reports the stored key', () => { + setSettings({ carto_api_key: 'abc123' }) + const { result } = renderHook(() => useCartoApiKey()) + expect(result.current).toBe('abc123') + }) +}) diff --git a/client/src/hooks/useTileUrl.ts b/client/src/hooks/useTileUrl.ts new file mode 100644 index 000000000..67940f903 --- /dev/null +++ b/client/src/hooks/useTileUrl.ts @@ -0,0 +1,21 @@ +import { useMemo } from 'react' +import { useSettingsStore } from '../store/settingsStore' +import { resolveTileUrl } from '../utils/tileUrl' + +/** + * Every logged-in map reads its tile template through here so the CARTO key + * reaches all of them, including the ones that ignore map_tile_url and pin a + * basemap of their own (atlas, collections). + */ +export function useTileUrl(fallback: string, ignoreUserTemplate = false): string { + const template = useSettingsStore((s) => s.settings.map_tile_url) + const cartoKey = useSettingsStore((s) => s.settings.carto_api_key) + return useMemo( + () => resolveTileUrl(ignoreUserTemplate ? '' : template, fallback, cartoKey), + [template, fallback, cartoKey, ignoreUserTemplate] + ) +} + +export function useCartoApiKey(): string { + return useSettingsStore((s) => s.settings.carto_api_key) ?? '' +} diff --git a/client/src/hooks/useTransportRoutes.ts b/client/src/hooks/useTransportRoutes.ts index afe76b763..02f7ea7a4 100644 --- a/client/src/hooks/useTransportRoutes.ts +++ b/client/src/hooks/useTransportRoutes.ts @@ -1,6 +1,8 @@ import { useEffect, useRef, useState } from 'react' import type { Reservation, ReservationEndpoint } from '../types' import { calculateRouteWithLegs } from '../components/Map/RouteCalculator' +import { MAX_DRIVE_KM } from '@trek/shared/roadtrip' +import { haversineKm } from '../utils/geo' /** * Real road-network geometry for road-based transport bookings (car, bus, taxi, @@ -20,20 +22,12 @@ const ROAD_PROFILE: Record = { bicycle: 'cycling', } -// Beyond this straight-line distance a car/taxi/bike (or even coach) booking is -// almost always a data quirk or an inter-continental hop the road router can't -// resolve — keep the straight line and don't hammer the public OSRM demo. -const MAX_ROUTE_KM = 2000 - -function haversineKm(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number { - const R = 6371 - const dLat = (b.lat - a.lat) * Math.PI / 180 - const dLng = (b.lng - a.lng) * Math.PI / 180 - const la1 = a.lat * Math.PI / 180 - const la2 = b.lat * Math.PI / 180 - const h = Math.sin(dLat / 2) ** 2 + Math.cos(la1) * Math.cos(la2) * Math.sin(dLng / 2) ** 2 - return 2 * R * Math.asin(Math.sqrt(h)) -} +// Beyond this straight-line distance a single leg of a car/taxi/bike (or even coach) +// booking is almost always a data quirk or an inter-continental hop the road router +// can't resolve — keep the straight line and don't hammer the public OSRM demo. +// Shared with the day-route builder, which draws the same conclusion about a +// booking endpoint that landed next to a local stop (#2133). +const MAX_ROUTE_KM = MAX_DRIVE_KM function orderedWaypoints(r: Reservation): ReservationEndpoint[] { return (r.endpoints || []) @@ -67,9 +61,14 @@ export function useTransportRoutes(reservations: Reservation[]): Map MAX_ROUTE_KM) continue + // Per leg, not over the whole booking: a road trip with stops is long by design + // and its total would trip a limit meant to catch a single impossible hop. It is + // one leg spanning an ocean that the road router cannot resolve, not the sum (#1797). + let tooFar = false + for (let i = 0; i < wps.length - 1; i++) { + if (haversineKm(wps[i], wps[i + 1]) > MAX_ROUTE_KM) { tooFar = true; break } + } + if (tooFar) continue const key = `${profile}:${wps.map(w => `${w.lat},${w.lng}`).join('|')}` if (attempted.get(r.id) === key) continue attempted.set(r.id, key) diff --git a/client/src/hooks/useTripRangeGuard.test.ts b/client/src/hooks/useTripRangeGuard.test.ts new file mode 100644 index 000000000..5eec8aeae --- /dev/null +++ b/client/src/hooks/useTripRangeGuard.test.ts @@ -0,0 +1,115 @@ +/** + * FE-HOOK-RANGEGUARD-001 to FE-HOOK-RANGEGUARD-006: the question before a trip + * dialog saves new dates. The desktop dialog and the phone sheet both render + * what this answers, so what it reads, when it reads nothing, and what it says + * when it cannot read are pinned here once. The repos are mocked; the layout + * rule is the real planDayGrid from shared, the one the server runs. + */ +import { createElement, type ReactNode } from 'react' +import { act, renderHook } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { TranslationProvider } from '../i18n/TranslationContext' +import { buildAssignment, buildBudgetItem, buildDay, buildPlace, buildReservation } from '../../tests/helpers/factories' +import type { Accommodation, BudgetItem, Day, Reservation } from '../types' +import { useTripRangeGuard, type RangeCheck, type RangeRemoval } from './useTripRangeGuard' + +const repos = { + days: vi.fn<() => Promise<{ days: Day[] }>>(), + reservations: vi.fn<() => Promise<{ reservations: Reservation[] }>>(), + accommodations: vi.fn<() => Promise<{ accommodations: Accommodation[] }>>(), + budget: vi.fn<() => Promise<{ items: BudgetItem[] }>>(), +} +vi.mock('../repo/dayRepo', () => ({ dayRepo: { list: () => repos.days() } })) +vi.mock('../repo/reservationRepo', () => ({ reservationRepo: { list: () => repos.reservations() } })) +vi.mock('../repo/accommodationRepo', () => ({ accommodationRepo: { list: () => repos.accommodations() } })) +vi.mock('../repo/budgetRepo', () => ({ budgetRepo: { list: () => repos.budget() } })) + +const wrapper = ({ children }: { children: ReactNode }) => createElement(TranslationProvider, null, children) + +const trip = { id: 7, start_date: '2026-10-01', end_date: '2026-10-05' } + +/** Five dated days; the ones named get a place. */ +function fiveDays(planned: number[] = []): Day[] { + return Array.from({ length: 5 }, (_, i) => buildDay({ + id: i + 1, + day_number: i + 1, + date: `2026-10-0${i + 1}`, + assignments: planned.includes(i + 1) ? [buildAssignment({ day_id: i + 1, place: buildPlace({ id: 90 + i }) })] : [], + })) +} + +async function ask(payload: Parameters['check']>[1]): Promise { + const { result } = renderHook(() => useTripRangeGuard(), { wrapper }) + let answer: RangeCheck = null + await act(async () => { answer = await result.current.check(trip, payload) }) + expect(result.current.checking).toBe(false) + return answer +} + +beforeEach(() => { + repos.days.mockReset().mockResolvedValue({ days: fiveDays([4, 5]) }) + repos.reservations.mockReset().mockResolvedValue({ reservations: [] }) + repos.accommodations.mockReset().mockResolvedValue({ accommodations: [] }) + repos.budget.mockReset().mockResolvedValue({ items: [] }) +}) + +describe('useTripRangeGuard', () => { + it('FE-HOOK-RANGEGUARD-001: a save that keeps the dates is answered without reading anything', async () => { + const rename = { title: 'Renamed', start_date: trip.start_date, end_date: trip.end_date } + expect(await ask(rename)).toBeNull() + expect(await ask({})).toBeNull() + expect(repos.days).not.toHaveBeenCalled() + expect(repos.reservations).not.toHaveBeenCalled() + expect(repos.accommodations).not.toHaveBeenCalled() + }) + + it('FE-HOOK-RANGEGUARD-002: an earlier end reads the three repos and names the days it takes, with what is on them', async () => { + const answer = await ask({ start_date: '2026-10-01', end_date: '2026-10-03' }) as RangeRemoval + expect(repos.days).toHaveBeenCalledTimes(1) + expect(repos.reservations).toHaveBeenCalledTimes(1) + expect(repos.accommodations).toHaveBeenCalledTimes(1) + expect(answer.removedDays.map(r => r.day.id)).toEqual([4, 5]) + expect(answer.content.places).toBe(2) + expect(answer.startMoved).toBe(false) + // Named the way the day list names them: the date, short. + expect(answer.dayLabels).toEqual(['Sun, Oct 4', 'Mon, Oct 5']) + }) + + it('FE-HOOK-RANGEGUARD-003: days that go empty ask nothing', async () => { + repos.days.mockResolvedValue({ days: fiveDays([1, 2]) }) + expect(await ask({ start_date: '2026-10-01', end_date: '2026-10-03' })).toBeNull() + }) + + it('FE-HOOK-RANGEGUARD-004: when the days cannot be read the answer is unknown, not silence', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}) + repos.reservations.mockRejectedValue(new Error('500')) + expect(await ask({ start_date: '2026-10-01', end_date: '2026-10-03' })).toBe('unknown') + expect(spy).toHaveBeenCalled() + spy.mockRestore() + }) + + it('FE-HOOK-RANGEGUARD-006: the expenses say whether the booking of a removed stay has one; unreadable ones cost no answer', async () => { + repos.accommodations.mockResolvedValue({ accommodations: [{ id: 9, trip_id: 7, start_day_id: 4, end_day_id: 5, place_name: 'Harbour Hotel' } as Accommodation] }) + repos.reservations.mockResolvedValue({ reservations: [buildReservation({ id: 40, day_id: 4, type: 'hotel', title: 'Harbour, 1 night', accommodation_id: 9 })] }) + const shrink = { start_date: '2026-10-01', end_date: '2026-10-03' } + + // Read, and none written against the booking. + expect((await ask(shrink) as RangeRemoval).content.stays[0].expense).toBeNull() + // Read, with one: said, though not by its sum, since shortening keeps it. + repos.budget.mockResolvedValue({ items: [buildBudgetItem({ reservation_id: 40, total_price: 120 })] }) + expect((await ask(shrink) as RangeRemoval).content.stays[0].expense).toEqual({ amount: null }) + // Not readable: the warning still comes, and says the booking may have one. + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + repos.budget.mockRejectedValue(new Error('500')) + const answer = await ask(shrink) as RangeRemoval + expect(answer.content.stays[0]).toMatchObject({ booking: 'Harbour, 1 night', expense: { amount: null } }) + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) + + it('FE-HOOK-RANGEGUARD-005: a later start with the same end takes the last days and says the start moved', async () => { + const answer = await ask({ start_date: '2026-10-03', end_date: '2026-10-05' }) as RangeRemoval + expect(answer.removedDays.map(r => r.day.id)).toEqual([4, 5]) + expect(answer.startMoved).toBe(true) + }) +}) diff --git a/client/src/hooks/useTripRangeGuard.ts b/client/src/hooks/useTripRangeGuard.ts new file mode 100644 index 000000000..e983425f6 --- /dev/null +++ b/client/src/hooks/useTripRangeGuard.ts @@ -0,0 +1,95 @@ +import { useCallback, useState } from 'react' +import { planDayGrid, resolveDayGridRange } from '@trek/shared' +import { useTranslation } from '../i18n' +import { dayRepo } from '../repo/dayRepo' +import { reservationRepo } from '../repo/reservationRepo' +import { accommodationRepo } from '../repo/accommodationRepo' +import { budgetRepo } from '../repo/budgetRepo' +import { dayLabel } from '../utils/dayLabel' +import { hasVisibleContent, tripRangeImpact, type TripRangeImpact } from '../utils/tripRangeImpact' +import type { AssignmentsMap, DayNotesMap, Trip } from '../types' + +/** Days a save would remove, with the names the day list gives them. */ +export interface RangeRemoval extends TripRangeImpact { + dayLabels: string[] +} + +/** + * The answer before a save: the days it removes with something on them, null + * when it removes nothing worth a question, or 'unknown' when the trip's days + * could not be read, so the dialog can still warn in general terms. + */ +export type RangeCheck = RangeRemoval | 'unknown' | null + +/** The fields of a trip save that move the day grid. */ +export interface RangePayload { + start_date?: string | null + end_date?: string | null + day_count?: number | null +} + +/** + * Asks, before a trip dialog saves new dates, which days the save would remove + * and what is on them. It reads the trip's days, bookings and stays through the + * repos and lays the days out with planDayGrid, the rule the server rebuilds + * them by, so the warning names exactly the days the save takes. A save that + * does not rebuild the days is answered without reading anything. The expenses + * only tell whether a removed stay's booking has one; when they cannot be read, + * the warning says it may. + * + * Used by the desktop trip dialog and the phone's trip sheet alike; both only + * render what comes back. + */ +export function useTripRangeGuard(): { checking: boolean; check: (trip: Pick, payload: RangePayload) => Promise } { + const { t, locale } = useTranslation() + const [checking, setChecking] = useState(false) + + const check = useCallback(async (trip: Pick, payload: RangePayload): Promise => { + const range = resolveDayGridRange(trip, payload) + if (!range.regenerate) return null + setChecking(true) + try { + const [{ days }, { reservations }, { accommodations }, expenses] = await Promise.all([ + dayRepo.list(trip.id), + reservationRepo.list(trip.id), + accommodationRepo.list(trip.id), + budgetRepo.list(trip.id).catch((err: unknown) => { + console.warn('Expenses not read for the warning before new dates:', err) + return null + }), + ]) + const plan = planDayGrid({ + days: days.map(d => ({ + id: d.id, + day_number: d.day_number ?? 0, + date: d.date, + hasPlanItems: (d.assignments?.length ?? 0) > 0 || (d.notes_items?.length ?? 0) > 0, + })), + stays: accommodations, + startDate: range.newStart, + endDate: range.newEnd, + dayCount: range.dayCount, + }) + if (plan.removed.length === 0) return null + + const assignments: AssignmentsMap = {} + const dayNotes: DayNotesMap = {} + for (const day of days) { + assignments[String(day.id)] = day.assignments ?? [] + dayNotes[String(day.id)] = day.notes_items ?? [] + } + const startMoved = !!trip.start_date && !!range.newStart && !!range.newEnd && range.newStart !== trip.start_date + const budget = expenses ? { items: expenses.items } : undefined + const impact = tripRangeImpact(plan, days, { assignments, dayNotes, reservations, accommodations, budget }, { startMoved }) + if (!hasVisibleContent(impact)) return null + return { ...impact, dayLabels: impact.removedDays.map(({ day, index }) => dayLabel(day, index, t, locale)) } + } catch (err: unknown) { + console.error('Failed to read the days a new range removes:', err) + return 'unknown' + } finally { + setChecking(false) + } + }, [t, locale]) + + return { checking, check } +} diff --git a/client/src/index.css b/client/src/index.css index a0416cc5c..1edddd4a5 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -15,7 +15,14 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow reloads the installed PWA. Desktop/tablet keep the rules above. */ @media (max-width: 767px) { html { overflow: visible; overscroll-behavior: none; } - body { height: auto; overflow: visible; } + /* overflow-x clip (not hidden: clip creates no scroll container) so a screen + that accidentally overflows sideways cannot widen the document. Android's + forced-zoom setting reacts to a wider document by growing the layout + viewport that position:fixed resolves against, which pushed the top bar + and the dock off the right edge on narrow phones. On body, never on html: + body's overflow only propagates to the viewport while html stays visible, + and the shared body scroll lock relies on that propagation. */ + body { height: auto; overflow: visible; overflow-x: clip; } /* Transparent so the shell's own background layer, which sits behind the page on a negative z-index, is not painted over: a negative layer goes above the canvas but below the background of ordinary blocks, and body is one. The @@ -35,6 +42,13 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow .journey-feed-scroll { scrollbar-width: none; -ms-overflow-style: none; } .journey-feed-scroll::-webkit-scrollbar { display: none; } +/* Road trip rail: hide the scrollbar of the day list. The totals card is pinned + above the list and therefore outside its scroll box, so a visible scrollbar + would take width from the day cards alone and leave the two stacks a few + pixels out of line — the one thing a column of nested cards cannot hide. */ +.roadtrip-rail-scroll { scrollbar-width: none; -ms-overflow-style: none; } +.roadtrip-rail-scroll::-webkit-scrollbar { display: none; } + /* Journey video: let the player fill the box the lightbox gives it. * * Plyr sizes its wrapper from the video's intrinsic aspect ratio via a @@ -122,8 +136,32 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow font-family: inherit !important; box-shadow: 0 8px 32px rgba(0,0,0,0.25) !important; transition: none !important; + max-width: min(280px, calc(100vw - 32px)) !important; + white-space: normal !important; + word-wrap: break-word !important; } .atlas-tooltip::before { border-top-color: rgba(10, 10, 20, 0.6) !important; } +/* Bucket-list marker tooltip only — arbitrary-length user notes need a scroll + cap. The scroll happens on .atlas-tooltip-scroll-inner, not this element, + so the outer border-radius clips the scrollbar instead of it poking + through the rounded corner. overflow-y on the inner div is toggled + per-instance in useAtlas.ts, not forced here. */ +.atlas-tooltip-scrollable { + width: min(480px, calc(100vw - 32px)) !important; + max-width: min(480px, calc(100vw - 32px)) !important; + overflow: hidden !important; + pointer-events: auto !important; +} +.atlas-tooltip-scroll-inner { + max-height: min(200px, 40vh); + overflow-y: hidden; + /* Leaflet puts touch-action: none on the map container; without widening it + back here a touch swipe over the note can never scroll it (#2153). */ + touch-action: pan-y; +} +.atlas-tooltip-scroll-inner::-webkit-scrollbar { width: 6px; } +.atlas-tooltip-scroll-inner::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.25); border-radius: 3px; } +html:not(.dark) .atlas-tooltip-scroll-inner::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); } html:not(.dark) .atlas-tooltip { background: rgba(255, 255, 255, 0.75) !important; color: #0f172a !important; @@ -201,8 +239,9 @@ html.dark .border-dashed.border-gray-300 { border-color: var(--border-primary) ! html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color: transparent !important; } /* Reorder buttons: desktop = original style; touch = always visible, larger touch targets. - Keyed on pointer as well as width — a tablet is a wide touch device, and drag reorder is - off wherever the pointer is coarse (#1432), so these buttons are the only affordance left. */ + Keyed on pointer as well as width — a tablet is a wide touch device with no hover, so + without this the buttons would never appear there. (Drag reorder itself is no longer off + on a coarse pointer: #1616 gave tablets the long-press bridge.) */ .reorder-buttons { flex-direction: column; opacity: 0; @@ -239,9 +278,6 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color align-items: center !important; justify-content: center !important; } - .note-edit-buttons { - opacity: 1 !important; - } } /* Packing list rows: on phones, keep the item name readable and move the dense @@ -272,15 +308,20 @@ html.dark .bg-slate-50\/60, html.dark [class*="bg-slate-50/"] { background-color } /* ── Press-Feedback + bessere Easings (Emil Kowalski) ─────────── */ -/* Buttons sollen antworten wenn sie gedrückt werden. */ +/* Buttons sollen antworten wenn sie gedrückt werden. + Map-Marker sind ausgenommen: MapLibre und Mapbox geben ihrem Marker-Element + role="button" und schreiben dessen `transform` bei jedem Frame einer Bewegung + neu. Mit einem Übergang darauf hinkt die Nadel dem Kartenzug hinterher und + springt am Ende an ihren Ort zurück, und ein Klick würde den scale(0.97) über + die Positionierung der Bibliothek legen. */ button:not(:disabled):not([data-no-press]), -[role="button"]:not([aria-disabled="true"]):not([data-no-press]) { +[role="button"]:not([aria-disabled="true"]):not([data-no-press]):not(.maplibregl-marker):not(.mapboxgl-marker) { transition-property: transform, color, background-color, border-color, box-shadow, opacity, filter !important; transition-duration: 180ms; transition-timing-function: cubic-bezier(0.23, 1, 0.32, 1); } button:not(:disabled):not([data-no-press]):active, -[role="button"]:not([aria-disabled="true"]):not([data-no-press]):active { +[role="button"]:not([aria-disabled="true"]):not([data-no-press]):not(.maplibregl-marker):not(.mapboxgl-marker):active { transform: scale(0.97); transition-duration: 80ms; } @@ -465,6 +506,47 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti will-change: transform, opacity; } +/* Dokumentenabgleich: die aktive Bahn laeuft, WAEHREND ein Lauf laeuft. + Bewegung als Antwort auf eine Handlung, nicht als Dekoration: sie hoert auf, + sobald der Lauf durch ist, und sagt in der Zwischenzeit, in welche Richtung + gerade etwas unterwegs ist. */ +@keyframes trek-docsync-lane { + from { background-position: 0 0; } + to { background-position: 16px 0; } +} +.trek-docsync-lane-run { + background-image: repeating-linear-gradient( + 90deg, + currentColor 0 5px, + transparent 5px 16px + ); + animation: trek-docsync-lane 700ms linear infinite; +} +.trek-docsync-lane-run-rev { + animation-direction: reverse; +} + +/* Die abgeschaltete Richtung bekommt eine Schraffur, dasselbe Zeichen, das eine + gesperrte Flaeche ueberall traegt. Es liest sich als "hier geht nichts durch", + ohne dass die Bahn verschwindet und die Zeile zusammenklappt. */ +.trek-docsync-lane-off { + background-image: repeating-linear-gradient( + -45deg, + var(--border-primary) 0 5px, + transparent 5px 20px + ); +} + +/* Dieselbe Schraffur im Telefon-Schema: die mobilen Flaechen kennen die + Desktop-Randfarbe nicht, und eine Fremdfarbe faellt dort sofort auf. */ +.trek-docsync-lane-off-m { + background-image: repeating-linear-gradient( + -45deg, + var(--m-rowbr) 0 5px, + transparent 5px 20px + ); +} + /* Mobile-Drawer-Feel — Modal slidet von unten rein, wird unten am Screen angedockt */ @keyframes trek-drawer-enter { from { opacity: 0; transform: translateY(100%); } @@ -487,6 +569,44 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti will-change: transform, opacity; } +/* Roadtrip: die Zzz am Tageswechsel im Streckenstrang. Drei Buchstaben auf einer + Grundlinie, jeder ein Drittel des Laufs nach dem vorigen, damit immer einer + unterwegs ist. Rein dekorativ — das Wort daneben sagt schon, was das Band ist. */ +@keyframes trek-doze { + 0% { opacity: 0; transform: translate(0, 3px) scale(0.7); } + 22% { opacity: 0.9; } + 100% { opacity: 0; transform: translate(6px, -10px) scale(1.15); } +} +.trek-doze > * { + animation: trek-doze 3s ease-out infinite; +} +.trek-doze > *:nth-child(2) { animation-delay: 1s; } +.trek-doze > *:nth-child(3) { animation-delay: 2s; } + +/* Roadtrip: der Block, den eine Nachtfahrt auf dem Folgetag hinterlaesst. Er faellt + von oben in die Karte, weil er von der Karte darueber kommt — kurz genug, dass ein + Neuberechnen der Ankunftszeiten nicht als Zappeln liest. */ +@keyframes trek-spill-in { + from { opacity: 0; transform: translateY(-6px) scale(0.985); } + to { opacity: 1; transform: translateY(0) scale(1); } +} +.trek-spill-in { + animation: trek-spill-in 320ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* Roadtrip: die Reserveleuchte am Band, an dem der Tank leerlaeuft. Das Gegenstueck + zu den Zzz am Tageswechsel — dort schlaeft der Tag ein, hier geht die Lampe im + Armaturenbrett an. Langsam an, kurz aus: eine Warnleuchte blinkt nicht hektisch, + sie glimmt. */ +@keyframes trek-lowfuel { + 0%, 62%, 100% { opacity: 1; } + 74% { opacity: 0.28; } + 86% { opacity: 1; } +} +.trek-lowfuel { + animation: trek-lowfuel 2.6s ease-in-out infinite; +} + /* Stagger-Helpers für Listen — Enter-Animation mit Offset */ @keyframes trek-fade-up { from { opacity: 0; transform: translateY(6px); } @@ -508,13 +628,29 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti /* Reduced motion — Emil's Accessibility-Regel: fewer and gentler, not zero */ @media (prefers-reduced-motion: reduce) { .trek-menu-enter, .trek-menu-enter-left, .trek-popover-enter, - .trek-modal-enter, .trek-toast-enter, .trek-stagger > * { + .trek-modal-enter, .trek-toast-enter, .trek-stagger > *, .trek-spill-in { animation: trek-backdrop-enter 120ms ease-out; } + /* Die Reserveleuchte steht still statt zu glimmen — sie bleibt an, was die Aussage + ohnehin ist. */ + .trek-lowfuel { + animation: none; + } + /* Die Bahn bleibt sichtbar, sie laeuft nur nicht mehr. Die Aussage + "hier ist gerade etwas unterwegs" traegt auch im Stillstand. */ + .trek-docsync-lane-run { + animation: none; + } .trek-skeleton { animation: none; background: var(--bg-tertiary); } + /* Die Zzz stehen still statt zu treiben — der Tageswechsel bleibt lesbar. */ + .trek-doze > * { + animation: none; + opacity: 0.6; + transform: none; + } button:not(:disabled):not([data-no-press]):active, [role="button"]:not([aria-disabled="true"]):not([data-no-press]):active, [data-press]:active { @@ -536,6 +672,19 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti --safe-top: env(safe-area-inset-top, 0px); --nav-h: 0px; --bottom-nav-h: 0px; + + /* Layer scale. Anything fixed or sticky picks a step from here instead of + inventing a number, so two overlays can no longer land in the wrong order + by accident. The steps are spaced so today's hardcoded z-indexes keep the + position they already have; each of those moves onto its token whenever + its file gets touched for other reasons. */ + --z-bar: 60; /* BottomNav, page-bound sticky bars */ + --z-nav: 200; /* the desktop navbar */ + --z-modal: 10000; /* shared/Modal, ConfirmDialog */ + --z-overlay: 99990; /* page-owned full-screen overlays */ + --z-notice: 99998; /* app-wide system and release notices */ + --z-toast: 100000; /* toasts, tooltips, context menus */ + --font-system: 'Poppins', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif; /* Secondary "subtext"/caption tier renders in Geist; primary text + headings stay Poppins. */ --font-subtext: 'Geist Sans', 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; @@ -1226,6 +1375,141 @@ img[alt="TREK"] { justify-content: center; } +/* Shared links in the Collab tab: one chip per link, two to a row across the full + width, one to a row where the panel is narrow. The chip is the link; edit, pin and + delete sit quietly in its tail and come forward on hover or keyboard focus. They + keep their width throughout: unfolding them reflowed the row under the pointer, + and a chip that grows as you reach for it is a chip you cannot catch. */ +.collab-link-grid { + container-type: inline-size; +} +.collab-link-grid__cells { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +@container (max-width: 420px) { + .collab-link-grid__cells { grid-template-columns: minmax(0, 1fr); } +} +.collab-link-chip { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + padding: 5px 8px 5px 5px; + border-radius: 999px; + background: var(--bg-secondary); + border: 1px solid var(--border-faint); + box-shadow: var(--shadow-sm); + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, background-color 0.15s ease; +} +.collab-link-chip:hover, .collab-link-chip:focus-within { + transform: translateY(-1px); + box-shadow: var(--shadow-md); + border-color: var(--border-primary); +} +.collab-link-chip--pinned { + background: var(--accent-subtle); + border-color: color-mix(in srgb, var(--accent) 35%, transparent); +} +.collab-link-chip__main { + display: flex; + align-items: center; + gap: 7px; + flex: 1 1 auto; + min-width: 0; + color: var(--text-primary); + text-decoration: none; + outline: none; +} +.collab-link-chip__icon { + position: relative; + display: grid; + place-items: center; + width: 24px; + height: 24px; + flex: 0 0 24px; + border-radius: 50%; + overflow: hidden; + background: var(--bg-card); + color: var(--text-faint); +} +.collab-link-chip__icon img { width: 16px; height: 16px; object-fit: contain; } +.collab-link-chip__icon > * { transition: opacity 0.15s ease; } +/* Reaching for the chip turns its favicon into the open-in-new-tab glyph: the + affordance without a glyph of its own taking room from the title. */ +.collab-link-chip__go { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--text-primary); + opacity: 0; +} +.collab-link-chip__main:hover .collab-link-chip__go, .collab-link-chip__main:focus-visible .collab-link-chip__go { opacity: 1; } +.collab-link-chip__main:hover .collab-link-chip__icon > :not(.collab-link-chip__go), +.collab-link-chip__main:focus-visible .collab-link-chip__icon > :not(.collab-link-chip__go) { opacity: 0; } +/* Title first, host with what is left: a max-content track takes its full width + before the fr track sees any, and shrinks only once the title alone no longer + fits. Two flex items shrank together and both ended in an ellipsis. */ +.collab-link-chip__text { + display: grid; + grid-template-columns: minmax(0, max-content) minmax(0, 1fr); + align-items: baseline; + column-gap: 6px; + min-width: 0; + flex: 1 1 auto; +} +.collab-link-chip__title { + font-size: calc(12.5px * var(--fs-scale-body, 1)); + font-weight: 600; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.collab-link-chip__host { + font-size: calc(10.5px * var(--fs-scale-caption, 1)); + color: var(--text-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.collab-link-chip__actions { + display: flex; + align-items: center; + flex: 0 0 auto; + gap: 1px; + margin-left: auto; + opacity: 0.45; + transition: opacity 0.15s ease; +} +.collab-link-chip:hover .collab-link-chip__actions, .collab-link-chip:focus-within .collab-link-chip__actions { opacity: 1; } +.collab-link-chip__action { + display: grid; + place-items: center; + width: 22px; + height: 22px; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--text-faint); + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; +} +.collab-link-chip__action:hover, .collab-link-chip__action:focus-visible { background: var(--bg-hover); color: var(--text-primary); } +.collab-link-chip__action--on { color: var(--accent); } +/* Fingers, not a pointer: the phone Collab tab mounts this same chip. 22px targets + a pixel apart put delete one slip away from pin, so the hit box grows to the + 30px at a 34px pitch the phone Notes tab uses. The vertical margin gives the + height back, the chip stays 36px. */ +@media (hover: none) { + .collab-link-chip__actions { opacity: 1; gap: 4px; } + .collab-link-chip__action { width: 30px; height: 30px; margin: -4px 0; } +} + /* Markdown in Collab Notes */ .collab-note-md strong, .collab-note-md-full strong { font-weight: 700 !important; } .collab-note-md em, .collab-note-md-full em { font-style: italic !important; } @@ -1246,19 +1530,6 @@ img[alt="TREK"] { .collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; } .collab-note-md-full hr { border: none; border-top: 1px solid var(--border-primary); margin: 0.8em 0; } -/* Note card actions: stacked beside the card, and big enough to aim at. */ -.note-edit-btn { - transition: background-color 0.12s ease, color 0.12s ease; -} -.note-edit-btn:hover { - background: var(--bg-hover); - color: var(--text-primary); -} -.note-edit-btn-danger:hover { - background: rgba(220, 38, 38, 0.13); - color: #dc2626; -} - /* Day-plan header action grid (edit / +transport / note / collapse) */ .dp-day-actions button { color: var(--text-faint); diff --git a/client/src/mobile/components/MBadge.test.tsx b/client/src/mobile/components/MBadge.test.tsx new file mode 100644 index 000000000..5ad7defba --- /dev/null +++ b/client/src/mobile/components/MBadge.test.tsx @@ -0,0 +1,74 @@ +// FE-COMP-MBADGE-001 to FE-COMP-MBADGE-006 +import { describe, expect, it } from 'vitest' +import { render, screen } from '@testing-library/react' +import { Route } from 'lucide-react' +import MBadge from './MBadge' + +describe('MBadge', () => { + it('FE-COMP-MBADGE-001: renders its label in a span and is no control', () => { + render(3 stops) + + const badge = screen.getByText('3 stops') + expect(badge.tagName).toBe('SPAN') + // It sits inside buttons, and a button inside a button is invalid markup. + expect(screen.queryByRole('button')).toBeNull() + }) + + it('FE-COMP-MBADGE-002: is neutral by default on the resting chip tokens, and strong on the active ones', () => { + render(neutral) + const neutral = screen.getByText('neutral') + expect(neutral.className).toContain('bg-[color:var(--m-ic)]') + expect(neutral.className).toContain('border-[color:var(--m-rowbr)]') + expect(neutral.className).not.toContain('bg-m-act') + + render(strong) + const strong = screen.getByText('strong') + expect(strong.className).toContain('bg-m-act') + expect(strong.className).toContain('text-m-actfg') + expect(strong.className).not.toContain('bg-[color:var(--m-ic)]') + }) + + it('FE-COMP-MBADGE-003: sets words in caps by default and leaves a unit symbol alone when asked', () => { + render(Sat 2) + expect(screen.getByText('Sat 2').className).toContain('uppercase') + + render(300 m) + // Uppercase would turn metres into megametres. + expect(screen.getByText('300 m').className).not.toContain('uppercase') + }) + + it('FE-COMP-MBADGE-004: is 18px at xs, the default, and 22px at sm', () => { + render(small) + expect(screen.getByText('small').className).toContain('h-[18px]') + + render(larger) + const larger = screen.getByText('larger') + expect(larger.className).toContain('h-[22px]') + expect(larger.className).toContain('ms-auto') + }) + + it('FE-COMP-MBADGE-005: draws an icon before the label, inside the same pill and out of its name', () => { + render(}>412 km) + + const badge = screen.getByText('412 km') + const icon = badge.firstElementChild as HTMLElement + expect(icon.querySelector('svg')).not.toBeNull() + expect(icon).toHaveAttribute('aria-hidden', 'true') + expect(badge.lastChild?.textContent).toBe('412 km') + }) + + it('FE-COMP-MBADGE-006: keeps a figure on one line, and lets a sentence wrap inside the width it is given', () => { + render(412 km) + const figure = screen.getByText('412 km') + expect(figure.className).toContain('whitespace-nowrap') + expect(figure.className).toContain('h-[18px]') + + render(Still working out the rest of the drive) + const sentence = screen.getByText('Still working out the rest of the drive') + expect(sentence.className).toContain('whitespace-normal') + expect(sentence.className).toContain('max-w-full') + expect(sentence.className).toContain('min-h-[22px]') + expect(sentence.className).not.toContain('whitespace-nowrap') + expect(sentence.className).not.toMatch(/(^|\s)h-\[22px\]/) + }) +}) diff --git a/client/src/mobile/components/MBadge.tsx b/client/src/mobile/components/MBadge.tsx new file mode 100644 index 000000000..f5c52fa01 --- /dev/null +++ b/client/src/mobile/components/MBadge.tsx @@ -0,0 +1,66 @@ +import type { ReactNode } from 'react' + +interface MBadgeProps { + /** + * `neutral` is MChip's resting look and the one to reach for. `strong` is MChip's active + * look, for the single figure a surface is about, and never inside something tappable: + * a filled pill in a button reads as a second control sitting in the first. + */ + tone?: 'neutral' | 'strong' + /** `xs` is 18px, for a second line under a title. `sm` is 22px, for a 34px pill. */ + size?: 'xs' | 'sm' + /** + * Caps with tracking by default, for words. A figure with a unit symbol passes false, + * because m and M are different units, and so does a clock, which has no case to change. + */ + caps?: boolean + /** + * Lets a long sentence break inside the pill instead of running past its container. A + * figure keeps to one line, which is why this is off by default: the pill then stops at + * the width it is given and grows in height with the text. + */ + wrap?: boolean + icon?: ReactNode + className?: string + children: ReactNode +} + +/** + * The non-interactive sibling of MChip: a fact, not a control. + * + * A span, because every place a badge sits in the road trip chrome is itself a button, and + * a button inside a button is markup that no browser and no screen reader agree on. Caps + * with tracking are the desktop day card's DAY_BADGE, set for a phone. + * + * Pills side by side lose the space between them when a screen reader builds a name out of + * them, so a button made of badges carries its own aria-label (see stageBadges.badgeLabel). + */ +export default function MBadge({ + tone = 'neutral', + size = 'xs', + caps = true, + wrap = false, + icon, + className = '', + children, +}: MBadgeProps) { + return ( + + {/* The icon only repeats what the label says, so it stays out of the name. */} + {icon && } + {children} + + ) +} diff --git a/client/src/mobile/components/MBottomNav.tsx b/client/src/mobile/components/MBottomNav.tsx index 43da5b782..63ac1a9e2 100644 --- a/client/src/mobile/components/MBottomNav.tsx +++ b/client/src/mobile/components/MBottomNav.tsx @@ -2,7 +2,8 @@ import { useEffect, useState } from 'react' import { useNavigate, useLocation, useMatch } from 'react-router' import { useSettingsStore } from '../../store/settingsStore' import { useTranslation } from '../../i18n' -import { ChevronRight, MoreHorizontal, Plus, Search } from 'lucide-react' +import { useJourneyStore } from '../../store/journeyStore' +import { ChevronRight, MoreHorizontal, Plus, Search, Upload } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { normalizeAppearance } from '@trek/shared' import { useNavItems, splitMobileNav } from '../../components/Layout/navItems' @@ -18,9 +19,10 @@ interface NavItem { to: string; label: string; icon: LucideIcon } // everywhere else it creates a new trip. Pages pick the intent up from the // query params. The result is unused on /vacay: that screen draws its own centre // FAB, so the dock yields the slot instead (see screenFabSlot below, #1811). -function useCreateAction(): { label: string; run: () => void } { +function useCreateAction(): { label: string; run: () => void; upload?: boolean } { const navigate = useNavigate() const { t } = useTranslation() + const galleryOpen = useJourneyStore(state => state.mobileGalleryOpen) const inTrip = useMatch('/trips/:id') const inJourney = useMatch('/journey/:id') const onJourneyList = useMatch('/journey') @@ -40,7 +42,15 @@ function useCreateAction(): { label: string; run: () => void } { return { label: t('places.addPlace'), run: () => navigate(`/trips/${id}?create=place`) } } if (inJourney) { - return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${inJourney.params.id}?create=entry`) } + // Context-aware per tab, like the trip's "+": the Gallery holds photos, so + // there the one big action is uploading one. Read from sessionStorage + // because the tab is the screen's own state and the dock is a sibling — + // exactly how the trip tabs hand theirs over. + const journeyId = inJourney.params.id + if (galleryOpen) { + return { label: t('common.upload'), run: () => navigate(`/journey/${journeyId}?create=photo`), upload: true } + } + return { label: t('journey.detail.addEntry'), run: () => navigate(`/journey/${journeyId}?create=entry`) } } if (onJourneyList) { return { label: t('journey.new'), run: () => navigate('/journey?create=1') } @@ -174,7 +184,11 @@ export default function MBottomNav() {